Skip to content

Commit 62ad545

Browse files
authored
Merge pull request #10 from openpatch/copilot/add-keyboard-shortcuts
Add keyboard shortcuts for zoom, grid, reset, and cut/copy/paste operations
2 parents e6f8c4c + b7ae8e7 commit 62ad545

2 files changed

Lines changed: 212 additions & 11 deletions

File tree

packages/learningmap/src/LearningMapEditor.tsx

Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function LearningMapEditor({
6464
language = "en",
6565
onChange,
6666
}: LearningMapEditorProps) {
67-
const { screenToFlowPosition } = useReactFlow();
67+
const { screenToFlowPosition, zoomIn, zoomOut, setCenter, fitView, getNodes, getEdges } = useReactFlow();
6868
const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable<RoadmapData>({
6969
settings: {},
7070
version: 1,
@@ -77,6 +77,8 @@ export function LearningMapEditor({
7777
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
7878
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
7979
const [settings, setSettings] = useState<Settings>({ background: { color: "#ffffff" } });
80+
const [showGrid, setShowGrid] = useState(true);
81+
const [clipboard, setClipboard] = useState<{ nodes: Node<NodeData>[]; edges: Edge[] } | null>(null);
8082

8183
// Use language from settings if available, otherwise use prop
8284
const effectiveLanguage = settings?.language || language;
@@ -89,12 +91,22 @@ export function LearningMapEditor({
8991
{ action: t.shortcuts.addTaskNode, shortcut: "Ctrl+A" },
9092
{ action: t.shortcuts.addTopicNode, shortcut: "Ctrl+O" },
9193
{ action: t.shortcuts.addImageNode, shortcut: "Ctrl+I" },
92-
{ action: t.shortcuts.addTextNode, shortcut: "Ctrl+X" },
94+
{ action: t.shortcuts.addTextNode, shortcut: "Ctrl+B" },
9395
{ action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" },
9496
{ action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" },
9597
{ action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" },
9698
{ action: t.shortcuts.selectMultipleNodes, shortcut: "Ctrl+Click or Shift+Drag" },
9799
{ action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" },
100+
{ action: t.shortcuts.zoomIn, shortcut: "Ctrl++" },
101+
{ action: t.shortcuts.zoomOut, shortcut: "Ctrl+-" },
102+
{ action: t.shortcuts.resetZoom, shortcut: "Ctrl+0" },
103+
{ action: t.shortcuts.fitView, shortcut: "Shift+1" },
104+
{ action: t.shortcuts.zoomToSelection, shortcut: "Shift+2" },
105+
{ action: t.shortcuts.toggleGrid, shortcut: "Ctrl+'" },
106+
{ action: t.shortcuts.resetMap, shortcut: "Ctrl+Delete" },
107+
{ action: t.shortcuts.cut, shortcut: "Ctrl+X" },
108+
{ action: t.shortcuts.copy, shortcut: "Ctrl+C" },
109+
{ action: t.shortcuts.paste, shortcut: "Ctrl+V" },
98110
];
99111

100112
const [helpOpen, setHelpOpen] = useState(false);
@@ -500,13 +512,111 @@ export function LearningMapEditor({
500512
setDidUndoRedo(true);
501513
}, [reset]);
502514

515+
const handleCut = useCallback(() => {
516+
const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id));
517+
if (selectedNodes.length > 0) {
518+
const selectedNodeIdSet = new Set(selectedNodeIds);
519+
const relatedEdges = edges.filter(e =>
520+
selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target)
521+
);
522+
setClipboard({ nodes: selectedNodes, edges: relatedEdges });
523+
// Delete the selected nodes
524+
setNodes(nds => nds.filter(n => !selectedNodeIdSet.has(n.id)));
525+
setEdges(eds => eds.filter(e =>
526+
!selectedNodeIdSet.has(e.source) && !selectedNodeIdSet.has(e.target)
527+
));
528+
setSelectedNodeIds([]);
529+
setSaved(false);
530+
}
531+
}, [nodes, edges, selectedNodeIds, setNodes, setEdges, setSelectedNodeIds, setSaved]);
532+
533+
const handleCopy = useCallback(() => {
534+
const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id));
535+
if (selectedNodes.length > 0) {
536+
const selectedNodeIdSet = new Set(selectedNodeIds);
537+
const relatedEdges = edges.filter(e =>
538+
selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target)
539+
);
540+
setClipboard({ nodes: selectedNodes, edges: relatedEdges });
541+
}
542+
}, [nodes, edges, selectedNodeIds]);
543+
544+
const handlePaste = useCallback(() => {
545+
if (!clipboard) return;
546+
547+
// Create a mapping from old node IDs to new node IDs
548+
const idMapping: Record<string, string> = {};
549+
let newNextNodeId = nextNodeId;
550+
551+
const newNodes = clipboard.nodes.map(node => {
552+
const newId = node.id.startsWith('background-node')
553+
? `background-node${newNextNodeId}`
554+
: `node${newNextNodeId}`;
555+
idMapping[node.id] = newId;
556+
newNextNodeId++;
557+
558+
return {
559+
...node,
560+
id: newId,
561+
position: {
562+
x: node.position.x + 50,
563+
y: node.position.y + 50,
564+
},
565+
};
566+
});
567+
568+
const newEdges = clipboard.edges.map((edge, idx) => ({
569+
...edge,
570+
id: `e${Date.now()}-${idx}`,
571+
source: idMapping[edge.source] || edge.source,
572+
target: idMapping[edge.target] || edge.target,
573+
}));
574+
575+
setNodes(nds => [...nds, ...newNodes]);
576+
setEdges(eds => [...eds, ...newEdges]);
577+
setNextNodeId(newNextNodeId);
578+
setSelectedNodeIds(newNodes.map(n => n.id));
579+
setSaved(false);
580+
}, [clipboard, nextNodeId, setNodes, setEdges, setNextNodeId, setSelectedNodeIds, setSaved]);
581+
582+
const handleZoomIn = useCallback(() => {
583+
zoomIn();
584+
}, [zoomIn]);
585+
586+
const handleZoomOut = useCallback(() => {
587+
zoomOut();
588+
}, [zoomOut]);
589+
590+
const handleResetZoom = useCallback(() => {
591+
setCenter(0, 0, { zoom: 1, duration: 300 });
592+
}, [setCenter]);
593+
594+
const handleFitView = useCallback(() => {
595+
fitView({ duration: 300 });
596+
}, [fitView]);
597+
598+
const handleZoomToSelection = useCallback(() => {
599+
if (selectedNodeIds.length > 0) {
600+
fitView({ nodes: selectedNodeIds.map(s => ({ id: s })), duration: 300, padding: 0.2 });
601+
}
602+
}, [selectedNodeIds, fitView]);
603+
604+
const handleToggleGrid = useCallback(() => {
605+
setShowGrid(prev => !prev);
606+
}, []);
607+
608+
const handleResetMap = useCallback(() => {
609+
if (confirm(t.resetMapWarning)) {
610+
setNodes([]);
611+
setEdges([]);
612+
setNextNodeId(1);
613+
setSaved(false);
614+
}
615+
}, [setNodes, setEdges, setNextNodeId, setSaved, t]);
616+
503617
const handleSelectionChange: OnSelectionChangeFunc = useCallback(
504618
({ nodes: selectedNodes }) => {
505-
if (selectedNodes.length > 1) {
506-
setSelectedNodeIds(selectedNodes.map(n => n.id));
507-
} else {
508-
setSelectedNodeIds([]);
509-
}
619+
setSelectedNodeIds(selectedNodes.map(n => n.id));
510620
},
511621
[setSelectedNodeIds]
512622
);
@@ -543,8 +653,8 @@ export function LearningMapEditor({
543653
e.preventDefault();
544654
addNewNode("image");
545655
}
546-
// add text node shortcut
547-
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) {
656+
// add text node shortcut - changed to Ctrl+T
657+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b' && !e.shiftKey) {
548658
e.preventDefault();
549659
addNewNode("text");
550660
}
@@ -563,6 +673,60 @@ export function LearningMapEditor({
563673
e.preventDefault();
564674
toggleDebugMode();
565675
}
676+
677+
// Zoom in shortcut
678+
if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=') && !e.shiftKey) {
679+
e.preventDefault();
680+
handleZoomIn();
681+
}
682+
// Zoom out shortcut
683+
if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_') && !e.shiftKey) {
684+
e.preventDefault();
685+
handleZoomOut();
686+
}
687+
// Reset zoom shortcut
688+
if ((e.ctrlKey || e.metaKey) && e.key === '0' && !e.shiftKey) {
689+
e.preventDefault();
690+
handleResetZoom();
691+
}
692+
// Fit view shortcut
693+
if (e.shiftKey && e.code === 'Digit1' && !e.ctrlKey && !e.metaKey) {
694+
e.preventDefault();
695+
handleFitView();
696+
}
697+
// Zoom to selection shortcut
698+
if (e.shiftKey && e.code === 'Digit2' && !e.ctrlKey && !e.metaKey) {
699+
e.preventDefault();
700+
handleZoomToSelection();
701+
}
702+
703+
console.log(e);
704+
// Toggle grid shortcut
705+
if ((e.ctrlKey || e.metaKey) && e.code === "Backslash") {
706+
e.preventDefault();
707+
handleToggleGrid();
708+
}
709+
// Reset map shortcut
710+
if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') {
711+
e.preventDefault();
712+
handleResetMap();
713+
}
714+
// Cut shortcut
715+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) {
716+
e.preventDefault();
717+
handleCut();
718+
}
719+
// Copy shortcut
720+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && !e.shiftKey) {
721+
e.preventDefault();
722+
handleCopy();
723+
}
724+
// Paste shortcut
725+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v' && !e.shiftKey) {
726+
e.preventDefault();
727+
handlePaste();
728+
}
729+
566730
// Dismiss with Escape
567731
if (helpOpen && e.key === 'Escape') {
568732
setHelpOpen(false);
@@ -572,7 +736,9 @@ export function LearningMapEditor({
572736
return () => {
573737
window.removeEventListener("keydown", handleKeyDown);
574738
};
575-
}, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]);
739+
}, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode,
740+
handleZoomIn, handleZoomOut, handleResetZoom, handleFitView, handleZoomToSelection, handleToggleGrid,
741+
handleResetMap, handleCut, handleCopy, handlePaste]);
576742

