Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/proud-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@learningmap/learningmap": minor
---

Make background nodes easier to work with and new nodes easier to see

- Add a layers panel listing every node, so nodes covered by another one can still be reached, selected, locked and reordered
- Add bring to front / forward / backward / send to back controls to the node panel and the multi selection panel
- 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
- Alt-click cycles through the nodes stacked under the cursor
- Selected nodes are lifted above the stack so their resize handles stay reachable
- Fix text nodes defaulting to a near-invisible light grey; the default now follows the background colour
- Empty image and text nodes render a visible placeholder and image nodes start at a usable size
- Fix nodes added with a keyboard shortcut getting no zIndex, which put them below every other node
- New nodes are placed inside the visible canvas, cascade instead of stacking on each other, and are selected on creation
- Text nodes can be edited in place with a double-click
- Fix the clickable area of a rotated text node not matching what is drawn
- Fix undo, copy, paste and delete being disabled whenever a node was selected
2 changes: 1 addition & 1 deletion packages/learningmap/src/ColorSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const ColorSelector: React.FC<ColorSelectorProps> = ({ value, onChange, l
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder="#e5e7eb"
placeholder="#111827"
style={{ width: 100 }}
/>
</div>
Expand Down
78 changes: 62 additions & 16 deletions packages/learningmap/src/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { MultiNodePanel } from "./MultiNodePanel";
import { EditorPanel } from "./EditorPanel";
import { EdgePanel } from "./EdgePanel";
import { SettingsPanel } from "./SettingsPanel";
import { LayersPanel } from "./LayersPanel";
import { NodeData } from "./types";
import { getNodesAtPosition } from "./zIndexHelper";
import { getReadableTextColor } from "./colorHelper";

const nodeTypes = {
topic: TopicNode,
Expand Down Expand Up @@ -39,6 +42,7 @@ export const EditorCanvas = memo(() => {
const onConnect = useEditorStore(state => state.onConnect);
const setSelectedNodeIds = useEditorStore(state => state.setSelectedNodeIds);
const setSelectedNodeId = useEditorStore(state => state.setSelectedNodeId);
const selectNode = useEditorStore(state => state.selectNode);
const setSelectedEdge = useEditorStore(state => state.setSelectedEdge);
const setDrawerOpen = useEditorStore(state => state.setDrawerOpen);
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
Expand Down Expand Up @@ -71,20 +75,42 @@ export const EditorCanvas = memo(() => {
canRedo: state.futureStates.length > 0,
}));

const handleNodeClick = useCallback((_: any, node: Node<NodeData>) => {
/**
* Picks the next node underneath the cursor, so nodes covered by another one
* can still be reached. Returns the clicked node when there is nothing to
* cycle through.
*/
const cycleStackedNode = useCallback((event: React.MouseEvent, clickedNodeId?: string) => {
const { nodes: currentNodes, selectedNodeId: currentSelectedNodeId } = useEditorStore.getState();
const point = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const stack = getNodesAtPosition(currentNodes, point);

if (stack.length === 0) return null;

const activeId = currentSelectedNodeId ?? clickedNodeId;
const activeIndex = stack.findIndex(n => n.id === activeId);
return stack[(activeIndex + 1) % stack.length];
}, [screenToFlowPosition]);

const handleNodeClick = useCallback((event: React.MouseEvent, node: Node<NodeData>) => {
// Execute picker callback when in picker mode
if (pickerMode) {
const executePickerCallback = useEditorStore.getState().executePickerCallback;
executePickerCallback(node.id);
return;
}

setSelectedNodeId(node.id);
setDrawerOpen(true);
setSelectedEdge(null);
setEdgeDrawerOpen(false);
setSettingsDrawerOpen(false);
}, [setSelectedNodeId, setDrawerOpen, setSelectedEdge, setEdgeDrawerOpen, setSettingsDrawerOpen, pickerMode]);

// Ctrl/Cmd-click adds to the selection and Shift-drag draws a selection
// box. Both are handled by React Flow, and taking over here would clear
// the other selected nodes.
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}

const target = event.altKey ? cycleStackedNode(event, node.id) ?? node : node;

selectNode(target.id, true);
}, [selectNode, pickerMode, cycleStackedNode]);

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

// Close the node panel if no nodes are selected and it's currently open
if (selectedNodes.length === 0) {
setDrawerOpen(false);
setSelectedNodeId(null);
}
if (selectedNodes.length > 0) return;

// Locked nodes are never selected on the canvas but can still be edited
// through the panel, so keep the panel open for them.
const state = useEditorStore.getState();
const activeNode = state.nodes.find(n => n.id === state.selectedNodeId);
if (activeNode?.data?.locked) return;

setDrawerOpen(false);
setSelectedNodeId(null);
},
[setSelectedNodeIds, setDrawerOpen, setSelectedNodeId]
);
Expand All @@ -115,13 +146,23 @@ export const EditorCanvas = memo(() => {
}, [screenToFlowPosition, setLastMousePosition]);

// Close panels when clicking on empty canvas
const handlePaneClick = useCallback(() => {
const handlePaneClick = useCallback((event: React.MouseEvent) => {
// Locked nodes do not receive clicks, so alt-clicking "through" them lands
// on the pane. Cycle from here as well to keep them reachable.
if (event.altKey && !pickerMode) {
const target = cycleStackedNode(event);
if (target) {
selectNode(target.id, true);
return;
}
}

setDrawerOpen(false);
setSelectedNodeId(null);
setEdgeDrawerOpen(false);
setSelectedEdge(null);
setSettingsDrawerOpen(false);
}, [setDrawerOpen, setSelectedNodeId, setEdgeDrawerOpen, setSelectedEdge, setSettingsDrawerOpen]);
}, [setDrawerOpen, setSelectedNodeId, setEdgeDrawerOpen, setSelectedEdge, setSettingsDrawerOpen, cycleStackedNode, selectNode, pickerMode]);

const defaultEdgeOptions = {
animated: false,
Expand All @@ -139,6 +180,8 @@ export const EditorCanvas = memo(() => {
style={{
backgroundColor: settings?.background?.color || "#ffffff",
cursor: pickerMode ? "crosshair" : "default",
// Default text colour for text nodes that have none of their own.
["--learningmap-text-default" as any]: getReadableTextColor(settings?.background?.color),
}}
onMouseMove={handleMouseMove}
>
Expand All @@ -159,7 +202,9 @@ export const EditorCanvas = memo(() => {
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={defaultEdgeOptions}
nodesDraggable={!pickerMode}
elevateNodesOnSelect={false}
// Selected nodes are lifted above the stack so their resize handles
// stay reachable even when the node itself sits in the background.
elevateNodesOnSelect={true}
nodesConnectable={!pickerMode}
selectNodesOnDrag={false}
elementsSelectable={!pickerMode}
Expand All @@ -178,6 +223,7 @@ export const EditorCanvas = memo(() => {
</ControlButton>
</Controls>
{selectedNodeIds.length > 1 && <MultiNodePanel />}
<LayersPanel />
<EditorPanel />
<EdgePanel />
<SettingsPanel />
Expand Down
2 changes: 2 additions & 0 deletions packages/learningmap/src/EditorDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const EditorDialogs = memo(({ jsonStore = "https://json.openpatch.org" }:
{ 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.cycleStackedNodes, shortcut: "Alt+Click" },
{ action: t.shortcuts.editTextInline, shortcut: "Double-click" },
{ action: t.shortcuts.selectAllNodes, shortcut: "Ctrl+A" },
{ action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" },
{ action: t.shortcuts.save, shortcut: "Ctrl+S" },
Expand Down
9 changes: 7 additions & 2 deletions packages/learningmap/src/EditorDrawerTextContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TextNodeData } from "./types";
import { ColorSelector } from "./ColorSelector";
import { RotationInput } from "./RotationInput";
import { useEditorStore } from "./editorStore";
import { getReadableTextColor } from "./colorHelper";

interface Props {
localNode: Node<TextNodeData>;
Expand All @@ -11,8 +12,12 @@ interface Props {

export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props) {
const getTranslationsFromStore = useEditorStore(state => state.getTranslations);
const backgroundColor = useEditorStore(state => state.settings?.background?.color);
const t = getTranslationsFromStore();


// Matches the default the text node renders with when no colour is set.
const defaultColor = getReadableTextColor(backgroundColor);

return (
<div className="panel-content">
<div className="form-group">
Expand All @@ -35,7 +40,7 @@ export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props)
<div className="form-group">
<ColorSelector
label={t.color}
value={localNode.data.color || "#e5e7eb"}
value={localNode.data.color || defaultColor}
onChange={color => handleFieldChange("color", color)}
/>
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/learningmap/src/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EditorDrawerTextContent } from "./EditorDrawerTextContent";
import { Completion, NodeData } from "./types";
import { useEditorStore } from "./editorStore";
import { NodePickerInput } from "./NodePickerInput";
import { LayerControls } from "./LayerControls";

export const EditorPanel: React.FC = () => {
// Get node and all nodes from store
Expand Down Expand Up @@ -296,6 +297,7 @@ export const EditorPanel: React.FC = () => {
<Panel position="center-right" className="editor-panel">
<div className="panel-inner">
{content}
<LayerControls nodeIds={[node.id]} />
<div className="panel-footer">
<button onClick={onCopy} className="secondary-button">
<Copy size={16} /> {t.copyNode}
Expand Down
41 changes: 23 additions & 18 deletions packages/learningmap/src/EditorToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from "react";
import { Menu, MenuButton, MenuDivider, MenuItem, SubMenu } from "@szhsin/react-menu";
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2, RotateCcw } from "lucide-react";
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2, RotateCcw, Layers } from "lucide-react";
import { useEditorStore } from "./editorStore";
import { Node, useReactFlow } from "@xyflow/react";
import { NodeData } from "./types";
import { useReactFlow } from "@xyflow/react";
import { useJsonStore } from "./useJsonStore";
import { useFileOperations } from "./useFileOperations";
import { getZIndexForNodeType } from "./zIndexHelper";
import { createNode, CreatableNodeType } from "./nodeFactory";

interface EditorToolbarProps {
disableSharing?: boolean;
Expand All @@ -33,6 +32,8 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
const setShowCompletionOptional = useEditorStore(state => state.setShowCompletionOptional);
const setShowUnlockAfter = useEditorStore(state => state.setShowUnlockAfter);
const addNode = useEditorStore(state => state.addNode);
const layersPanelOpen = useEditorStore(state => state.layersPanelOpen);
const setLayersPanelOpen = useEditorStore(state => state.setLayersPanelOpen);
const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen);
const setDrawerOpen = useEditorStore(state => state.setDrawerOpen);
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
Expand All @@ -52,20 +53,15 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
const onSetShowCompletionOptional = (checked: boolean) => setShowCompletionOptional(checked);
const onSetShowUnlockAfter = (checked: boolean) => setShowUnlockAfter(checked);

const onAddNewNode = (type: "task" | "topic" | "image" | "text") => {
// Position new nodes at the center of the current viewport
const position = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const newNode: Node<NodeData> = {
id: `node-${Date.now()}`,
type,
position,
zIndex: getZIndexForNodeType(type),
data: {
label: type === "task" ? t.newTask : type === "topic" ? t.newTopic : type,
state: "unlocked",
},
};
addNode(newNode);
const onAddNewNode = (type: CreatableNodeType) => {
addNode(
createNode({
type,
nodes: useEditorStore.getState().nodes,
t,
screenToFlowPosition,
}),
);
};

const onOpenSettingsDrawer = () => {
Expand Down Expand Up @@ -104,6 +100,15 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
<span style={{ marginLeft: 'auto', paddingLeft: '16px', color: '#9ca3af', fontSize: '0.875rem' }}>Ctrl+4</span>
</MenuItem>
</Menu>
<button
disabled={previewMode}
onClick={() => setLayersPanelOpen(!layersPanelOpen)}
className={`toolbar-button ${layersPanelOpen ? "active" : ""}`}
title={t.layers}
aria-pressed={layersPanelOpen}
>
<Layers size={16} /> <span className="toolbar-label">{t.layers}</span>
</button>
<button disabled={previewMode} onClick={onOpenSettingsDrawer} className="toolbar-button">
<Settings size={16} /> <span className="toolbar-label">{t.settings}</span>
</button>
Expand Down
Loading
Loading