Skip to content

Commit 761eb36

Browse files
Copilotmikebarkmin
andcommitted
Add configurable keybindings prop to LearningMapEditor
- Add KeyBinding and KeyBindings types to define custom keyboard shortcuts - Update KeyboardShortcuts component to accept and use custom keybindings - Add keyBindings prop to LearningMapEditor component - VS Code extension now disables Ctrl+S (save) since VS Code handles saving - Export KeyBinding and KeyBindings types from package - Update web-component to support key-bindings attribute - Document keyboard shortcuts and customization in React docs - Document key-bindings attribute in web-component docs Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com>
1 parent cd2261b commit 761eb36

8 files changed

Lines changed: 233 additions & 76 deletions

File tree

docs/book/react/index.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,63 @@ function App() {
4848
| `jsonStore` | `string` | `"https://json.openpatch.org"` | URL for JSON storage service |
4949
| `disableSharing` | `boolean` | `false` | Hide the share button (useful in environments without external sharing) |
5050
| `disableFileOperations` | `boolean` | `false` | Hide open and download buttons (useful when file operations are handled externally) |
51+
| `keyBindings` | `Partial<KeyBindings>` | `undefined` | Custom keyboard shortcuts (see [Keyboard Shortcuts](#keyboard-shortcuts)) |
52+
53+
#### Keyboard Shortcuts
54+
55+
The editor includes many keyboard shortcuts for efficient editing. You can customize these shortcuts by providing a `keyBindings` prop:
56+
57+
```tsx
58+
import { LearningMapEditor, KeyBindings } from '@learningmap/learningmap';
59+
60+
const customKeyBindings: Partial<KeyBindings> = {
61+
save: undefined, // Disable save shortcut
62+
addTaskNode: { key: 't', ctrl: true }, // Change from Ctrl+1 to Ctrl+T
63+
};
64+
65+
<LearningMapEditor keyBindings={customKeyBindings} />
66+
```
67+
68+
**Default Keyboard Shortcuts:**
69+
70+
| Action | Default Shortcut | KeyBinding Property |
71+
|--------|-----------------|-------------------|
72+
| Add Task Node | `Ctrl+1` | `addTaskNode` |
73+
| Add Topic Node | `Ctrl+2` | `addTopicNode` |
74+
| Add Image Node | `Ctrl+3` | `addImageNode` |
75+
| Add Text Node | `Ctrl+4` | `addTextNode` |
76+
| Save | `Ctrl+S` | `save` |
77+
| Undo | `Ctrl+Z` | `undo` |
78+
| Redo | `Ctrl+Y` | `redo` |
79+
| Toggle Preview | `Ctrl+P` | `togglePreview` |
80+
| Toggle Debug | `Ctrl+D` | `toggleDebug` |
81+
| Zoom In | `Ctrl++` | `zoomIn` |
82+
| Zoom Out | `Ctrl+-` | `zoomOut` |
83+
| Reset Zoom | `Ctrl+0` | `resetZoom` |
84+
| Toggle Grid | `Ctrl+'` | `toggleGrid` |
85+
| Reset Map | `Ctrl+Delete` | `resetMap` |
86+
| Cut | `Ctrl+X` | `cut` |
87+
| Copy | `Ctrl+C` | `copy` |
88+
| Paste | `Ctrl+V` | `paste` |
89+
| Select All | `Ctrl+A` | `selectAll` |
90+
| Fit View | `Shift+!` | `fitView` |
91+
| Zoom to Selection | `Shift+@` | `zoomToSelection` |
92+
| Delete Selected | `Delete` | `deleteSelected` |
93+
| Help | `Ctrl+?` | `help` |
94+
95+
**KeyBinding Type:**
96+
97+
```typescript
98+
interface KeyBinding {
99+
key: string;
100+
ctrl?: boolean;
101+
shift?: boolean;
102+
alt?: boolean;
103+
meta?: boolean;
104+
}
105+
```
106+
107+
To disable a shortcut, set it to `undefined`. To customize, provide a `KeyBinding` object with the desired key combination.
51108

52109
#### Features
53110

docs/book/web-component/index.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ Interactive editor for creating and editing learning maps.
9999
| `json-store` | `string` | `"https://json.openpatch.org"` | URL for JSON storage service |
100100
| `disable-sharing` | `boolean` | `false` | Hide the share button (useful in environments without external sharing) |
101101
| `disable-file-operations` | `boolean` | `false` | Hide open and download buttons (useful when file operations are handled externally) |
102+
| `key-bindings` | `string` | `undefined` | JSON string of custom keyboard shortcuts (see React docs for KeyBindings type) |
103+
104+
#### Customizing Keyboard Shortcuts
105+
106+
You can customize keyboard shortcuts by passing a JSON string to the `key-bindings` attribute:
107+
108+
```html
109+
<hyperbook-learningmap-editor
110+
key-bindings='{"save": null, "addTaskNode": {"key": "t", "ctrl": true}}'
111+
></hyperbook-learningmap-editor>
112+
```
113+
114+
This example disables the save shortcut and changes the "add task" shortcut from `Ctrl+1` to `Ctrl+T`. See the React package documentation for a complete list of available shortcuts and the KeyBindings type definition.
102115

103116
#### Events
104117

packages/learningmap/src/KeyboardShortcuts.tsx

Lines changed: 113 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,58 @@ import { useEffect } from "react";
22
import { useReactFlow } from "@xyflow/react";
33
import { useEditorStore, useTemporalStore } from "./editorStore";
44
import { Node } from "@xyflow/react";
5-
import { NodeData } from "./types";
5+
import { NodeData, KeyBindings, KeyBinding } from "./types";
66
import { getTranslations } from "./translations";
77

88
interface KeyboardShortcutsProps {
99
jsonStore?: string;
10+
keyBindings?: Partial<KeyBindings>;
1011
}
1112

12-
export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }: KeyboardShortcutsProps) => {
13+
// Default keybindings
14+
const defaultKeyBindings: KeyBindings = {
15+
addTaskNode: { key: '1', ctrl: true },
16+
addTopicNode: { key: '2', ctrl: true },
17+
addImageNode: { key: '3', ctrl: true },
18+
addTextNode: { key: '4', ctrl: true },
19+
save: { key: 's', ctrl: true },
20+
undo: { key: 'z', ctrl: true },
21+
redo: { key: 'y', ctrl: true },
22+
help: { key: '?', ctrl: true },
23+
togglePreview: { key: 'p', ctrl: true },
24+
toggleDebug: { key: 'd', ctrl: true },
25+
zoomIn: { key: '+', ctrl: true },
26+
zoomOut: { key: '-', ctrl: true },
27+
resetZoom: { key: '0', ctrl: true },
28+
toggleGrid: { key: "'", ctrl: true },
29+
resetMap: { key: 'Delete', ctrl: true },
30+
cut: { key: 'x', ctrl: true },
31+
copy: { key: 'c', ctrl: true },
32+
paste: { key: 'v', ctrl: true },
33+
selectAll: { key: 'a', ctrl: true },
34+
fitView: { key: '!', shift: true },
35+
zoomToSelection: { key: '@', shift: true },
36+
deleteSelected: { key: 'Delete' },
37+
};
38+
39+
const matchesKeyBinding = (e: KeyboardEvent, binding: KeyBinding | undefined): boolean => {
40+
if (!binding) return false;
41+
42+
const keyMatches = e.key.toLowerCase() === binding.key.toLowerCase();
43+
const ctrlMatches = binding.ctrl ? (e.ctrlKey || e.metaKey) : !(e.ctrlKey || e.metaKey);
44+
const shiftMatches = binding.shift ? e.shiftKey : !e.shiftKey;
45+
const altMatches = binding.alt ? e.altKey : !e.altKey;
46+
47+
return keyMatches && ctrlMatches && shiftMatches && altMatches;
48+
};
49+
50+
export const KeyboardShortcuts = ({
51+
jsonStore = "https://json.openpatch.org",
52+
keyBindings: customKeyBindings = {}
53+
}: KeyboardShortcutsProps) => {
54+
// Merge custom keybindings with defaults
55+
const keyBindings = { ...defaultKeyBindings, ...customKeyBindings };
56+
1357
const { zoomIn, zoomOut, setCenter, fitView, screenToFlowPosition } = useReactFlow();
1458

1559
// Get store state
@@ -167,74 +211,72 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }:
167211
if (drawerOpen || edgeDrawerOpen || settingsDrawerOpen) {
168212
return; // Ignore shortcuts when any drawer is open
169213
}
170-
if (e.ctrlKey || e.metaKey) {
171-
if (e.key === '1') {
172-
e.preventDefault();
173-
onAddNode("task");
174-
} else if (e.key === '2') {
175-
e.preventDefault();
176-
onAddNode("topic");
177-
} else if (e.key === '3') {
178-
e.preventDefault();
179-
onAddNode("image");
180-
} else if (e.key === '4') {
181-
e.preventDefault();
182-
onAddNode("text");
183-
} else if (e.key === 's') {
184-
e.preventDefault();
185-
onSave();
186-
} else if (e.key === 'z' && !e.shiftKey) {
187-
e.preventDefault();
188-
undo();
189-
} else if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) {
190-
e.preventDefault();
191-
redo();
192-
} else if ((e.key === '?' || (e.shiftKey && e.key === '/'))) {
193-
e.preventDefault();
194-
setHelpOpen(!helpOpen);
195-
} else if (e.key.toLowerCase() === 'p' && !e.shiftKey) {
196-
e.preventDefault();
197-
onTogglePreview();
198-
} else if (e.key.toLowerCase() === 'd' && !e.shiftKey) {
199-
e.preventDefault();
200-
onToggleDebug();
201-
} else if (e.key === '+' || e.key === '=') {
202-
e.preventDefault();
203-
onZoomIn();
204-
} else if (e.key === '-') {
205-
e.preventDefault();
206-
onZoomOut();
207-
} else if (e.key === '0') {
208-
e.preventDefault();
209-
onResetZoom();
210-
} else if (e.key === "'") {
211-
e.preventDefault();
212-
onToggleGrid();
213-
} else if (e.key === 'Delete') {
214-
e.preventDefault();
215-
onResetMap();
216-
} else if (e.key.toLowerCase() === 'x') {
217-
e.preventDefault();
218-
onCut();
219-
} else if (e.key.toLowerCase() === 'c') {
220-
e.preventDefault();
221-
onCopy();
222-
} else if (e.key.toLowerCase() === 'v') {
223-
e.preventDefault();
224-
onPaste();
225-
} else if (e.key.toLowerCase() === 'a') {
226-
e.preventDefault();
227-
onSelectAll();
228-
}
229-
} else if (e.shiftKey) {
230-
if (e.key === '!') {
231-
e.preventDefault();
232-
onFitView();
233-
} else if (e.key === '@') {
234-
e.preventDefault();
235-
onZoomToSelection();
236-
}
237-
} else if (e.key === 'Delete') {
214+
215+
// Check each keybinding
216+
if (matchesKeyBinding(e, keyBindings.addTaskNode)) {
217+
e.preventDefault();
218+
onAddNode("task");
219+
} else if (matchesKeyBinding(e, keyBindings.addTopicNode)) {
220+
e.preventDefault();
221+
onAddNode("topic");
222+
} else if (matchesKeyBinding(e, keyBindings.addImageNode)) {
223+
e.preventDefault();
224+
onAddNode("image");
225+
} else if (matchesKeyBinding(e, keyBindings.addTextNode)) {
226+
e.preventDefault();
227+
onAddNode("text");
228+
} else if (matchesKeyBinding(e, keyBindings.save)) {
229+
e.preventDefault();
230+
onSave();
231+
} else if (matchesKeyBinding(e, keyBindings.undo)) {
232+
e.preventDefault();
233+
undo();
234+
} else if (matchesKeyBinding(e, keyBindings.redo)) {
235+
e.preventDefault();
236+
redo();
237+
} else if (matchesKeyBinding(e, keyBindings.help)) {
238+
e.preventDefault();
239+
setHelpOpen(!helpOpen);
240+
} else if (matchesKeyBinding(e, keyBindings.togglePreview)) {
241+
e.preventDefault();
242+
onTogglePreview();
243+
} else if (matchesKeyBinding(e, keyBindings.toggleDebug)) {
244+
e.preventDefault();
245+
onToggleDebug();
246+
} else if (matchesKeyBinding(e, keyBindings.zoomIn)) {
247+
e.preventDefault();
248+
onZoomIn();
249+
} else if (matchesKeyBinding(e, keyBindings.zoomOut)) {
250+
e.preventDefault();
251+
onZoomOut();
252+
} else if (matchesKeyBinding(e, keyBindings.resetZoom)) {
253+
e.preventDefault();
254+
onResetZoom();
255+
} else if (matchesKeyBinding(e, keyBindings.toggleGrid)) {
256+
e.preventDefault();
257+
onToggleGrid();
258+
} else if (matchesKeyBinding(e, keyBindings.resetMap)) {
259+
e.preventDefault();
260+
onResetMap();
261+
} else if (matchesKeyBinding(e, keyBindings.cut)) {
262+
e.preventDefault();
263+
onCut();
264+
} else if (matchesKeyBinding(e, keyBindings.copy)) {
265+
e.preventDefault();
266+
onCopy();
267+
} else if (matchesKeyBinding(e, keyBindings.paste)) {
268+
e.preventDefault();
269+
onPaste();
270+
} else if (matchesKeyBinding(e, keyBindings.selectAll)) {
271+
e.preventDefault();
272+
onSelectAll();
273+
} else if (matchesKeyBinding(e, keyBindings.fitView)) {
274+
e.preventDefault();
275+
onFitView();
276+
} else if (matchesKeyBinding(e, keyBindings.zoomToSelection)) {
277+
e.preventDefault();
278+
onZoomToSelection();
279+
} else if (matchesKeyBinding(e, keyBindings.deleteSelected)) {
238280
e.preventDefault();
239281
onDeleteSelected();
240282
}
@@ -246,7 +288,7 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }:
246288
};
247289
}, [onAddNode, onDeleteSelected, onSave, undo, redo, helpOpen, setHelpOpen, onTogglePreview, onToggleDebug,
248290
onZoomIn, onZoomOut, onResetZoom, onFitView, onZoomToSelection, onToggleGrid,
249-
onResetMap, onCut, onCopy, onPaste, onSelectAll, drawerOpen, edgeDrawerOpen, settingsDrawerOpen]);
291+
onResetMap, onCut, onCopy, onPaste, onSelectAll, drawerOpen, edgeDrawerOpen, settingsDrawerOpen, keyBindings]);
250292

