Skip to content

Commit f2822c7

Browse files
committed
add picker for selecting nodes
1 parent c67d1c0 commit f2822c7

15 files changed

Lines changed: 253 additions & 60 deletions

.changeset/deep-beans-cut.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@learningmap/learningmap": minor
3+
---
4+
5+
Add a picker so selecting nodes

docs/book/changelog.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ If you need a new feature, open an [issue](https://github.com/openpatch/learning
1313

1414
:::
1515

16+
## v0.6.0
17+
18+
::::tabs
19+
20+
:::tab{title="New :rocket:" id="new"}
21+
22+
- Add a picker for selecting nodes.
23+
24+
:::
25+
26+
::::
27+
1628
## v0.5.1
1729

1830
::::tabs

packages/learningmap/src/EditorCanvas.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const EditorCanvas = memo(() => {
3131
const showGrid = useEditorStore(state => state.showGrid);
3232
const selectedNodeIds = useEditorStore(state => state.selectedNodeIds);
3333
const settings = useEditorStore(state => state.settings);
34+
const pickerMode = useEditorStore(state => state.pickerMode);
3435

3536
// Get actions from store
3637
const onNodesChange = useEditorStore(state => state.onNodesChange);
@@ -71,12 +72,19 @@ export const EditorCanvas = memo(() => {
7172
}));
7273

7374
const handleNodeClick = useCallback((_: any, node: Node<NodeData>) => {
75+
// Execute picker callback when in picker mode
76+
if (pickerMode) {
77+
const executePickerCallback = useEditorStore.getState().executePickerCallback;
78+
executePickerCallback(node.id);
79+
return;
80+
}
81+
7482
setSelectedNodeId(node.id);
7583
setDrawerOpen(true);
7684
setSelectedEdge(null);
7785
setEdgeDrawerOpen(false);
7886
setSettingsDrawerOpen(false);
79-
}, [setSelectedNodeId, setDrawerOpen, setSelectedEdge, setEdgeDrawerOpen, setSettingsDrawerOpen]);
87+
}, [setSelectedNodeId, setDrawerOpen, setSelectedEdge, setEdgeDrawerOpen, setSettingsDrawerOpen, pickerMode]);
8088

