From f4765fe12dad63e4752dcefdc65ec37e83bd2165 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:12:50 +0000 Subject: [PATCH 1/4] Initial plan From 7e9660228c5bd2deecd90aa7025ac05cf41a4495 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:21:55 +0000 Subject: [PATCH 2/4] Add keyboard shortcuts for zoom, grid, reset, cut/copy/paste Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../learningmap/src/LearningMapEditor.tsx | 180 +++++++++++++++++- packages/learningmap/src/translations.ts | 30 +++ 2 files changed, 204 insertions(+), 6 deletions(-) diff --git a/packages/learningmap/src/LearningMapEditor.tsx b/packages/learningmap/src/LearningMapEditor.tsx index c66e747..abe90c0 100644 --- a/packages/learningmap/src/LearningMapEditor.tsx +++ b/packages/learningmap/src/LearningMapEditor.tsx @@ -64,7 +64,7 @@ export function LearningMapEditor({ language = "en", onChange, }: LearningMapEditorProps) { - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, zoomIn, zoomOut, setCenter, fitView, getNodes, getEdges } = useReactFlow(); const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable({ settings: {}, version: 1, @@ -77,6 +77,8 @@ export function LearningMapEditor({ const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [settings, setSettings] = useState({ background: { color: "#ffffff" } }); + const [showGrid, setShowGrid] = useState(true); + const [clipboard, setClipboard] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null); // Use language from settings if available, otherwise use prop const effectiveLanguage = settings?.language || language; @@ -89,12 +91,22 @@ export function LearningMapEditor({ { action: t.shortcuts.addTaskNode, shortcut: "Ctrl+A" }, { action: t.shortcuts.addTopicNode, shortcut: "Ctrl+O" }, { action: t.shortcuts.addImageNode, shortcut: "Ctrl+I" }, - { action: t.shortcuts.addTextNode, shortcut: "Ctrl+X" }, + { action: t.shortcuts.addTextNode, shortcut: "Ctrl+T" }, { action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" }, { action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" }, { action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" }, { action: t.shortcuts.selectMultipleNodes, shortcut: "Ctrl+Click or Shift+Drag" }, { action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" }, + { action: t.shortcuts.zoomIn, shortcut: "Ctrl++" }, + { action: t.shortcuts.zoomOut, shortcut: "Ctrl+-" }, + { action: t.shortcuts.resetZoom, shortcut: "Ctrl+0" }, + { action: t.shortcuts.fitView, shortcut: "Shift+1" }, + { action: t.shortcuts.zoomToSelection, shortcut: "Shift+2" }, + { action: t.shortcuts.toggleGrid, shortcut: "Ctrl+'" }, + { action: t.shortcuts.resetMap, shortcut: "Ctrl+Delete" }, + { action: t.shortcuts.cut, shortcut: "Ctrl+X" }, + { action: t.shortcuts.copy, shortcut: "Ctrl+C" }, + { action: t.shortcuts.paste, shortcut: "Ctrl+V" }, ]; const [helpOpen, setHelpOpen] = useState(false); @@ -500,6 +512,108 @@ export function LearningMapEditor({ setDidUndoRedo(true); }, [reset]); + const handleCut = useCallback(() => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + const selectedNodeIdSet = new Set(selectedNodeIds); + const relatedEdges = edges.filter(e => + selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) + ); + setClipboard({ nodes: selectedNodes, edges: relatedEdges }); + // Delete the selected nodes + setNodes(nds => nds.filter(n => !selectedNodeIdSet.has(n.id))); + setEdges(eds => eds.filter(e => + !selectedNodeIdSet.has(e.source) && !selectedNodeIdSet.has(e.target) + )); + setSelectedNodeIds([]); + setSaved(false); + } + }, [nodes, edges, selectedNodeIds, setNodes, setEdges, setSelectedNodeIds, setSaved]); + + const handleCopy = useCallback(() => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + const selectedNodeIdSet = new Set(selectedNodeIds); + const relatedEdges = edges.filter(e => + selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) + ); + setClipboard({ nodes: selectedNodes, edges: relatedEdges }); + } + }, [nodes, edges, selectedNodeIds]); + + const handlePaste = useCallback(() => { + if (!clipboard) return; + + // Create a mapping from old node IDs to new node IDs + const idMapping: Record = {}; + let newNextNodeId = nextNodeId; + + const newNodes = clipboard.nodes.map(node => { + const newId = node.id.startsWith('background-node') + ? `background-node${newNextNodeId}` + : `node${newNextNodeId}`; + idMapping[node.id] = newId; + newNextNodeId++; + + return { + ...node, + id: newId, + position: { + x: node.position.x + 50, + y: node.position.y + 50, + }, + }; + }); + + const newEdges = clipboard.edges.map((edge, idx) => ({ + ...edge, + id: `e${Date.now()}-${idx}`, + source: idMapping[edge.source] || edge.source, + target: idMapping[edge.target] || edge.target, + })); + + setNodes(nds => [...nds, ...newNodes]); + setEdges(eds => [...eds, ...newEdges]); + setNextNodeId(newNextNodeId); + setSelectedNodeIds(newNodes.map(n => n.id)); + setSaved(false); + }, [clipboard, nextNodeId, setNodes, setEdges, setNextNodeId, setSelectedNodeIds, setSaved]); + + const handleZoomIn = useCallback(() => { + zoomIn(); + }, [zoomIn]); + + const handleZoomOut = useCallback(() => { + zoomOut(); + }, [zoomOut]); + + const handleResetZoom = useCallback(() => { + setCenter(0, 0, { zoom: 1, duration: 300 }); + }, [setCenter]); + + const handleFitView = useCallback(() => { + fitView({ duration: 300 }); + }, [fitView]); + + const handleZoomToSelection = useCallback(() => { + if (selectedNodeIds.length > 0) { + fitView({ nodes: selectedNodeIds, duration: 300, padding: 0.2 }); + } + }, [selectedNodeIds, fitView]); + + const handleToggleGrid = useCallback(() => { + setShowGrid(prev => !prev); + }, []); + + const handleResetMap = useCallback(() => { + if (confirm(t.openFileWarning)) { + setNodes([]); + setEdges([]); + setNextNodeId(1); + setSaved(false); + } + }, [setNodes, setEdges, setNextNodeId, setSaved, t]); + const handleSelectionChange: OnSelectionChangeFunc = useCallback( ({ nodes: selectedNodes }) => { if (selectedNodes.length > 1) { @@ -543,8 +657,8 @@ export function LearningMapEditor({ e.preventDefault(); addNewNode("image"); } - // add text node shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { + // add text node shortcut - changed to Ctrl+T + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 't' && !e.shiftKey) { e.preventDefault(); addNewNode("text"); } @@ -563,6 +677,58 @@ export function LearningMapEditor({ e.preventDefault(); toggleDebugMode(); } + + // Zoom in shortcut + if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=') && !e.shiftKey) { + e.preventDefault(); + handleZoomIn(); + } + // Zoom out shortcut + if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_') && !e.shiftKey) { + e.preventDefault(); + handleZoomOut(); + } + // Reset zoom shortcut + if ((e.ctrlKey || e.metaKey) && e.key === '0' && !e.shiftKey) { + e.preventDefault(); + handleResetZoom(); + } + // Fit view shortcut + if (e.shiftKey && e.key === '1' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleFitView(); + } + // Zoom to selection shortcut + if (e.shiftKey && e.key === '2' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleZoomToSelection(); + } + // Toggle grid shortcut + if ((e.ctrlKey || e.metaKey) && e.key === "'" && !e.shiftKey) { + e.preventDefault(); + handleToggleGrid(); + } + // Reset map shortcut + if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') { + e.preventDefault(); + handleResetMap(); + } + // Cut shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { + e.preventDefault(); + handleCut(); + } + // Copy shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && !e.shiftKey) { + e.preventDefault(); + handleCopy(); + } + // Paste shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v' && !e.shiftKey) { + e.preventDefault(); + handlePaste(); + } + // Dismiss with Escape if (helpOpen && e.key === 'Escape') { setHelpOpen(false); @@ -572,7 +738,9 @@ export function LearningMapEditor({ return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]); + }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode, + handleZoomIn, handleZoomOut, handleResetZoom, handleFitView, handleZoomToSelection, handleToggleGrid, + handleResetMap, handleCut, handleCopy, handlePaste]); return ( <> @@ -629,7 +797,7 @@ export function LearningMapEditor({ nodesConnectable={true} colorMode={colorMode} > - + {showGrid && } diff --git a/packages/learningmap/src/translations.ts b/packages/learningmap/src/translations.ts index b4c0b92..fcbb24b 100644 --- a/packages/learningmap/src/translations.ts +++ b/packages/learningmap/src/translations.ts @@ -44,6 +44,16 @@ export interface Translations { toggleDebugMode: string; selectMultipleNodes: string; showHelp: string; + zoomIn: string; + zoomOut: string; + resetZoom: string; + fitView: string; + zoomToSelection: string; + toggleGrid: string; + resetMap: string; + cut: string; + copy: string; + paste: string; }; // Drawer titles @@ -211,6 +221,16 @@ const en: Translations = { toggleDebugMode: "Toggle Debug Mode", selectMultipleNodes: "Select Multiple Nodes", showHelp: "Show Help", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", + resetZoom: "Reset Zoom", + fitView: "Fit View", + zoomToSelection: "Zoom to Selection", + toggleGrid: "Toggle Grid", + resetMap: "Reset Map", + cut: "Cut", + copy: "Copy", + paste: "Paste", }, // Drawer titles @@ -382,6 +402,16 @@ const de: Translations = { toggleDebugMode: "Debug-Modus umschalten", selectMultipleNodes: "Mehrere Knoten auswählen", showHelp: "Hilfe anzeigen", + zoomIn: "Vergrößern", + zoomOut: "Verkleinern", + resetZoom: "Zoom zurücksetzen", + fitView: "Alles anzeigen", + zoomToSelection: "Auswahl anzeigen", + toggleGrid: "Raster umschalten", + resetMap: "Karte zurücksetzen", + cut: "Ausschneiden", + copy: "Kopieren", + paste: "Einfügen", }, // Drawer titles From 8ddd5565c6d9cc88bca746f1f3e72f07961fe67a Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Mon, 13 Oct 2025 00:23:02 +0200 Subject: [PATCH 3/4] fix keyboard shortcuts --- .../learningmap/src/LearningMapEditor.tsx | 48 +++++++++---------- packages/learningmap/src/translations.ts | 5 ++ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/learningmap/src/LearningMapEditor.tsx b/packages/learningmap/src/LearningMapEditor.tsx index abe90c0..8e74ee6 100644 --- a/packages/learningmap/src/LearningMapEditor.tsx +++ b/packages/learningmap/src/LearningMapEditor.tsx @@ -516,13 +516,13 @@ export function LearningMapEditor({ const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); if (selectedNodes.length > 0) { const selectedNodeIdSet = new Set(selectedNodeIds); - const relatedEdges = edges.filter(e => + const relatedEdges = edges.filter(e => selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) ); setClipboard({ nodes: selectedNodes, edges: relatedEdges }); // Delete the selected nodes setNodes(nds => nds.filter(n => !selectedNodeIdSet.has(n.id))); - setEdges(eds => eds.filter(e => + setEdges(eds => eds.filter(e => !selectedNodeIdSet.has(e.source) && !selectedNodeIdSet.has(e.target) )); setSelectedNodeIds([]); @@ -534,7 +534,7 @@ export function LearningMapEditor({ const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); if (selectedNodes.length > 0) { const selectedNodeIdSet = new Set(selectedNodeIds); - const relatedEdges = edges.filter(e => + const relatedEdges = edges.filter(e => selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) ); setClipboard({ nodes: selectedNodes, edges: relatedEdges }); @@ -543,18 +543,18 @@ export function LearningMapEditor({ const handlePaste = useCallback(() => { if (!clipboard) return; - + // Create a mapping from old node IDs to new node IDs const idMapping: Record = {}; let newNextNodeId = nextNodeId; - + const newNodes = clipboard.nodes.map(node => { - const newId = node.id.startsWith('background-node') - ? `background-node${newNextNodeId}` + const newId = node.id.startsWith('background-node') + ? `background-node${newNextNodeId}` : `node${newNextNodeId}`; idMapping[node.id] = newId; newNextNodeId++; - + return { ...node, id: newId, @@ -564,14 +564,14 @@ export function LearningMapEditor({ }, }; }); - + const newEdges = clipboard.edges.map((edge, idx) => ({ ...edge, id: `e${Date.now()}-${idx}`, source: idMapping[edge.source] || edge.source, target: idMapping[edge.target] || edge.target, })); - + setNodes(nds => [...nds, ...newNodes]); setEdges(eds => [...eds, ...newEdges]); setNextNodeId(newNextNodeId); @@ -597,7 +597,7 @@ export function LearningMapEditor({ const handleZoomToSelection = useCallback(() => { if (selectedNodeIds.length > 0) { - fitView({ nodes: selectedNodeIds, duration: 300, padding: 0.2 }); + fitView({ nodes: selectedNodeIds.map(s => ({ id: s })), duration: 300, padding: 0.2 }); } }, [selectedNodeIds, fitView]); @@ -606,7 +606,7 @@ export function LearningMapEditor({ }, []); const handleResetMap = useCallback(() => { - if (confirm(t.openFileWarning)) { + if (confirm(t.resetMapWarning)) { setNodes([]); setEdges([]); setNextNodeId(1); @@ -616,11 +616,7 @@ export function LearningMapEditor({ const handleSelectionChange: OnSelectionChangeFunc = useCallback( ({ nodes: selectedNodes }) => { - if (selectedNodes.length > 1) { - setSelectedNodeIds(selectedNodes.map(n => n.id)); - } else { - setSelectedNodeIds([]); - } + setSelectedNodeIds(selectedNodes.map(n => n.id)); }, [setSelectedNodeIds] ); @@ -677,7 +673,7 @@ export function LearningMapEditor({ e.preventDefault(); toggleDebugMode(); } - + // Zoom in shortcut if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=') && !e.shiftKey) { e.preventDefault(); @@ -694,17 +690,19 @@ export function LearningMapEditor({ handleResetZoom(); } // Fit view shortcut - if (e.shiftKey && e.key === '1' && !e.ctrlKey && !e.metaKey) { + if (e.shiftKey && e.code === 'Digit1' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); handleFitView(); } // Zoom to selection shortcut - if (e.shiftKey && e.key === '2' && !e.ctrlKey && !e.metaKey) { + if (e.shiftKey && e.code === 'Digit2' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); handleZoomToSelection(); } + + console.log(e); // Toggle grid shortcut - if ((e.ctrlKey || e.metaKey) && e.key === "'" && !e.shiftKey) { + if ((e.ctrlKey || e.metaKey) && e.code === "Backslash") { e.preventDefault(); handleToggleGrid(); } @@ -728,7 +726,7 @@ export function LearningMapEditor({ e.preventDefault(); handlePaste(); } - + // Dismiss with Escape if (helpOpen && e.key === 'Escape') { setHelpOpen(false); @@ -738,9 +736,9 @@ export function LearningMapEditor({ return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode, - handleZoomIn, handleZoomOut, handleResetZoom, handleFitView, handleZoomToSelection, handleToggleGrid, - handleResetMap, handleCut, handleCopy, handlePaste]); + }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode, + handleZoomIn, handleZoomOut, handleResetZoom, handleFitView, handleZoomToSelection, handleToggleGrid, + handleResetMap, handleCut, handleCopy, handlePaste]); return ( <> diff --git a/packages/learningmap/src/translations.ts b/packages/learningmap/src/translations.ts index fcbb24b..9b1da4e 100644 --- a/packages/learningmap/src/translations.ts +++ b/packages/learningmap/src/translations.ts @@ -111,6 +111,7 @@ export interface Translations { // Messages openFileWarning: string; + resetMapWarning: string; failedToLoadFile: string; failedToExportSVG: string; @@ -288,6 +289,8 @@ const en: Translations = { // Messages openFileWarning: "Opening a file will replace your current map. Continue?", + resetMapWarning: + "Are you sure you want to reset the map? This action cannot be undone.", failedToLoadFile: "Failed to load the file. Please make sure it is a valid roadmap JSON file.", failedToExportSVG: "Failed to export SVG: ", @@ -470,6 +473,8 @@ const de: Translations = { // Messages openFileWarning: "Das Öffnen einer Datei ersetzt Ihre aktuelle Karte. Fortfahren?", + resetMapWarning: + "Sind Sie sicher, dass Sie die Karte zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", failedToLoadFile: "Datei konnte nicht geladen werden. Bitte stellen Sie sicher, dass es sich um eine gültige Roadmap-JSON-Datei handelt.", failedToExportSVG: "SVG-Export fehlgeschlagen: ", From b7ae8e793b1f2e0112d118380c91bcad18b1dbc7 Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Mon, 13 Oct 2025 00:25:41 +0200 Subject: [PATCH 4/4] change t to b --- packages/learningmap/src/LearningMapEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/learningmap/src/LearningMapEditor.tsx b/packages/learningmap/src/LearningMapEditor.tsx index 8e74ee6..edc1632 100644 --- a/packages/learningmap/src/LearningMapEditor.tsx +++ b/packages/learningmap/src/LearningMapEditor.tsx @@ -91,7 +91,7 @@ export function LearningMapEditor({ { action: t.shortcuts.addTaskNode, shortcut: "Ctrl+A" }, { action: t.shortcuts.addTopicNode, shortcut: "Ctrl+O" }, { action: t.shortcuts.addImageNode, shortcut: "Ctrl+I" }, - { action: t.shortcuts.addTextNode, shortcut: "Ctrl+T" }, + { action: t.shortcuts.addTextNode, shortcut: "Ctrl+B" }, { action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" }, { action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" }, { action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" }, @@ -654,7 +654,7 @@ export function LearningMapEditor({ addNewNode("image"); } // add text node shortcut - changed to Ctrl+T - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 't' && !e.shiftKey) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b' && !e.shiftKey) { e.preventDefault(); addNewNode("text"); }