Skip to content

Commit 2f4e97a

Browse files
authored
Merge pull request #63 from openpatch/claude/app-usability-improvements-w9qdnt
Improve working with background nodes and new nodes
2 parents 5ef89a9 + a540b14 commit 2f4e97a

25 files changed

Lines changed: 1673 additions & 103 deletions

‎.changeset/proud-donkeys-shave.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@learningmap/learningmap": minor
3+
---
4+
5+
Make background nodes easier to work with and new nodes easier to see
6+
7+
- Add a layers panel listing every node, so nodes covered by another one can still be reached, selected, locked and reordered
8+
- Add bring to front / forward / backward / send to back controls to the node panel and the multi selection panel
9+
- Add per node locking, which stops a background image from being dragged or selected by accident while it can still be edited from the layers panel
10+
- Alt-click cycles through the nodes stacked under the cursor
11+
- Selected nodes are lifted above the stack so their resize handles stay reachable
12+
- Fix text nodes defaulting to a near-invisible light grey; the default now follows the background colour
13+
- Empty image and text nodes render a visible placeholder and image nodes start at a usable size
14+
- Fix nodes added with a keyboard shortcut getting no zIndex, which put them below every other node
15+
- New nodes are placed inside the visible canvas, cascade instead of stacking on each other, and are selected on creation
16+
- Text nodes can be edited in place with a double-click
17+
- Fix the clickable area of a rotated text node not matching what is drawn
18+
- Fix undo, copy, paste and delete being disabled whenever a node was selected