577743
return (
578744
<>
@@ -629,7 +795,7 @@ export function LearningMapEditor({
629795
nodesConnectable={true}
630796
colorMode={colorMode}
631797
>
632-
<Background />
798+
{showGrid && <Background />}
633799
<Controls>
634800
<ControlButton title={t.undo} disabled={!canUndo} onClick={handleUndo}>
635801
<Undo />

packages/learningmap/src/translations.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ export interface Translations {
4444
toggleDebugMode: string;
4545
selectMultipleNodes: string;
4646
showHelp: string;
47+
zoomIn: string;
48+
zoomOut: string;
49+
resetZoom: string;
50+
fitView: string;
51+
zoomToSelection: string;
52+
toggleGrid: string;
53+
resetMap: string;
54+
cut: string;
55+
copy: string;
56+
paste: string;
4757
};
4858

4959
// Drawer titles
@@ -101,6 +111,7 @@ export interface Translations {
101111

102112
// Messages
103113
openFileWarning: string;
114+
resetMapWarning: string;
104115
failedToLoadFile: string;
105116
failedToExportSVG: string;
106117

@@ -211,6 +222,16 @@ const en: Translations = {
211222
toggleDebugMode: "Toggle Debug Mode",
212223
selectMultipleNodes: "Select Multiple Nodes",
213224
showHelp: "Show Help",
225+
zoomIn: "Zoom In",
226+
zoomOut: "Zoom Out",
227+
resetZoom: "Reset Zoom",
228+
fitView: "Fit View",
229+
zoomToSelection: "Zoom to Selection",
230+
toggleGrid: "Toggle Grid",
231+
resetMap: "Reset Map",
232+
cut: "Cut",
233+
copy: "Copy",
234+
paste: "Paste",
214235
},
215236

216237
// Drawer titles
@@ -268,6 +289,8 @@ const en: Translations = {
268289

269290
// Messages
270291
openFileWarning: "Opening a file will replace your current map. Continue?",
292+
resetMapWarning:
293+
"Are you sure you want to reset the map? This action cannot be undone.",
271294
failedToLoadFile:
272295
"Failed to load the file. Please make sure it is a valid roadmap JSON file.",
273296
failedToExportSVG: "Failed to export SVG: ",
@@ -382,6 +405,16 @@ const de: Translations = {
382405
toggleDebugMode: "Debug-Modus umschalten",
383406
selectMultipleNodes: "Mehrere Knoten auswählen",
384407
showHelp: "Hilfe anzeigen",
408+
zoomIn: "Vergrößern",
409+
zoomOut: "Verkleinern",
410+
resetZoom: "Zoom zurücksetzen",
411+
fitView: "Alles anzeigen",
412+
zoomToSelection: "Auswahl anzeigen",
413+
toggleGrid: "Raster umschalten",
414+
resetMap: "Karte zurücksetzen",
415+
cut: "Ausschneiden",
416+
copy: "Kopieren",
417+
paste: "Einfügen",
385418
},
386419

387420
// Drawer titles
@@ -440,6 +473,8 @@ const de: Translations = {
440473
// Messages
441474
openFileWarning:
442475
"Das Öffnen einer Datei ersetzt Ihre aktuelle Karte. Fortfahren?",
476+
resetMapWarning:
477+
"Sind Sie sicher, dass Sie die Karte zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
443478
failedToLoadFile:
444479
"Datei konnte nicht geladen werden. Bitte stellen Sie sicher, dass es sich um eine gültige Roadmap-JSON-Datei handelt.",
445480
failedToExportSVG: "SVG-Export fehlgeschlagen: ",

0 commit comments

Comments
 (0)