Skip to content

Commit 1d5bf34

Browse files
authored
Merge pull request #35 from openpatch/copilot/improve-ux-dropdowns-nodes
UX improvements: connected node sorting, markdown descriptions, book resources, edge handling, node centering, and edge defaults
2 parents 69d32ba + c5c3022 commit 1d5bf34

12 files changed

Lines changed: 337 additions & 50 deletions

packages/learningmap/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@
3434
},
3535
"dependencies": {
3636
"@szhsin/react-menu": "^4.5.0",
37+
"@types/dompurify": "^3.2.0",
3738
"@xyflow/react": "^12.8.6",
39+
"dompurify": "^3.3.0",
3840
"elkjs": "^0.11.0",
3941
"fast-deep-equal": "^3.1.3",
4042
"html-to-image": "1.11.13",
4143
"lucide-react": "^0.545.0",
44+
"marked": "^16.4.1",
4245
"react": "^19.2.0",
4346
"react-dom": "^19.2.0",
4447
"throttle-debounce": "^5.0.2",

packages/learningmap/src/Drawer.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Node } from "@xyflow/react";
2-
import { NodeData } from "./types";
2+
import { NodeData, Resource } from "./types";
33
import { X, Lock, CheckCircle } from "lucide-react";
44
import { Video } from "./Video";
55
import StarCircle from "./icons/StarCircle";
66
import { getTranslations } from "./translations";
7+
import { marked } from "marked";
8+
import { useMemo } from "react";
9+
import DOMPurify from "dompurify";
710

811
interface DrawerProps {
912
open: boolean;
@@ -57,7 +60,14 @@ function getCompletionOptional(node: Node<NodeData>, nodes: Node<NodeData>[]): N
5760
export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, language = "en" }: DrawerProps) {
5861
const t = getTranslations(language);
5962

60-
if (!open) return null;
63+
// Parse markdown description and sanitize HTML
64+
const descriptionHtml = useMemo(() => {
65+
if (!node || !node.data?.description) return '';
66+
const rawHtml = marked.parse(node.data.description, { async: false });
67+
return DOMPurify.sanitize(rawHtml);
68+
}, [node, node?.data?.description]);
69+
70+
if (!open || !node) return null;
6171

6272
const locked = node.data?.state === 'locked' || false;
6373
const unlocked = node.data?.state === 'unlocked' || false;
@@ -95,17 +105,39 @@ export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, lang
95105
</button>
96106
</header>
97107
<div className="drawer-content">
98-
{node.data?.description && <div className="drawer-description" style={{ marginBottom: 16 }}>{node.data?.description}</div>}
108+
{node.data?.description && <div className="drawer-description" style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: descriptionHtml }} />}
99109
{node.data?.video && <div className="drawer-video" style={{ marginBottom: 16 }}>
100110
<Video url={node.data?.video} />
101111
</div>}
102112
{node.data?.resources && node.data?.resources.length > 0 && (
103113
<div className="drawer-resources" style={{ marginBottom: 16 }}>
104114
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t.resourcesLabel}</div>
105115
<ul>
106-
{node.data?.resources.map((r: any) => (
107-
<li key={r.url}><a href={r.url} target="_blank" rel="noopener noreferrer">{r.label}</a></li>
108-
))}
116+
{node.data?.resources.map((r: Resource, idx: number) => {
117+
if (r.type === "book") {
118+
// Format: 📚 Label (Name, Location)
119+
// If name is empty, no comma should be visible
120+
const bookDetails = [];
121+
if (r.bookName) bookDetails.push(r.bookName);
122+
if (r.bookLocation) bookDetails.push(r.bookLocation);
123+
const detailsText = bookDetails.length > 0 ? ` (${bookDetails.join(', ')})` : '';
124+
125+
return (
126+
<li key={idx}>
127+
📚 <strong>{r.label}</strong>{detailsText}
128+
</li>
129+
);
130+
}
131+
return (
132+
<li key={idx}>
133+
🌐 {r.url ? (
134+
<a href={r.url} target="_blank" rel="noopener noreferrer">{r.label}</a>
135+
) : (
136+
<span>{r.label}</span>
137+
)}
138+
</li>
139+
);
140+
})}
109141
</ul>
110142
</div>
111143
)}