8189
const handleEdgeClick = useCallback((_: any, edge: Edge) => {
8290
setSelectedEdge(edge);
@@ -127,9 +135,10 @@ export const EditorCanvas = memo(() => {
127135
return (
128136
<div
129137
ref={canvasRef}
130-
className="editor-canvas"
138+
className={`editor-canvas ${pickerMode ? "picker-mode" : ""}`}
131139
style={{
132140
backgroundColor: settings?.background?.color || "#ffffff",
141+
cursor: pickerMode ? "crosshair" : "default",
133142
}}
134143
onMouseMove={handleMouseMove}
135144
>
@@ -149,10 +158,11 @@ export const EditorCanvas = memo(() => {
149158
edgeTypes={edgeTypes}
150159
proOptions={{ hideAttribution: true }}
151160
defaultEdgeOptions={defaultEdgeOptions}
152-
nodesDraggable={true}
161+
nodesDraggable={!pickerMode}
153162
elevateNodesOnSelect={false}
154-
nodesConnectable={true}
163+
nodesConnectable={!pickerMode}
155164
selectNodesOnDrag={false}
165+
elementsSelectable={!pickerMode}
156166
colorMode="light"
157167
>
158168
{showGrid && <Background />}

packages/learningmap/src/EditorDrawer.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EditorDrawerImageContent } from "./EditorDrawerImageContent";
77
import { EditorDrawerTextContent } from "./EditorDrawerTextContent";
88
import { Completion, NodeData } from "./types";
99
import { useEditorStore } from "./editorStore";
10+
import { NodePickerInput } from "./NodePickerInput";
1011

1112
export const EditorDrawer: React.FC = () => {
1213
// Get node and all nodes from store
@@ -77,18 +78,19 @@ export const EditorDrawer: React.FC = () => {
7778
const aLabel = (a.data.label || a.id).toLowerCase();
7879
const bLabel = (b.data.label || b.id).toLowerCase();
7980
return aLabel.localeCompare(bLabel);
80-
});
81+
}).map(n => ({
82+
id: n.id,
83+
label: n.data.label || n.id
84+
}));
8185

82-
// Helper for dropdowns
83-
const renderNodeSelect = (value: string, onChange: (id: string) => void) => (
84-
<select value={value} onChange={e => onChange(e.target.value)}>
85-
<option value="">{t.selectNode}</option>
86-
{sortedNodeOptions.map(n => (
87-
<option key={n.id} value={n.id}>
88-
{n.data.label || n.id}
89-
</option>
90-
))}
91-
</select>
86+
// Helper for picker inputs
87+
const renderNodePicker = (value: string, onChange: (id: string) => void, onRemove: () => void) => (
88+
<NodePickerInput
89+
value={value}
90+
onChange={onChange}
91+
onRemove={onRemove}
92+
nodeOptions={sortedNodeOptions}
93+
/>
9294
);
9395

9496
// Completion Needs
@@ -197,7 +199,7 @@ export const EditorDrawer: React.FC = () => {
197199
handleUnlockAfterChange={handleUnlockAfterChange}
198200
addUnlockAfter={addUnlockAfter}
199201
removeUnlockAfter={removeUnlockAfter}
200-
renderNodeSelect={renderNodeSelect}
202+
renderNodePicker={renderNodePicker}
201203
handleCompletionNeedsChange={handleCompletionNeedsChange}
202204
addCompletionNeed={addCompletionNeed}
203205
removeCompletionNeed={removeCompletionNeed}
@@ -225,7 +227,7 @@ export const EditorDrawer: React.FC = () => {
225227
handleUnlockAfterChange={handleUnlockAfterChange}
226228
addUnlockAfter={addUnlockAfter}
227229
removeUnlockAfter={removeUnlockAfter}
228-
renderNodeSelect={renderNodeSelect}
230+
renderNodePicker={renderNodePicker}
229231
handleCompletionNeedsChange={handleCompletionNeedsChange}
230232
addCompletionNeed={addCompletionNeed}
231233
removeCompletionNeed={removeCompletionNeed}

packages/learningmap/src/EditorDrawerTaskContent.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface Props {
1313
handleUnlockAfterChange: (idx: number, id: string) => void;
1414
addUnlockAfter: () => void;
1515
removeUnlockAfter: (idx: number) => void;
16-
renderNodeSelect: (value: string, onChange: (id: string) => void) => React.ReactNode;
16+
renderNodePicker: (value: string, onChange: (id: string) => void, onRemove: () => void) => React.ReactNode;
1717
handleCompletionNeedsChange: (idx: number, id: string) => void;
1818
addCompletionNeed: () => void;
1919
removeCompletionNeed: (idx: number) => void;
@@ -31,7 +31,7 @@ export function EditorDrawerTaskContent({
3131
handleUnlockAfterChange,
3232
addUnlockAfter,
3333
removeUnlockAfter,
34-
renderNodeSelect,
34+
renderNodePicker,
3535
handleCompletionNeedsChange,
3636
addCompletionNeed,
3737
removeCompletionNeed,
@@ -235,42 +235,27 @@ export function EditorDrawerTaskContent({
235235
</div>
236236
<div className="form-group">
237237
<label>{t.unlockAfter}</label>
238-
{(localNode.data.unlock?.after || []).map((id: string, idx: number) => (
239-
<div key={idx} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
240-
{renderNodeSelect(id, newId => handleUnlockAfterChange(idx, newId))}
241-
<button onClick={() => removeUnlockAfter(idx)} className="icon-button">
242-
<Trash2 size={16} />
243-
</button>
244-
</div>
245-
))}
238+
{(localNode.data.unlock?.after || []).map((id: string, idx: number) =>
239+
renderNodePicker(id, newId => handleUnlockAfterChange(idx, newId), () => removeUnlockAfter(idx))
240+
)}
246241
<button onClick={addUnlockAfter} className="secondary-button">
247242
<Plus size={16} /> {t.unlockAfter}
248243
</button>
249244
</div>
250245
{localNode.type === "topic" && <div className="form-group">
251246
<label>{t.completionNeeds}</label>
252-
{(localNode.data.completion?.needs || []).map((need: string, idx: number) => (
253-
<div key={idx} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
254-
{renderNodeSelect(need, newId => handleCompletionNeedsChange(idx, newId))}
255-
<button onClick={() => removeCompletionNeed(idx)} className="icon-button">
256-
<Trash2 size={16} />
257-
</button>
258-
</div>
259-
))}
247+
{(localNode.data.completion?.needs || []).map((need: string, idx: number) =>
248+
renderNodePicker(need, newId => handleCompletionNeedsChange(idx, newId), () => removeCompletionNeed(idx))
249+
)}
260250
<button onClick={addCompletionNeed} className="secondary-button">
261251
<Plus size={16} /> {t.completionNeeds}
262252
</button>
263253
</div>}
264254
{localNode.type === "topic" && <div className="form-group">
265255
<label>{t.completionOptional}</label>
266-
{(localNode.data.completion?.optional || []).map((opt: string, idx: number) => (
267-
<div key={idx} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
268-
{renderNodeSelect(opt, newId => handleCompletionOptionalChange(idx, newId))}
269-
<button onClick={() => removeCompletionOptional(idx)} className="icon-button">
270-
<Trash2 size={16} />
271-
</button>
272-
</div>
273-
))}
256+
{(localNode.data.completion?.optional || []).map((opt: string, idx: number) =>
257+
renderNodePicker(opt, newId => handleCompletionOptionalChange(idx, newId), () => removeCompletionOptional(idx))
258+
)}
274259
<button onClick={addCompletionOptional} className="secondary-button">
275260
<Plus size={16} /> {t.completionOptional}
276261
</button>

packages/learningmap/src/EditorPanel.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EditorDrawerImageContent } from "./EditorDrawerImageContent";
77
import { EditorDrawerTextContent } from "./EditorDrawerTextContent";
88
import { Completion, NodeData } from "./types";
99
import { useEditorStore } from "./editorStore";
10+
import { NodePickerInput } from "./NodePickerInput";
1011

1112
export const EditorPanel: React.FC = () => {
1213
// Get node and all nodes from store
@@ -104,18 +105,19 @@ export const EditorPanel: React.FC = () => {
104105
const aLabel = (a.data.label || a.id).toLowerCase();
105106
const bLabel = (b.data.label || b.id).toLowerCase();
106107
return aLabel.localeCompare(bLabel);
107-
});
108+
}).map(n => ({
109+
id: n.id,
110+
label: n.data.label || n.id
111+
}));
108112

109-
// Helper for dropdowns
110-
const renderNodeSelect = (value: string, onChange: (id: string) => void) => (
111-
<select value={value} onChange={e => onChange(e.target.value)}>
112-
<option value="">{t.selectNode}</option>
113-
{sortedNodeOptions.map(n => (
114-
<option key={n.id} value={n.id}>
115-
{n.data.label || n.id}
116-
</option>
117-
))}
118-
</select>
113+
// Helper for picker inputs
114+
const renderNodePicker = (value: string, onChange: (id: string) => void, onRemove: () => void) => (
115+
<NodePickerInput
116+
value={value}
117+
onChange={onChange}
118+
onRemove={onRemove}
119+
nodeOptions={sortedNodeOptions}
120+
/>
119121
);
120122

121123
// Completion Needs
@@ -220,7 +222,7 @@ export const EditorPanel: React.FC = () => {
220222
handleUnlockAfterChange={handleUnlockAfterChange}
221223
addUnlockAfter={addUnlockAfter}
222224
removeUnlockAfter={removeUnlockAfter}
223-
renderNodeSelect={renderNodeSelect}
225+
renderNodePicker={renderNodePicker}
224226
handleCompletionNeedsChange={handleCompletionNeedsChange}
225227
addCompletionNeed={addCompletionNeed}
226228
removeCompletionNeed={removeCompletionNeed}
@@ -248,7 +250,7 @@ export const EditorPanel: React.FC = () => {
248250
handleUnlockAfterChange={handleUnlockAfterChange}
249251
addUnlockAfter={addUnlockAfter}
250252
removeUnlockAfter={removeUnlockAfter}
251-
renderNodeSelect={renderNodeSelect}
253+
renderNodePicker={renderNodePicker}
252254
handleCompletionNeedsChange={handleCompletionNeedsChange}
253255
addCompletionNeed={addCompletionNeed}
254256
removeCompletionNeed={removeCompletionNeed}

packages/learningmap/src/KeyboardShortcuts.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export const KeyboardShortcuts = ({
8383
const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen);
8484
const settingsDrawerOpen = useEditorStore(state => state.settingsDrawerOpen);
8585
const getTranslationsFromStore = useEditorStore(state => state.getTranslations);
86+
const pickerMode = useEditorStore(state => state.pickerMode);
87+
const setPickerMode = useEditorStore(state => state.setPickerMode);
8688

8789
const t = getTranslationsFromStore();
8890

@@ -206,6 +208,13 @@ export const KeyboardShortcuts = ({
206208

207209
useEffect(() => {
208210
const handleKeyDown = (e: KeyboardEvent) => {
211+
// ESC key cancels picker mode
212+
if (e.key === 'Escape' && pickerMode) {
213+
e.preventDefault();
214+
setPickerMode(false, null);
215+
return;
216+
}
217+
209218
if (drawerOpen || edgeDrawerOpen || settingsDrawerOpen) {
210219
return; // Ignore shortcuts when any drawer is open
211220
}
@@ -286,7 +295,7 @@ export const KeyboardShortcuts = ({
286295
};
287296
}, [onAddNode, onDeleteSelected, onSave, undo, redo, helpOpen, setHelpOpen, onTogglePreview, onToggleDebug,
288297
onZoomIn, onZoomOut, onResetZoom, onFitView, onZoomToSelection, onToggleGrid,
289-
onResetMap, onCut, onCopy, onPaste, onSelectAll, drawerOpen, edgeDrawerOpen, settingsDrawerOpen, keyBindings]);
298+
onResetMap, onCut, onCopy, onPaste, onSelectAll, drawerOpen, edgeDrawerOpen, settingsDrawerOpen, keyBindings, pickerMode, setPickerMode]);
290299

291300
return null;
292301
};

packages/learningmap/src/LearningMapEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { WelcomeMessage } from "./WelcomeMessage";
99
import { EditorCanvas } from "./EditorCanvas";
1010
import { EditorDialogs } from "./EditorDialogs";
1111
import { KeyboardShortcuts } from "./KeyboardShortcuts";
12+
import { PickerBanner } from "./PickerBanner";
1213
import { detectBrowserLanguage } from "./translations";
1314
import { useEffect } from "react";
1415

@@ -86,6 +87,9 @@ export function LearningMapEditor({
8687
{/* Toolbar */}
8788
<EditorToolbar disableSharing={disableSharing} disableFileOperations={disableFileOperations} />
8889

90+
{/* Picker mode banner */}
91+
<PickerBanner />
92+
8993
{/* Preview or Edit mode */}
9094
{previewMode && <LearningMap roadmapData={getRoadmapData()} />}
9195
{!previewMode && <>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from "react";
2+
import { Target, Trash2 } from "lucide-react";
3+
import { useEditorStore } from "./editorStore";
4+
5+
interface NodePickerInputProps {
6+
value: string;
7+
onChange: (nodeId: string) => void;
8+
onRemove: () => void;
9+
nodeOptions: Array<{ id: string; label: string }>;
10+
}
11+
12+
export function NodePickerInput({
13+
value,
14+
onChange,
15+
onRemove,
16+
nodeOptions,
17+
}: NodePickerInputProps) {
18+
const getTranslationsFromStore = useEditorStore((state) => state.getTranslations);
19+
const setPickerMode = useEditorStore((state) => state.setPickerMode);
20+
const pickerMode = useEditorStore((state) => state.pickerMode);
21+
22+
const t = getTranslationsFromStore();
23+
24+
const selectedNode = nodeOptions.find((n) => n.id === value);
25+
26+
const handlePickClick = () => {
27+
setPickerMode(true, (nodeId: string) => {
28+
onChange(nodeId);
29+
});
30+
};
31+
32+
return (
33+
<div style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
34+
<select
35+
value={value}
36+
onChange={(e) => onChange(e.target.value)}
37+
style={{ flex: 1 }}
38+
>
39+
<option value="">{t.selectNode}</option>
40+
{nodeOptions.map((n) => (
41+
<option key={n.id} value={n.id}>
42+
{n.label}
43+
</option>
44+
))}
45+
</select>
46+
<button
47+
onClick={handlePickClick}
48+
className="icon-button"
49+
title={t.pickFromMap}
50+
disabled={pickerMode}
51+
style={{
52+
opacity: pickerMode ? 0.5 : 1,
53+
cursor: pickerMode ? "not-allowed" : "pointer",
54+
}}
55+
>
56+
<Target size={16} />
57+
</button>
58+
<button onClick={onRemove} className="icon-button">
59+
<Trash2 size={16} />
60+
</button>
61+
</div>
62+
);
63+
}

0 commit comments

Comments
 (0)