‎packages/learningmap/src/ColorSelector.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const ColorSelector: React.FC<ColorSelectorProps> = ({ value, onChange, l
2020
type="text"
2121
value={value}
2222
onChange={e => onChange(e.target.value)}
23-
placeholder="#e5e7eb"
23+
placeholder="#111827"
2424
style={{ width: 100 }}
2525
/>
2626
</div>

‎packages/learningmap/src/EditorCanvas.tsx‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { MultiNodePanel } from "./MultiNodePanel";
1111
import { EditorPanel } from "./EditorPanel";
1212
import { EdgePanel } from "./EdgePanel";
1313
import { SettingsPanel } from "./SettingsPanel";
14+
import { LayersPanel } from "./LayersPanel";
1415
import { NodeData } from "./types";
16+
import { getNodesAtPosition } from "./zIndexHelper";
17+
import { getReadableTextColor } from "./colorHelper";
1518

1619
const nodeTypes = {
1720
topic: TopicNode,
@@ -39,6 +42,7 @@ export const EditorCanvas = memo(() => {
3942
const onConnect = useEditorStore(state => state.onConnect);
4043
const setSelectedNodeIds = useEditorStore(state => state.setSelectedNodeIds);
4144
const setSelectedNodeId = useEditorStore(state => state.setSelectedNodeId);
45+
const selectNode = useEditorStore(state => state.selectNode);
4246
const setSelectedEdge = useEditorStore(state => state.setSelectedEdge);
4347
const setDrawerOpen = useEditorStore(state => state.setDrawerOpen);
4448
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
@@ -71,20 +75,42 @@ export const EditorCanvas = memo(() => {
7175
canRedo: state.futureStates.length > 0,
7276
}));
7377

74-
const handleNodeClick = useCallback((_: any, node: Node<NodeData>) => {
78+
/**
79+
* Picks the next node underneath the cursor, so nodes covered by another one
80+
* can still be reached. Returns the clicked node when there is nothing to
81+
* cycle through.
82+
*/
83+
const cycleStackedNode = useCallback((event: React.MouseEvent, clickedNodeId?: string) => {
84+
const { nodes: currentNodes, selectedNodeId: currentSelectedNodeId } = useEditorStore.getState();
85+
const point = screenToFlowPosition({ x: event.clientX, y: event.clientY });
86+
const stack = getNodesAtPosition(currentNodes, point);
87+
88+
if (stack.length === 0) return null;
89+
90+
const activeId = currentSelectedNodeId ?? clickedNodeId;
91+
const activeIndex = stack.findIndex(n => n.id === activeId);
92+
return stack[(activeIndex + 1) % stack.length];
93+
}, [screenToFlowPosition]);
94+
95+
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node<NodeData>) => {
7596
// Execute picker callback when in picker mode
7697
if (pickerMode) {
7798
const executePickerCallback = useEditorStore.getState().executePickerCallback;
7899
executePickerCallback(node.id);
79100
return;
80101
}
81-
82-
setSelectedNodeId(node.id);
83-
setDrawerOpen(true);
84-
setSelectedEdge(null);
85-
setEdgeDrawerOpen(false);
86-
setSettingsDrawerOpen(false);
87-
}, [setSelectedNodeId, setDrawerOpen, setSelectedEdge, setEdgeDrawerOpen, setSettingsDrawerOpen, pickerMode]);
102+
103+
// Ctrl/Cmd-click adds to the selection and Shift-drag draws a selection
104+
// box. Both are handled by React Flow, and taking over here would clear
105+
// the other selected nodes.
106+
if (event.ctrlKey || event.metaKey || event.shiftKey) {
107+
return;
108+
}
109+
110+
const target = event.altKey ? cycleStackedNode(event, node.id) ?? node : node;
111+
112+
selectNode(target.id, true);
113+
}, [selectNode, pickerMode, cycleStackedNode]);
88114

89115
const handleEdgeClick = useCallback((_: any, edge: Edge) => {
90116
setSelectedEdge(edge);
@@ -99,11 +125,16 @@ export const EditorCanvas = memo(() => {
99125
// Only select nodes, not edges (as per requirement #6)
100126
setSelectedNodeIds(selectedNodes.map(n => n.id));
101127

102-
// Close the node panel if no nodes are selected and it's currently open
103-
if (selectedNodes.length === 0) {
104-
setDrawerOpen(false);
105-
setSelectedNodeId(null);
106-
}
128+
if (selectedNodes.length > 0) return;
129+
130+
// Locked nodes are never selected on the canvas but can still be edited
131+
// through the panel, so keep the panel open for them.
132+
const state = useEditorStore.getState();
133+
const activeNode = state.nodes.find(n => n.id === state.selectedNodeId);
134+
if (activeNode?.data?.locked) return;
135+
136+
setDrawerOpen(false);
137+
setSelectedNodeId(null);
107138
},
108139
[setSelectedNodeIds, setDrawerOpen, setSelectedNodeId]
109140
);
@@ -115,13 +146,23 @@ export const EditorCanvas = memo(() => {
115146
}, [screenToFlowPosition, setLastMousePosition]);
116147

117148
// Close panels when clicking on empty canvas
118-
const handlePaneClick = useCallback(() => {
149+
const handlePaneClick = useCallback((event: React.MouseEvent) => {
150+
// Locked nodes do not receive clicks, so alt-clicking "through" them lands
151+
// on the pane. Cycle from here as well to keep them reachable.
152+
if (event.altKey && !pickerMode) {
153+
const target = cycleStackedNode(event);
154+
if (target) {
155+
selectNode(target.id, true);
156+
return;
157+
}
158+
}
159+
119160
setDrawerOpen(false);
120161
setSelectedNodeId(null);
121162
setEdgeDrawerOpen(false);
122163
setSelectedEdge(null);
123164
setSettingsDrawerOpen(false);
124-
}, [setDrawerOpen, setSelectedNodeId, setEdgeDrawerOpen, setSelectedEdge, setSettingsDrawerOpen]);
165+
}, [setDrawerOpen, setSelectedNodeId, setEdgeDrawerOpen, setSelectedEdge, setSettingsDrawerOpen, cycleStackedNode, selectNode, pickerMode]);
125166

126167
const defaultEdgeOptions = {
127168
animated: false,
@@ -139,6 +180,8 @@ export const EditorCanvas = memo(() => {
139180
style={{
140181
backgroundColor: settings?.background?.color || "#ffffff",
141182
cursor: pickerMode ? "crosshair" : "default",
183+
// Default text colour for text nodes that have none of their own.
184+
["--learningmap-text-default" as any]: getReadableTextColor(settings?.background?.color),
142185
}}
143186
onMouseMove={handleMouseMove}
144187
>
@@ -159,7 +202,9 @@ export const EditorCanvas = memo(() => {
159202
proOptions={{ hideAttribution: true }}
160203
defaultEdgeOptions={defaultEdgeOptions}
161204
nodesDraggable={!pickerMode}
162-
elevateNodesOnSelect={false}
205+
// Selected nodes are lifted above the stack so their resize handles
206+
// stay reachable even when the node itself sits in the background.
207+
elevateNodesOnSelect={true}
163208
nodesConnectable={!pickerMode}
164209
selectNodesOnDrag={false}
165210
elementsSelectable={!pickerMode}
@@ -178,6 +223,7 @@ export const EditorCanvas = memo(() => {
178223
</ControlButton>
179224
</Controls>
180225
{selectedNodeIds.length > 1 && <MultiNodePanel />}
226+
<LayersPanel />
181227
<EditorPanel />
182228
<EdgePanel />
183229
<SettingsPanel />

‎packages/learningmap/src/EditorDialogs.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const EditorDialogs = memo(({ jsonStore = "https://json.openpatch.org" }:
5050
{ action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" },
5151
{ action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" },
5252
{ action: t.shortcuts.selectMultipleNodes, shortcut: "Ctrl+Click or Shift+Drag" },
53+
{ action: t.shortcuts.cycleStackedNodes, shortcut: "Alt+Click" },
54+
{ action: t.shortcuts.editTextInline, shortcut: "Double-click" },
5355
{ action: t.shortcuts.selectAllNodes, shortcut: "Ctrl+A" },
5456
{ action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" },
5557
{ action: t.shortcuts.save, shortcut: "Ctrl+S" },

‎packages/learningmap/src/EditorDrawerTextContent.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TextNodeData } from "./types";
33
import { ColorSelector } from "./ColorSelector";
44
import { RotationInput } from "./RotationInput";
55
import { useEditorStore } from "./editorStore";
6+
import { getReadableTextColor } from "./colorHelper";
67

78
interface Props {
89
localNode: Node<TextNodeData>;
@@ -11,8 +12,12 @@ interface Props {
1112

1213
export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props) {
1314
const getTranslationsFromStore = useEditorStore(state => state.getTranslations);
15+
const backgroundColor = useEditorStore(state => state.settings?.background?.color);
1416
const t = getTranslationsFromStore();
15-
17+
18+
// Matches the default the text node renders with when no colour is set.
19+
const defaultColor = getReadableTextColor(backgroundColor);
20+
1621
return (
1722
<div className="panel-content">
1823
<div className="form-group">
@@ -35,7 +40,7 @@ export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props)
3540
<div className="form-group">
3641
<ColorSelector
3742
label={t.color}
38-
value={localNode.data.color || "#e5e7eb"}
43+
value={localNode.data.color || defaultColor}
3944
onChange={color => handleFieldChange("color", color)}
4045
/>
4146
</div>

‎packages/learningmap/src/EditorPanel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { EditorDrawerTextContent } from "./EditorDrawerTextContent";
88
import { Completion, NodeData } from "./types";
99
import { useEditorStore } from "./editorStore";
1010
import { NodePickerInput } from "./NodePickerInput";
11+
import { LayerControls } from "./LayerControls";
1112

1213
export const EditorPanel: React.FC = () => {
1314
// Get node and all nodes from store
@@ -296,6 +297,7 @@ export const EditorPanel: React.FC = () => {
296297
<Panel position="center-right" className="editor-panel">
297298
<div className="panel-inner">
298299
{content}
300+
<LayerControls nodeIds={[node.id]} />
299301
<div className="panel-footer">
300302
<button onClick={onCopy} className="secondary-button">
301303
<Copy size={16} /> {t.copyNode}

‎packages/learningmap/src/EditorToolbar.tsx‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import React from "react";
22
import { Menu, MenuButton, MenuDivider, MenuItem, SubMenu } from "@szhsin/react-menu";
3-
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2, RotateCcw } from "lucide-react";
3+
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2, RotateCcw, Layers } from "lucide-react";
44
import { useEditorStore } from "./editorStore";
5-
import { Node, useReactFlow } from "@xyflow/react";
6-
import { NodeData } from "./types";
5+
import { useReactFlow } from "@xyflow/react";
76
import { useJsonStore } from "./useJsonStore";
87
import { useFileOperations } from "./useFileOperations";
9-
import { getZIndexForNodeType } from "./zIndexHelper";
8+
import { createNode, CreatableNodeType } from "./nodeFactory";
109

1110
interface EditorToolbarProps {
1211
disableSharing?: boolean;
@@ -33,6 +32,8 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
3332
const setShowCompletionOptional = useEditorStore(state => state.setShowCompletionOptional);
3433
const setShowUnlockAfter = useEditorStore(state => state.setShowUnlockAfter);
3534
const addNode = useEditorStore(state => state.addNode);
35+
const layersPanelOpen = useEditorStore(state => state.layersPanelOpen);
36+
const setLayersPanelOpen = useEditorStore(state => state.setLayersPanelOpen);
3637
const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen);
3738
const setDrawerOpen = useEditorStore(state => state.setDrawerOpen);
3839
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
@@ -52,20 +53,15 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
5253
const onSetShowCompletionOptional = (checked: boolean) => setShowCompletionOptional(checked);
5354
const onSetShowUnlockAfter = (checked: boolean) => setShowUnlockAfter(checked);
5455

55-
const onAddNewNode = (type: "task" | "topic" | "image" | "text") => {
56-
// Position new nodes at the center of the current viewport
57-
const position = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
58-
const newNode: Node<NodeData> = {
59-
id: `node-${Date.now()}`,
60-
type,
61-
position,
62-
zIndex: getZIndexForNodeType(type),
63-
data: {
64-
label: type === "task" ? t.newTask : type === "topic" ? t.newTopic : type,
65-
state: "unlocked",
66-
},
67-
};
68-
addNode(newNode);
56+
const onAddNewNode = (type: CreatableNodeType) => {
57+
addNode(
58+
createNode({
59+
type,
60+
nodes: useEditorStore.getState().nodes,
61+
t,
62+
screenToFlowPosition,
63+
}),
64+
);
6965
};
7066

7167
const onOpenSettingsDrawer = () => {
@@ -104,6 +100,15 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
104100
<span style={{ marginLeft: 'auto', paddingLeft: '16px', color: '#9ca3af', fontSize: '0.875rem' }}>Ctrl+4</span>
105101
</MenuItem>
106102
</Menu>
103+
<button
104+
disabled={previewMode}
105+
onClick={() => setLayersPanelOpen(!layersPanelOpen)}
106+
className={`toolbar-button ${layersPanelOpen ? "active" : ""}`}
107+
title={t.layers}
108+
aria-pressed={layersPanelOpen}
109+
>
110+
<Layers size={16} /> <span className="toolbar-label">{t.layers}</span>
111+
</button>
107112
<button disabled={previewMode} onClick={onOpenSettingsDrawer} className="toolbar-button">
108113
<Settings size={16} /> <span className="toolbar-label">{t.settings}</span>
109114
</button>

0 commit comments

Comments
 (0)