packages/learningmap/src/EditorCanvas.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps)
8787

8888
const handleSelectionChange: OnSelectionChangeFunc = useCallback(
8989
({ nodes: selectedNodes }) => {
90+
// Only select nodes, not edges (as per requirement #6)
9091
setSelectedNodeIds(selectedNodes.map(n => n.id));
9192
},
9293
[setSelectedNodeIds]
@@ -101,10 +102,10 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps)
101102
const defaultEdgeOptions = {
102103
animated: false,
103104
style: {
104-
stroke: "#94a3b8",
105+
stroke: settings?.defaultEdgeColor || "#94a3b8",
105106
strokeWidth: 2,
106107
},
107-
type: "default",
108+
type: settings?.defaultEdgeType || "default",
108109
};
109110

110111
return (
@@ -133,6 +134,7 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps)
133134
nodesDraggable={true}
134135
elevateNodesOnSelect={false}
135136
nodesConnectable={true}
137+
selectNodesOnDrag={false}
136138
colorMode="light"
137139
>
138140
{showGrid && <Background />}

packages/learningmap/src/EditorDrawer.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,38 @@ export const EditorDrawer: React.FC<EditorDrawerProps> = ({
6060
// Filter out the current node from selectable options
6161
const nodeOptions = nodes.filter(n => n.id !== node.id && (n.type === "task" || n.type === "topic"));
6262

63+
// Get edges connected to this node
64+
const edges = useEditorStore.getState().edges;
65+
const connectedNodeIds = new Set<string>();
66+
edges.forEach(edge => {
67+
if (edge.source === node.id) {
68+
connectedNodeIds.add(edge.target);
69+
}
70+
if (edge.target === node.id) {
71+
connectedNodeIds.add(edge.source);
72+
}
73+
});
74+
75+
// Sort node options: connected nodes first, then alphabetically by label
76+
const sortedNodeOptions = [...nodeOptions].sort((a, b) => {
77+
const aConnected = connectedNodeIds.has(a.id);
78+
const bConnected = connectedNodeIds.has(b.id);
79+
80+
// Connected nodes come first
81+
if (aConnected && !bConnected) return -1;
82+
if (!aConnected && bConnected) return 1;
83+
84+
// Otherwise sort alphabetically by label
85+
const aLabel = (a.data.label || a.id).toLowerCase();
86+
const bLabel = (b.data.label || b.id).toLowerCase();
87+
return aLabel.localeCompare(bLabel);
88+
});
89+
6390
// Helper for dropdowns
6491
const renderNodeSelect = (value: string, onChange: (id: string) => void) => (
6592
<select value={value} onChange={e => onChange(e.target.value)}>
6693
<option value="">{t.selectNode}</option>
67-
{nodeOptions.map(n => (
94+
{sortedNodeOptions.map(n => (
6895
<option key={n.id} value={n.id}>
6996
{n.data.label || n.id}
7097
</option>
@@ -149,7 +176,7 @@ export const EditorDrawer: React.FC<EditorDrawerProps> = ({
149176

150177
const addResource = () => {
151178
if (!localNode) return;
152-
const resources = [...(localNode.data.resources || []), { label: "", url: "" }];
179+
const resources = [...(localNode.data.resources || []), { label: "", type: "url", url: "" }];
153180
handleFieldChange("resources", resources);
154181
};
155182

packages/learningmap/src/EditorDrawerTaskContent.tsx

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Node } from "@xyflow/react";
22
import { Plus, Trash2 } from "lucide-react";
3-
import { NodeData } from "./types";
3+
import { NodeData, Resource } from "./types";
44
import { getTranslations } from "./translations";
55

66
interface Props {
@@ -93,6 +93,17 @@ export function EditorDrawerTaskContent({
9393
placeholder={t.placeholderNodeLabel}
9494
/>
9595
</div>
96+
<div className="form-group">
97+
<label>Font Size (px)</label>
98+
<input
99+
type="number"
100+
value={localNode.data.fontSize || 14}
101+
onChange={(e) => handleFieldChange("fontSize", parseInt(e.target.value) || 14)}
102+
placeholder="14"
103+
min="8"
104+
max="72"
105+
/>
106+
</div>
96107
<div className="form-group">
97108
<label>{t.summary}</label>
98109
<input
@@ -131,27 +142,59 @@ export function EditorDrawerTaskContent({
131142
</div>
132143
<div className="form-group">
133144
<label>{t.resources}</label>
134-
{(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => (
135-
<div key={idx} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
136-
<input
137-
type="text"
138-
value={resource.label || ""}
139-
onChange={(e) => handleResourceChange(idx, "label", e.target.value)}
140-
placeholder={t.placeholderLabel}
141-
style={{ flex: 1 }}
142-
/>
143-
<input
144-
type="text"
145-
value={resource.url || ""}
146-
onChange={(e) => handleResourceChange(idx, "url", e.target.value)}
147-
placeholder={t.placeholderURL}
148-
style={{ flex: 2 }}
149-
/>
150-
<button onClick={() => removeResource(idx)} className="icon-button">
151-
<Trash2 size={16} />
152-
</button>
153-
</div>
154-
))}
145+
{(localNode.data.resources || []).map((resource: Resource, idx: number) => {
146+
const isBook = resource.type === "book";
147+
return (
148+
<div key={idx} style={{ marginBottom: "16px", padding: "12px", border: "1px solid #e5e7eb", borderRadius: "6px" }}>
149+
<div style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
150+
<select
151+
value={resource.type || "url"}
152+
onChange={(e) => handleResourceChange(idx, "type", e.target.value)}
153+
style={{ flex: 0.5 }}
154+
>
155+
<option value="url">URL</option>
156+
<option value="book">Book</option>
157+
</select>
158+
<input
159+
type="text"
160+
value={resource.label || ""}
161+
onChange={(e) => handleResourceChange(idx, "label", e.target.value)}
162+
placeholder={t.placeholderLabel}
163+
style={{ flex: 1 }}
164+
/>
165+
<button onClick={() => removeResource(idx)} className="icon-button">
166+
<Trash2 size={16} />
167+
</button>
168+
</div>
169+
{isBook ? (
170+
<>
171+
<input
172+
type="text"
173+
value={resource.bookName || ""}
174+
onChange={(e) => handleResourceChange(idx, "bookName", e.target.value)}
175+
placeholder="Book name (e.g., Lambacher Schweitzer GK)"
176+
style={{ width: "100%", marginBottom: "8px" }}
177+
/>
178+
<input
179+
type="text"
180+
value={resource.bookLocation || ""}
181+
onChange={(e) => handleResourceChange(idx, "bookLocation", e.target.value)}
182+
placeholder="Location (e.g., S. 223 Nr. 5)"
183+
style={{ width: "100%" }}
184+
/>
185+
</>
186+
) : (
187+
<input
188+
type="text"
189+
value={resource.url || ""}
190+
onChange={(e) => handleResourceChange(idx, "url", e.target.value)}
191+
placeholder={t.placeholderURL}
192+
style={{ width: "100%" }}
193+
/>
194+
)}
195+
</div>
196+
);
197+
})}
155198
<button onClick={addResource} className="secondary-button">
156199
<Plus size={16} /> {t.addResource}
157200
</button>

packages/learningmap/src/KeyboardShortcuts.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }:
1515
// Get store state
1616
const helpOpen = useEditorStore(state => state.helpOpen);
1717
const selectedNodeIds = useEditorStore(state => state.selectedNodeIds);
18+
const selectedEdge = useEditorStore(state => state.selectedEdge);
1819
const nodes = useEditorStore(state => state.nodes);
1920
const lastMousePosition = useEditorStore(state => state.lastMousePosition);
2021
const settings = useEditorStore(state => state.settings);
@@ -33,6 +34,9 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }:
3334
const showGrid = useEditorStore(state => state.showGrid);
3435
const setShowGrid = useEditorStore(state => state.setShowGrid);
3536
const deleteNode = useEditorStore(state => state.deleteNode);
37+
const deleteEdge = useEditorStore(state => state.deleteEdge);
38+
const setSelectedEdge = useEditorStore(state => state.setSelectedEdge);
39+
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
3640
const drawerOpen = useEditorStore(state => state.drawerOpen);
3741
const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen);
3842
const settingsDrawerOpen = useEditorStore(state => state.settingsDrawerOpen);
@@ -61,6 +65,15 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }:
6165
};
6266

6367
const onDeleteSelected = () => {
68+
// Delete selected edge if any
69+
if (selectedEdge) {
70+
deleteEdge(selectedEdge.id);
71+
setSelectedEdge(null);
72+
setEdgeDrawerOpen(false);
73+
return;
74+
}
75+
76+
// Otherwise delete selected nodes
6477
if (selectedNodeIds.length > 0) {
6578
// Delete all selected nodes
6679
selectedNodeIds.forEach(nodeId => {

packages/learningmap/src/SettingsDrawer.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
1717
// Get state from store
1818
const isOpen = useEditorStore(state => state.settingsDrawerOpen);
1919
const settings = useEditorStore(state => state.settings);
20+
const edges = useEditorStore(state => state.edges);
21+
const setEdges = useEditorStore(state => state.setEdges);
2022

2123
// Get actions from store
2224
const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen);
@@ -57,6 +59,22 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
5759
}));
5860
};
5961

62+
const handleUpdateAllEdges = () => {
63+
const defaultType = localSettings?.defaultEdgeType || "default";
64+
const defaultColor = localSettings?.defaultEdgeColor || "#94a3b8";
65+
66+
const updatedEdges = edges.map(edge => ({
67+
...edge,
68+
type: defaultType,
69+
style: {
70+
...edge.style,
71+
stroke: defaultColor,
72+
}
73+
}));
74+
75+
setEdges(updatedEdges);
76+
};
77+
6078
return (
6179
<>
6280
<div className="drawer-overlay" onClick={onClose} />
@@ -174,6 +192,39 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
174192
{t.useCurrentViewport}
175193
</button>
176194
</div>
195+
196+
<div className="form-group">
197+
<label>Default Edge Type</label>
198+
<select
199+
value={localSettings?.defaultEdgeType || "default"}
200+
onChange={(e) => setLocalSettings(settings => ({ ...settings, defaultEdgeType: e.target.value }))}
201+
>
202+
<option value="default">Default</option>
203+
<option value="straight">Straight</option>
204+
<option value="step">Step</option>
205+
<option value="smoothstep">Smooth Step</option>
206+
<option value="simplebezier">Simple Bezier</option>
207+
</select>
208+
</div>
209+
210+
<div className="form-group">
211+
<ColorSelector
212+
label="Default Edge Color"
213+
value={localSettings?.defaultEdgeColor || "#94a3b8"}
214+
onChange={color => setLocalSettings(settings => ({ ...settings, defaultEdgeColor: color }))}
215+
/>
216+
</div>
217+
218+
<div className="form-group">
219+
<button
220+
onClick={handleUpdateAllEdges}
221+
className="secondary-button"
222+
style={{ width: '100%' }}
223+
type="button"
224+
>
225+
Update All Edges to Default Settings
226+
</button>
227+
</div>
177228
</div>
178229

179230
<div className="drawer-footer">

0 commit comments

Comments
 (0)