251293
return null;
252294
};

packages/learningmap/src/LearningMapEditor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
ReactFlowProvider,
33
} from "@xyflow/react";
4-
import { RoadmapData } from "./types";
4+
import { RoadmapData, KeyBindings } from "./types";
55
import { EditorToolbar } from "./EditorToolbar";
66
import { LearningMap } from "./LearningMap";
77
import { useEditorStore } from "./editorStore";
@@ -17,6 +17,7 @@ export interface LearningMapEditorProps {
1717
jsonStore?: string;
1818
disableSharing?: boolean;
1919
disableFileOperations?: boolean;
20+
keyBindings?: Partial<KeyBindings>;
2021
}
2122

2223
export function LearningMapEditor({
@@ -25,6 +26,7 @@ export function LearningMapEditor({
2526
jsonStore = "https://json.openpatch.org",
2627
disableSharing = false,
2728
disableFileOperations = false,
29+
keyBindings,
2830
}: LearningMapEditorProps) {
2931
// Only get minimal state needed in this component
3032
const nodes = useEditorStore(state => state.nodes);
@@ -65,7 +67,7 @@ export function LearningMapEditor({
6567
return (
6668
<>
6769
{/* Keyboard shortcuts handler */}
68-
<KeyboardShortcuts jsonStore={jsonStore} />
70+
<KeyboardShortcuts jsonStore={jsonStore} keyBindings={keyBindings} />
6971

7072
{/* Toolbar */}
7173
<EditorToolbar defaultLanguage={language} disableSharing={disableSharing} disableFileOperations={disableFileOperations} />

packages/learningmap/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import LearningMap from "./LearningMap";
22
import LearningMapEditor from "./LearningMapEditor";
33

4-
export type { RoadmapData, RoadmapState } from "./types";
4+
export type { RoadmapData, RoadmapState, KeyBindings, KeyBinding } from "./types";
55
export type { LearningMapProps } from "./LearningMap";
66
export type { LearningMapEditorProps } from "./LearningMapEditor";
77
export { LearningMap, LearningMapEditor };

packages/learningmap/src/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,36 @@ export type HelperLine = {
110110
color?: string;
111111
anchorName: string;
112112
};
113+
114+
export interface KeyBinding {
115+
key: string;
116+
ctrl?: boolean;
117+
shift?: boolean;
118+
alt?: boolean;
119+
meta?: boolean;
120+
}
121+
122+
export interface KeyBindings {
123+
addTaskNode?: KeyBinding;
124+
addTopicNode?: KeyBinding;
125+
addImageNode?: KeyBinding;
126+
addTextNode?: KeyBinding;
127+
save?: KeyBinding;
128+
undo?: KeyBinding;
129+
redo?: KeyBinding;
130+
help?: KeyBinding;
131+
togglePreview?: KeyBinding;
132+
toggleDebug?: KeyBinding;
133+
zoomIn?: KeyBinding;
134+
zoomOut?: KeyBinding;
135+
resetZoom?: KeyBinding;
136+
toggleGrid?: KeyBinding;
137+
resetMap?: KeyBinding;
138+
cut?: KeyBinding;
139+
copy?: KeyBinding;
140+
paste?: KeyBinding;
141+
selectAll?: KeyBinding;
142+
fitView?: KeyBinding;
143+
zoomToSelection?: KeyBinding;
144+
deleteSelected?: KeyBinding;
145+
}

packages/web-component/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const LearningmapEditorWC = r2wc(LearningMapEditor, {
2323
jsonStore: "string",
2424
disableSharing: "boolean",
2525
disableFileOperations: "boolean",
26+
keyBindings: "json",
2627
onChange: "function",
2728
},
2829
events: {

0 commit comments

Comments
 (0)