From a540b140d299e25151a9be3cb01bc3fd4a148789 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:27:33 +0000 Subject: [PATCH] Improve working with background nodes and new nodes Background image and text nodes were hard to reach once other nodes covered them, and freshly added text and image nodes were nearly impossible to spot on the canvas. Reaching and ordering nodes: - Add a layers panel listing every node, so a fully covered node can still be selected, zoomed to, locked and reordered - Add bring to front / forward / backward / send to back controls to the node panel and the multi selection panel, backed by a new reorderNodes helper that normalizes the whole stack - Add per node locking that stops a positioned background image from being dragged, selected, cut or deleted by accident, while it stays editable through the layers panel - Alt-click cycles through the nodes stacked under the cursor - Selected nodes are lifted above the stack so the resize handles of a background node stay reachable Seeing new nodes: - Text nodes defaulted to #e5e7eb, a contrast ratio of about 1.2:1 on the default white background; the default now follows the background colour - Empty image and text nodes render a visible dashed placeholder, and image nodes start at a usable size instead of collapsing - Nodes added with a keyboard shortcut got no zIndex at all and rendered below every other node until the map was saved and reloaded; both add paths now share a single createNode helper - New nodes are placed inside the visible canvas rather than the window centre, avoid the open side panel, cascade instead of stacking on each other, and are selected on creation Other fixes: - Text nodes can be edited in place with a double-click - A rotated text node sized its hit box to the unrotated text, so clicks landed next to the glyphs; the wrapper now matches the rotated bounds - Undo, copy, paste and delete were disabled whenever a node was selected, because selecting one opens the editor panel and the shortcut handler bailed out on any open panel; it now only steps aside while the user is typing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qfk61fYbS3FP9MWE9e42WW --- .changeset/proud-donkeys-shave.md | 18 ++ packages/learningmap/src/ColorSelector.tsx | 2 +- packages/learningmap/src/EditorCanvas.tsx | 78 ++++-- packages/learningmap/src/EditorDialogs.tsx | 2 + .../src/EditorDrawerTextContent.tsx | 9 +- packages/learningmap/src/EditorPanel.tsx | 2 + packages/learningmap/src/EditorToolbar.tsx | 41 +-- .../learningmap/src/KeyboardShortcuts.tsx | 75 +++--- packages/learningmap/src/LayerControls.tsx | 72 ++++++ packages/learningmap/src/LayersPanel.tsx | 179 +++++++++++++ packages/learningmap/src/LearningMap.tsx | 3 + packages/learningmap/src/MultiNodePanel.tsx | 2 + packages/learningmap/src/WelcomeMessage.tsx | 24 +- packages/learningmap/src/colorHelper.test.ts | 34 +++ packages/learningmap/src/colorHelper.ts | 56 +++++ packages/learningmap/src/editorStore.ts | 124 ++++++++- packages/learningmap/src/index.css | 238 ++++++++++++++++++ packages/learningmap/src/nodeFactory.test.ts | 152 +++++++++++ packages/learningmap/src/nodeFactory.ts | 121 +++++++++ packages/learningmap/src/nodes/ImageNode.tsx | 15 +- packages/learningmap/src/nodes/TextNode.tsx | 127 +++++++++- packages/learningmap/src/translations.ts | 54 ++++ packages/learningmap/src/types.ts | 4 + packages/learningmap/src/zIndexHelper.test.ts | 197 +++++++++++++++ packages/learningmap/src/zIndexHelper.ts | 147 +++++++++++ 25 files changed, 1673 insertions(+), 103 deletions(-) create mode 100644 .changeset/proud-donkeys-shave.md create mode 100644 packages/learningmap/src/LayerControls.tsx create mode 100644 packages/learningmap/src/LayersPanel.tsx create mode 100644 packages/learningmap/src/colorHelper.test.ts create mode 100644 packages/learningmap/src/colorHelper.ts create mode 100644 packages/learningmap/src/nodeFactory.test.ts create mode 100644 packages/learningmap/src/nodeFactory.ts create mode 100644 packages/learningmap/src/zIndexHelper.test.ts diff --git a/.changeset/proud-donkeys-shave.md b/.changeset/proud-donkeys-shave.md new file mode 100644 index 0000000..88be9e6 --- /dev/null +++ b/.changeset/proud-donkeys-shave.md @@ -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 diff --git a/packages/learningmap/src/ColorSelector.tsx b/packages/learningmap/src/ColorSelector.tsx index 911ee2f..c355590 100644 --- a/packages/learningmap/src/ColorSelector.tsx +++ b/packages/learningmap/src/ColorSelector.tsx @@ -20,7 +20,7 @@ export const ColorSelector: React.FC = ({ value, onChange, l type="text" value={value} onChange={e => onChange(e.target.value)} - placeholder="#e5e7eb" + placeholder="#111827" style={{ width: 100 }} /> diff --git a/packages/learningmap/src/EditorCanvas.tsx b/packages/learningmap/src/EditorCanvas.tsx index 8e93559..bd14526 100644 --- a/packages/learningmap/src/EditorCanvas.tsx +++ b/packages/learningmap/src/EditorCanvas.tsx @@ -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, @@ -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); @@ -71,20 +75,42 @@ export const EditorCanvas = memo(() => { canRedo: state.futureStates.length > 0, })); - const handleNodeClick = useCallback((_: any, node: Node) => { + /** + * 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) => { // 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); @@ -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] ); @@ -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, @@ -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} > @@ -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} @@ -178,6 +223,7 @@ export const EditorCanvas = memo(() => { {selectedNodeIds.length > 1 && } + diff --git a/packages/learningmap/src/EditorDialogs.tsx b/packages/learningmap/src/EditorDialogs.tsx index 6b83a72..55b15a9 100644 --- a/packages/learningmap/src/EditorDialogs.tsx +++ b/packages/learningmap/src/EditorDialogs.tsx @@ -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" }, diff --git a/packages/learningmap/src/EditorDrawerTextContent.tsx b/packages/learningmap/src/EditorDrawerTextContent.tsx index a1ee820..4cd1230 100644 --- a/packages/learningmap/src/EditorDrawerTextContent.tsx +++ b/packages/learningmap/src/EditorDrawerTextContent.tsx @@ -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; @@ -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 (
@@ -35,7 +40,7 @@ export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props)
handleFieldChange("color", color)} />
diff --git a/packages/learningmap/src/EditorPanel.tsx b/packages/learningmap/src/EditorPanel.tsx index 0833528..f1b49e9 100644 --- a/packages/learningmap/src/EditorPanel.tsx +++ b/packages/learningmap/src/EditorPanel.tsx @@ -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 @@ -296,6 +297,7 @@ export const EditorPanel: React.FC = () => {
{content} +
diff --git a/packages/learningmap/src/KeyboardShortcuts.tsx b/packages/learningmap/src/KeyboardShortcuts.tsx index 90a87df..6cfa70d 100644 --- a/packages/learningmap/src/KeyboardShortcuts.tsx +++ b/packages/learningmap/src/KeyboardShortcuts.tsx @@ -1,8 +1,8 @@ import { useEffect } from "react"; import { useReactFlow } from "@xyflow/react"; import { useEditorStore, useTemporalStore } from "./editorStore"; -import { Node } from "@xyflow/react"; -import { NodeData, KeyBindings, KeyBinding } from "./types"; +import { KeyBindings, KeyBinding } from "./types"; +import { createNode, CreatableNodeType } from "./nodeFactory"; interface KeyboardShortcutsProps { jsonStore?: string; @@ -35,6 +35,20 @@ const defaultKeyBindings: KeyBindings = { deleteSelected: { key: 'Delete' }, }; +/** + * True while the user is typing, so shortcuts do not hijack the keystroke. + * + * This replaces an earlier check on the open drawers: because selecting a node + * opens the editor panel, that check disabled undo, copy/paste and delete for + * as long as a node was selected. + */ +const isTypingTarget = (target: EventTarget | null): boolean => { + const element = target as HTMLElement | null; + if (!element || !element.tagName) return false; + if (element.isContentEditable) return true; + return ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName); +}; + const matchesKeyBinding = (e: KeyboardEvent, binding: KeyBinding | undefined): boolean => { if (!binding) return false; @@ -79,9 +93,6 @@ export const KeyboardShortcuts = ({ const deleteEdge = useEditorStore(state => state.deleteEdge); const setSelectedEdge = useEditorStore(state => state.setSelectedEdge); const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen); - const drawerOpen = useEditorStore(state => state.drawerOpen); - const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen); - const settingsDrawerOpen = useEditorStore(state => state.settingsDrawerOpen); const getTranslationsFromStore = useEditorStore(state => state.getTranslations); const pickerMode = useEditorStore(state => state.pickerMode); const setPickerMode = useEditorStore(state => state.setPickerMode); @@ -94,20 +105,21 @@ export const KeyboardShortcuts = ({ redo: state.redo, })); - const onAddNode = (type: "task" | "topic" | "image" | "text") => { - const position = lastMousePosition || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - const newNode: Node = { - id: `node-${Date.now()}`, - type, - position, - data: { - label: type === "task" ? t.newTask : type === "topic" ? t.newTopic : type, - state: "unlocked", - }, - }; - addNode(newNode); + const onAddNode = (type: CreatableNodeType) => { + addNode( + createNode({ + type, + nodes: useEditorStore.getState().nodes, + t, + position: lastMousePosition, + screenToFlowPosition, + }), + ); }; + const isLocked = (nodeId: string) => + Boolean(nodes.find(n => n.id === nodeId)?.data?.locked); + const onDeleteSelected = () => { // Delete selected edge if any if (selectedEdge) { @@ -116,11 +128,11 @@ export const KeyboardShortcuts = ({ setEdgeDrawerOpen(false); return; } - - // Otherwise delete selected nodes - if (selectedNodeIds.length > 0) { - // Delete all selected nodes - selectedNodeIds.forEach(nodeId => { + + // Otherwise delete selected nodes, leaving locked ones alone + const deletable = selectedNodeIds.filter(nodeId => !isLocked(nodeId)); + if (deletable.length > 0) { + deletable.forEach(nodeId => { deleteNode(nodeId); }); setSelectedNodeIds([]); @@ -156,12 +168,11 @@ export const KeyboardShortcuts = ({ }; const onCut = () => { - const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id) && !n.data?.locked); if (selectedNodes.length > 0) { setClipboard({ nodes: selectedNodes, edges: [] }); - // Delete all selected nodes - selectedNodeIds.forEach(nodeId => { - deleteNode(nodeId); + selectedNodes.forEach(node => { + deleteNode(node.id); }); setSelectedNodeIds([]); } @@ -187,7 +198,7 @@ export const KeyboardShortcuts = ({ }; const onSelectAll = () => { - setSelectedNodeIds(nodes.map(n => n.id)); + setSelectedNodeIds(nodes.filter(n => !n.data?.locked).map(n => n.id)); }; const onZoomIn = () => zoomIn(); @@ -215,10 +226,12 @@ export const KeyboardShortcuts = ({ return; } - if (drawerOpen || edgeDrawerOpen || settingsDrawerOpen) { - return; // Ignore shortcuts when any drawer is open + // Only step aside while the user is actually typing, so shortcuts keep + // working with a node selected and its editor panel open. + if (isTypingTarget(e.target)) { + return; } - + // Check each keybinding if (matchesKeyBinding(e, keyBindings.addTaskNode)) { e.preventDefault(); @@ -295,7 +308,7 @@ export const KeyboardShortcuts = ({ }; }, [onAddNode, onDeleteSelected, onSave, undo, redo, helpOpen, setHelpOpen, onTogglePreview, onToggleDebug, onZoomIn, onZoomOut, onResetZoom, onFitView, onZoomToSelection, onToggleGrid, - onResetMap, onCut, onCopy, onPaste, onSelectAll, drawerOpen, edgeDrawerOpen, settingsDrawerOpen, keyBindings, pickerMode, setPickerMode]); + onResetMap, onCut, onCopy, onPaste, onSelectAll, keyBindings, pickerMode, setPickerMode]); return null; }; diff --git a/packages/learningmap/src/LayerControls.tsx b/packages/learningmap/src/LayerControls.tsx new file mode 100644 index 0000000..bc31eb9 --- /dev/null +++ b/packages/learningmap/src/LayerControls.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { + ArrowUpToLine, + ArrowDownToLine, + ArrowUp, + ArrowDown, + Lock, + Unlock, +} from "lucide-react"; +import { useEditorStore } from "./editorStore"; + +interface LayerControlsProps { + nodeIds: string[]; + /** Renders as a compact icon row without labels. */ + compact?: boolean; +} + +/** + * Stacking order and lock controls, shared by the single node editor panel and + * the multi selection panel. + */ +export const LayerControls: React.FC = ({ + nodeIds, + compact = false, +}) => { + const nodes = useEditorStore((state) => state.nodes); + const reorderNodes = useEditorStore((state) => state.reorderNodes); + const setNodesLocked = useEditorStore((state) => state.setNodesLocked); + const t = useEditorStore((state) => state.getTranslations)(); + + if (nodeIds.length === 0) return null; + + const selected = nodes.filter((n) => nodeIds.includes(n.id)); + const allLocked = + selected.length > 0 && selected.every((n) => Boolean(n.data?.locked)); + + const buttons = [ + { title: t.bringToFront, icon: ArrowUpToLine, op: "front" as const }, + { title: t.bringForward, icon: ArrowUp, op: "forward" as const }, + { title: t.sendBackward, icon: ArrowDown, op: "backward" as const }, + { title: t.sendToBack, icon: ArrowDownToLine, op: "back" as const }, + ]; + + const LockIcon = allLocked ? Lock : Unlock; + const lockTitle = allLocked ? t.unlockNode : t.lockNode; + + return ( +
+ {buttons.map(({ title, icon: Icon, op }) => ( + + ))} + +
+ ); +}; diff --git a/packages/learningmap/src/LayersPanel.tsx b/packages/learningmap/src/LayersPanel.tsx new file mode 100644 index 0000000..f9325ac --- /dev/null +++ b/packages/learningmap/src/LayersPanel.tsx @@ -0,0 +1,179 @@ +import React from "react"; +import { Node, Panel, useReactFlow } from "@xyflow/react"; +import { + X, + Lock, + Unlock, + Image as ImageIcon, + Type, + CircleCheck, + Circle, + ArrowUpToLine, + ArrowDownToLine, + ArrowUp, + ArrowDown, + Crosshair, +} from "lucide-react"; +import { useEditorStore } from "./editorStore"; +import { NodeData } from "./types"; +import { getEffectiveZIndex } from "./zIndexHelper"; +import { Translations } from "./translations"; + +const TYPE_ICONS: Record> = { + task: CircleCheck, + topic: Circle, + image: ImageIcon, + text: Type, +}; + +/** + * Display name for a node. Only task and topic nodes carry a label, so image + * and text nodes fall back to their own content. + */ +export function getNodeDisplayName( + node: Node, + t: Translations, +): string { + if (node.type === "image") { + return node.data?.caption || t.image; + } + if (node.type === "text") { + return node.data?.text || t.text; + } + return node.data?.label || t.untitled; +} + +export const LayersPanel: React.FC = () => { + const isOpen = useEditorStore((state) => state.layersPanelOpen); + const nodes = useEditorStore((state) => state.nodes); + const selectedNodeId = useEditorStore((state) => state.selectedNodeId); + const selectedNodeIds = useEditorStore((state) => state.selectedNodeIds); + + const setLayersPanelOpen = useEditorStore((state) => state.setLayersPanelOpen); + const selectNode = useEditorStore((state) => state.selectNode); + const setNodeLocked = useEditorStore((state) => state.setNodeLocked); + const reorderNodes = useEditorStore((state) => state.reorderNodes); + const t = useEditorStore((state) => state.getTranslations)(); + + const { fitView } = useReactFlow(); + + if (!isOpen) return null; + + // Top-most first, matching how the nodes are stacked on the canvas. + const ordered = [...nodes].sort( + (a, b) => getEffectiveZIndex(b) - getEffectiveZIndex(a), + ); + + const isActive = (nodeId: string) => + nodeId === selectedNodeId || selectedNodeIds.includes(nodeId); + + const activeIds = () => { + if (selectedNodeIds.length > 0) return selectedNodeIds; + return selectedNodeId ? [selectedNodeId] : []; + }; + + const onReorder = (operation: Parameters[1]) => { + const ids = activeIds(); + if (ids.length > 0) { + reorderNodes(ids, operation); + } + }; + + const onRevealNode = (node: Node) => { + selectNode(node.id, true); + fitView({ nodes: [{ id: node.id }], duration: 300, maxZoom: 1.5 }); + }; + + const hasSelection = activeIds().length > 0; + + return ( + +
+

{t.layers}

+ +
+ +
+ + + + +
+ + {ordered.length === 0 ? ( +

{t.layersEmpty}

+ ) : ( +
    + {ordered.map((node) => { + const Icon = TYPE_ICONS[node.type || "task"] || Circle; + const locked = Boolean(node.data?.locked); + return ( +
  • + + + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/packages/learningmap/src/LearningMap.tsx b/packages/learningmap/src/LearningMap.tsx index d77977f..92ddcfb 100644 --- a/packages/learningmap/src/LearningMap.tsx +++ b/packages/learningmap/src/LearningMap.tsx @@ -10,6 +10,7 @@ import { Drawer } from "./Drawer"; import { ProgressTracker } from "./ProgressTracker"; import { useViewerStore } from "./viewerStore"; import { detectBrowserLanguage } from "./translations"; +import { getReadableTextColor } from "./colorHelper"; const nodeTypes = { topic: TopicNode, @@ -152,6 +153,8 @@ export function LearningMap({ className="editor-canvas" style={{ backgroundColor: settings?.background?.color || "#ffffff", + // Default text colour for text nodes that have none of their own. + ["--learningmap-text-default" as any]: getReadableTextColor(settings?.background?.color), }} > { // Get selected nodes from store @@ -146,5 +147,6 @@ export const MultiNodePanel: FC = () => { {nodes.length > 2 && } + ; } diff --git a/packages/learningmap/src/WelcomeMessage.tsx b/packages/learningmap/src/WelcomeMessage.tsx index 3404192..c838b46 100644 --- a/packages/learningmap/src/WelcomeMessage.tsx +++ b/packages/learningmap/src/WelcomeMessage.tsx @@ -1,10 +1,10 @@ import React from "react"; import { FolderOpen, Plus, Info } from "lucide-react"; import { useEditorStore } from "./editorStore"; -import { Node } from "@xyflow/react"; -import { NodeData } from "./types"; +import { useReactFlow } from "@xyflow/react"; import logo from "./logo.svg"; import { useFileOperations } from "./useFileOperations"; +import { createNode } from "./nodeFactory"; export const WelcomeMessage: React.FC = () => { // Get state and actions from store @@ -13,21 +13,19 @@ export const WelcomeMessage: React.FC = () => { const getTranslationsFromStore = useEditorStore(state => state.getTranslations); const { openRoadmap } = useFileOperations(); + const { screenToFlowPosition } = useReactFlow(); const t = getTranslationsFromStore(); const onAddTopic = () => { - const position = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; - const newNode: Node = { - id: `node-${Date.now()}`, - type: "topic", - position, - data: { - label: t.newTopic, - state: "unlocked", - }, - }; - addNode(newNode); + addNode( + createNode({ + type: "topic", + nodes: useEditorStore.getState().nodes, + t, + screenToFlowPosition, + }), + ); }; const onShowHelp = () => setHelpOpen(true); diff --git a/packages/learningmap/src/colorHelper.test.ts b/packages/learningmap/src/colorHelper.test.ts new file mode 100644 index 0000000..127070b --- /dev/null +++ b/packages/learningmap/src/colorHelper.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { getReadableTextColor, getRelativeLuminance } from "./colorHelper"; + +describe("getRelativeLuminance", () => { + it("returns 0 for black and 1 for white", () => { + expect(getRelativeLuminance("#000000")).toBeCloseTo(0); + expect(getRelativeLuminance("#ffffff")).toBeCloseTo(1); + }); + + it("supports the short hex form", () => { + expect(getRelativeLuminance("#fff")).toBeCloseTo( + getRelativeLuminance("#ffffff"), + ); + }); + + it("treats an unparseable colour as light", () => { + expect(getRelativeLuminance("not-a-colour")).toBe(1); + }); +}); + +describe("getReadableTextColor", () => { + it("uses dark text on a light background", () => { + expect(getReadableTextColor("#ffffff")).toBe("#111827"); + }); + + it("uses light text on a dark background", () => { + expect(getReadableTextColor("#111827")).toBe("#f9fafb"); + }); + + it("defaults to the white background when none is given", () => { + expect(getReadableTextColor()).toBe("#111827"); + expect(getReadableTextColor("")).toBe("#111827"); + }); +}); diff --git a/packages/learningmap/src/colorHelper.ts b/packages/learningmap/src/colorHelper.ts new file mode 100644 index 0000000..9ddc4cf --- /dev/null +++ b/packages/learningmap/src/colorHelper.ts @@ -0,0 +1,56 @@ +/** Fallback used when no background colour has been configured. */ +const DEFAULT_BACKGROUND = "#ffffff"; + +const DARK_TEXT = "#111827"; +const LIGHT_TEXT = "#f9fafb"; + +function parseHexColor(color: string): [number, number, number] | null { + const hex = color.trim().replace(/^#/, ""); + + if (hex.length === 3) { + const [r, g, b] = hex.split(""); + return [ + parseInt(r + r, 16), + parseInt(g + g, 16), + parseInt(b + b, 16), + ]; + } + + if (hex.length === 6) { + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + } + + return null; +} + +/** Relative luminance per WCAG 2.1, from 0 (black) to 1 (white). */ +export function getRelativeLuminance(color: string): number { + const rgb = parseHexColor(color); + if (!rgb || rgb.some(Number.isNaN)) return 1; + + const [r, g, b] = rgb.map((channel) => { + const value = channel / 255; + return value <= 0.03928 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Text colour that stays readable on `background`. + * + * Used as the default for text nodes: the previous fixed default (#e5e7eb) + * sits at a contrast ratio of roughly 1.2:1 on the default white background, + * which makes a freshly added text node effectively invisible. + */ +export function getReadableTextColor(background?: string): string { + return getRelativeLuminance(background || DEFAULT_BACKGROUND) > 0.5 + ? DARK_TEXT + : LIGHT_TEXT; +} diff --git a/packages/learningmap/src/editorStore.ts b/packages/learningmap/src/editorStore.ts index d17a0ff..8fb8e23 100644 --- a/packages/learningmap/src/editorStore.ts +++ b/packages/learningmap/src/editorStore.ts @@ -16,7 +16,11 @@ import { Connection, } from "@xyflow/react"; import { NodeData, RoadmapData, Settings } from "./types"; -import { getZIndexForNodeType } from "./zIndexHelper"; +import { + getZIndexForNodeType, + reorderNodes, + LayerOperation, +} from "./zIndexHelper"; import { getTranslations, detectBrowserLanguage, Translations } from "./translations"; // Global flag to control persistence @@ -26,6 +30,22 @@ export function setPersistence(enabled: boolean) { persistenceEnabled = enabled; } +/** + * Mirrors `data.locked` onto the React Flow interaction flags. + * + * Locking lives in `data` so it is persisted with the map, but React Flow + * reads these flags from the node itself. + */ +function applyLockFlags(node: Node): Node { + const locked = Boolean(node.data?.locked); + return { + ...node, + draggable: !locked, + selectable: !locked, + connectable: !locked, + }; +} + // Note: This is a global store for the editor. Typically only one editor instance is active at a time. // If you need multiple independent editor instances, consider creating store instances per component or using context. export interface EditorState { @@ -47,6 +67,7 @@ export interface EditorState { edgeDrawerOpen: boolean; shareDialogOpen: boolean; loadExternalDialogOpen: boolean; + layersPanelOpen: boolean; // Selected items selectedNodeId: string | null; @@ -84,6 +105,10 @@ export interface EditorState { deleteNode: (nodeId: string) => void; deleteEdge: (edgeId: string) => void; addNode: (node: Node) => void; + reorderNodes: (nodeIds: string[], operation: LayerOperation) => void; + setNodeLocked: (nodeId: string, locked: boolean) => void; + setNodesLocked: (nodeIds: string[], locked: boolean) => void; + selectNode: (nodeId: string, openPanel?: boolean) => void; setJsonStore: (jsonStore: string) => void; setDefaultLanguage: (defaultLanguage: string) => void; @@ -98,6 +123,7 @@ export interface EditorState { setEdgeDrawerOpen: (edgeDrawerOpen: boolean) => void; setShareDialogOpen: (shareDialogOpen: boolean) => void; setLoadExternalDialogOpen: (loadExternalDialogOpen: boolean) => void; + setLayersPanelOpen: (layersPanelOpen: boolean) => void; setSelectedNodeId: (nodeId: string | null) => void; setSelectedNodeIds: (nodeIds: string[]) => void; setSelectedEdge: (edge: Edge | null) => void; @@ -139,6 +165,7 @@ const initialState = { edgeDrawerOpen: false, shareDialogOpen: false, loadExternalDialogOpen: false, + layersPanelOpen: false, selectedNodeId: null, selectedNodeIds: [], selectedEdge: null, @@ -315,7 +342,7 @@ export const useEditorStore = create()( updateNode: (nodeId, updates) => { set({ nodes: get().nodes.map((n) => - n.id === nodeId ? { ...n, ...updates } : n, + n.id === nodeId ? applyLockFlags({ ...n, ...updates }) : n, ), }); get().updateDebugEdges(); @@ -325,7 +352,7 @@ export const useEditorStore = create()( set({ nodes: get().nodes.map((n) => n.id === nodeId - ? { ...n, data: { ...n.data, ...dataUpdates } } + ? applyLockFlags({ ...n, data: { ...n.data, ...dataUpdates } }) : n, ), }); @@ -371,8 +398,77 @@ export const useEditorStore = create()( }, addNode: (node) => { + // Deselect everything else so the new node is unambiguously the + // active one, otherwise it is easy to lose track of where it landed. + const nodes = get().nodes.map((n) => + n.selected ? { ...n, selected: false } : n, + ); + + set({ + nodes: [...nodes, applyLockFlags({ ...node, selected: true })], + selectedNodeId: node.id, + selectedNodeIds: [node.id], + }); + + // Image and text nodes are empty until they are given content, so + // open the editor right away instead of leaving a blank placeholder. + if (node.type === "image" || node.type === "text") { + set({ + drawerOpen: true, + edgeDrawerOpen: false, + settingsDrawerOpen: false, + selectedEdge: null, + }); + } + }, + + reorderNodes: (nodeIds, operation) => { + set({ nodes: reorderNodes(get().nodes, nodeIds, operation) }); + }, + + setNodeLocked: (nodeId, locked) => { + get().setNodesLocked([nodeId], locked); + }, + + setNodesLocked: (nodeIds, locked) => { + const ids = new Set(nodeIds); + set({ + nodes: get().nodes.map((n) => + ids.has(n.id) + ? applyLockFlags({ + ...n, + data: { ...n.data, locked }, + // A locked node cannot stay selected on the canvas. + selected: locked ? false : n.selected, + }) + : n, + ), + }); + + if (locked) { + set({ + selectedNodeIds: get().selectedNodeIds.filter( + (id) => !ids.has(id), + ), + }); + } + }, + + selectNode: (nodeId, openPanel = true) => { + const node = get().nodes.find((n) => n.id === nodeId); set({ - nodes: [...get().nodes, node], + nodes: get().nodes.map((n) => { + // Locked nodes stay unselected on the canvas, but can still be + // edited through the panel. + const selected = n.id === nodeId && !n.data?.locked; + return n.selected === selected ? n : { ...n, selected }; + }), + selectedNodeId: nodeId, + selectedNodeIds: node?.data?.locked ? [] : [nodeId], + selectedEdge: null, + edgeDrawerOpen: false, + settingsDrawerOpen: false, + drawerOpen: openPanel, }); }, @@ -399,6 +495,7 @@ export const useEditorStore = create()( setShareDialogOpen: (shareDialogOpen) => set({ shareDialogOpen }), setLoadExternalDialogOpen: (loadExternalDialogOpen) => set({ loadExternalDialogOpen }), + setLayersPanelOpen: (layersPanelOpen) => set({ layersPanelOpen }), setSelectedNodeId: (selectedNodeId) => set({ selectedNodeId, @@ -450,15 +547,16 @@ export const useEditorStore = create()( ? roadmapData.edges : []; - const rawNodes = nodesArr.map((n) => ({ - ...n, - draggable: true, - className: n.data.color ? n.data.color : n.className, - // Ensure zIndex is set based on node type if not already present - zIndex: - n.zIndex !== undefined ? n.zIndex : getZIndexForNodeType(n.type), - data: { ...n.data }, - })); + const rawNodes = nodesArr.map((n) => + applyLockFlags({ + ...n, + className: n.data.color ? n.data.color : n.className, + // Ensure zIndex is set based on node type if not already present + zIndex: + n.zIndex !== undefined ? n.zIndex : getZIndexForNodeType(n.type), + data: { ...n.data }, + }), + ); // Calculate next node ID let nextNodeId = 1; diff --git a/packages/learningmap/src/index.css b/packages/learningmap/src/index.css index 5ec5c0b..7850780 100644 --- a/packages/learningmap/src/index.css +++ b/packages/learningmap/src/index.css @@ -534,6 +534,65 @@ header.drawer-header { width: 100%; } +/* Empty node placeholders ------------------------------------------------- + An image or text node without content used to render as a bare, unstyled + label that was almost impossible to spot on the canvas. */ +.node-placeholder { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 100%; + min-width: 160px; + min-height: 120px; + padding: 8px; + border: 2px dashed #94a3b8; + border-radius: 12px; + background: rgba(148, 163, 184, 0.14); + color: #475569; + font-size: 14px; + text-align: center; + box-sizing: border-box; +} + +.node-placeholder-text { + min-width: 0; + min-height: 0; + padding: 6px 12px; + white-space: nowrap; +} + +/* Text nodes -------------------------------------------------------------- + The wrapper is sized to the rotated bounding box of its content so the + clickable area lines up with what is drawn. */ +.text-node { + position: relative; + min-width: 24px; + min-height: 24px; +} + +.text-node-content { + position: absolute; + top: 50%; + left: 50%; + font-weight: bold; + white-space: nowrap; + transform-origin: center center; +} + +.text-node-input { + font: inherit; + font-weight: inherit; + color: inherit; + background: rgba(255, 255, 255, 0.92); + border: 2px solid var(--learningmap-color-openpatch); + border-radius: 6px; + padding: 2px 6px; + outline: none; + min-width: 120px; +} + .react-flow__node-task { padding: 16px 24px; border-radius: 16px; @@ -1032,6 +1091,185 @@ dialog.help[open] { flex: 1; } +/* Layer controls (editor panel + multi node panel) */ +.layer-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid #e5e7eb; + flex-shrink: 0; +} + +.layer-controls button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: white; + color: #1f2937; + font-size: 13px; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s; +} + +.layer-controls button:hover:not(:disabled) { + background: #f3f4f6; + border-color: var(--learningmap-color-openpatch); +} + +.layer-controls button.active { + border-color: var(--learningmap-color-openpatch); + background: #eff6ff; +} + +.layer-controls.compact { + padding: 0; + border-top: none; + gap: 4px; +} + +.layer-controls.compact button { + padding: 8px; + border: none; +} + +/* Layers panel */ +.layers-panel { + pointer-events: all; + width: 260px; + max-height: 70vh; + display: flex; + flex-direction: column; + background: var(--color-nav, white); + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.layers-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; +} + +.layers-panel-title { + font-size: 16px; + font-weight: 700; + margin: 0; +} + +.layers-panel-actions { + display: flex; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.layers-panel-actions button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: white; + color: #1f2937; + cursor: pointer; +} + +.layers-panel-actions button:hover:not(:disabled) { + background: #f3f4f6; + border-color: var(--learningmap-color-openpatch); +} + +.layers-panel-actions button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.layers-panel-empty { + padding: 16px; + margin: 0; + color: #6b7280; + font-size: 13px; + text-align: center; +} + +.layers-list { + list-style: none; + margin: 0; + padding: 4px; + overflow-y: auto; +} + +.layers-list-item { + display: flex; + align-items: center; + gap: 2px; + border-radius: 6px; + padding: 2px; +} + +.layers-list-item:hover { + background: #f3f4f6; +} + +.layers-list-item.active { + background: #eff6ff; + outline: 1px solid var(--learningmap-color-openpatch); +} + +.layers-list-item.locked .layers-list-label { + color: #6b7280; + font-style: italic; +} + +.layers-list-select { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: none; + background: none; + color: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.layers-list-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layers-list-action { + display: flex; + align-items: center; + padding: 6px; + border: none; + border-radius: 4px; + background: none; + color: #6b7280; + cursor: pointer; +} + +.layers-list-action:hover { + background: #e5e7eb; + color: #1f2937; +} + /* Picker Mode Styles */ .editor-canvas.picker-mode .react-flow__node-task, .editor-canvas.picker-mode .react-flow__node-topic { diff --git a/packages/learningmap/src/nodeFactory.test.ts b/packages/learningmap/src/nodeFactory.test.ts new file mode 100644 index 0000000..12f6957 --- /dev/null +++ b/packages/learningmap/src/nodeFactory.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { Node } from "@xyflow/react"; +import { createNode, findFreePosition } from "./nodeFactory"; +import { NodeData } from "./types"; +import { translations } from "./translations"; + +const t = translations.en; + +const makeNode = ( + id: string, + type: string, + x: number, + y: number, + zIndex?: number, +): Node => + ({ + id, + type, + position: { x, y }, + zIndex, + data: { label: id, state: "unlocked" }, + }) as Node; + +// The factory never needs a real viewport in these tests. +const screenToFlowPosition = (position: { x: number; y: number }) => position; + +describe("findFreePosition", () => { + it("keeps a free position untouched", () => { + expect(findFreePosition([], { x: 10, y: 10 })).toEqual({ x: 10, y: 10 }); + }); + + it("moves off an occupied position", () => { + const nodes = [makeNode("a", "task", 10, 10)]; + expect(findFreePosition(nodes, { x: 10, y: 10 })).not.toEqual({ + x: 10, + y: 10, + }); + }); + + it("cascades past a run of stacked nodes", () => { + const nodes = [ + makeNode("a", "task", 0, 0), + makeNode("b", "task", 28, 28), + makeNode("c", "task", 56, 56), + ]; + expect(findFreePosition(nodes, { x: 0, y: 0 })).toEqual({ x: 84, y: 84 }); + }); +}); + +describe("createNode", () => { + it("always sets a zIndex", () => { + // Regression: nodes added via the keyboard used to have no zIndex at all + // and rendered below every other node. + for (const type of ["task", "topic", "image", "text"] as const) { + const node = createNode({ + type, + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }); + expect(node.zIndex).toBeTypeOf("number"); + } + }); + + it("places a new image behind the existing nodes", () => { + const nodes = [makeNode("a", "task", 0, 0, 5)]; + const node = createNode({ + type: "image", + nodes, + t, + position: { x: 200, y: 200 }, + screenToFlowPosition, + }); + expect(node.zIndex!).toBeLessThan(5); + }); + + it("gives image nodes an initial size", () => { + const node = createNode({ + type: "image", + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }); + expect(node.width).toBeGreaterThan(0); + expect(node.height).toBeGreaterThan(0); + }); + + it("labels task and topic nodes", () => { + expect( + createNode({ + type: "task", + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }).data.label, + ).toBe(t.newTask); + expect( + createNode({ + type: "topic", + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }).data.label, + ).toBe(t.newTopic); + }); + + it("does not put a placeholder label on image and text nodes", () => { + for (const type of ["image", "text"] as const) { + const node = createNode({ + type, + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }); + expect(node.data.label).toBeUndefined(); + } + }); + + it("offsets a node that would land on an existing one", () => { + const nodes = [makeNode("a", "task", 100, 100)]; + const node = createNode({ + type: "task", + nodes, + t, + position: { x: 100, y: 100 }, + screenToFlowPosition, + }); + expect(node.position).not.toEqual({ x: 100, y: 100 }); + }); + + it("generates unique ids for nodes created in the same millisecond", () => { + const ids = new Set( + Array.from( + { length: 50 }, + () => + createNode({ + type: "task", + nodes: [], + t, + position: { x: 0, y: 0 }, + screenToFlowPosition, + }).id, + ), + ); + expect(ids.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/learningmap/src/nodeFactory.ts b/packages/learningmap/src/nodeFactory.ts new file mode 100644 index 0000000..18c90a1 --- /dev/null +++ b/packages/learningmap/src/nodeFactory.ts @@ -0,0 +1,121 @@ +import { Node, XYPosition } from "@xyflow/react"; +import { NodeData } from "./types"; +import { getZIndexForNewNode } from "./zIndexHelper"; +import { Translations } from "./translations"; + +export type CreatableNodeType = "task" | "topic" | "image" | "text"; + +/** Initial size for nodes that would otherwise render as a tiny empty box. */ +const INITIAL_SIZE: Partial< + Record +> = { + image: { width: 220, height: 160 }, +}; + +/** Distance at which a new node is considered to overlap an existing one. */ +const OCCUPIED_THRESHOLD = 24; +const CASCADE_OFFSET = 28; +const MAX_CASCADE_STEPS = 40; + +type ScreenToFlowPosition = (position: XYPosition) => XYPosition; + +/** + * Center of the visible canvas in flow coordinates. + * + * Uses the canvas element instead of the window so the toolbar is accounted + * for, and shrinks the usable area by an open side panel so new nodes never + * land underneath it. + */ +export function getCanvasCenterPosition( + screenToFlowPosition: ScreenToFlowPosition, +): XYPosition { + if (typeof document === "undefined") { + return screenToFlowPosition({ x: 0, y: 0 }); + } + + const canvas = document.querySelector(".editor-canvas"); + const rect = canvas?.getBoundingClientRect(); + + const left = rect?.left ?? 0; + const top = rect?.top ?? 0; + let width = rect?.width ?? window.innerWidth; + const height = rect?.height ?? window.innerHeight; + + // The editor panel is docked to the right edge and covers the canvas. + const panel = document.querySelector(".editor-panel"); + if (panel) { + const panelWidth = panel.getBoundingClientRect().width; + width = Math.max(width - panelWidth, width / 3); + } + + return screenToFlowPosition({ x: left + width / 2, y: top + height / 2 }); +} + +/** + * Nudges `position` diagonally until it no longer sits on top of an existing + * node, so consecutively added nodes cascade instead of hiding each other. + */ +export function findFreePosition( + nodes: Node[], + position: XYPosition, +): XYPosition { + let candidate = { ...position }; + + for (let step = 0; step < MAX_CASCADE_STEPS; step++) { + const occupied = nodes.some( + (node) => + Math.abs(node.position.x - candidate.x) < OCCUPIED_THRESHOLD && + Math.abs(node.position.y - candidate.y) < OCCUPIED_THRESHOLD, + ); + if (!occupied) break; + candidate = { + x: candidate.x + CASCADE_OFFSET, + y: candidate.y + CASCADE_OFFSET, + }; + } + + return candidate; +} + +export interface CreateNodeOptions { + type: CreatableNodeType; + nodes: Node[]; + t: Translations; + /** Flow position to place the node at. Defaults to the canvas center. */ + position?: XYPosition | null; + screenToFlowPosition: ScreenToFlowPosition; +} + +/** + * Single source of truth for new nodes, shared by the toolbar, the keyboard + * shortcuts and the welcome screen so they cannot drift apart. + */ +export function createNode({ + type, + nodes, + t, + position, + screenToFlowPosition, +}: CreateNodeOptions): Node { + const basePosition = position ?? getCanvasCenterPosition(screenToFlowPosition); + const size = INITIAL_SIZE[type]; + + // Only task and topic nodes render a label; image and text nodes carry their + // own content fields and would otherwise store an unused placeholder string. + const data: NodeData = + type === "task" + ? { label: t.newTask, state: "unlocked" } + : type === "topic" + ? { label: t.newTopic, state: "unlocked" } + : ({ state: "unlocked" } as NodeData); + + return { + id: `node-${Date.now()}-${Math.floor(Math.random() * 1000)}`, + type, + position: findFreePosition(nodes, basePosition), + zIndex: getZIndexForNewNode(nodes, type), + draggable: true, + ...(size ?? {}), + data, + }; +} diff --git a/packages/learningmap/src/nodes/ImageNode.tsx b/packages/learningmap/src/nodes/ImageNode.tsx index 681d71b..0682276 100644 --- a/packages/learningmap/src/nodes/ImageNode.tsx +++ b/packages/learningmap/src/nodes/ImageNode.tsx @@ -1,5 +1,7 @@ import { Node, NodeResizer } from "@xyflow/react"; +import { ImageIcon } from "lucide-react"; import { ImageNodeData } from "../types"; +import { useEditorStore } from "../editorStore"; // Normalize and validate URL to prevent XSS attacks function normalizeAndValidateUrl(url: string): string | null { @@ -84,7 +86,10 @@ function parseMarkdownLinks(text: string): React.ReactNode[] { return parts.length > 0 ? parts : [text]; } -export const ImageNode = ({ data, selected }: Node) => { +export const ImageNode = ({ data, selected, isConnectable }: Node) => { + const t = useEditorStore((state) => state.getTranslations)(); + const editable = Boolean(isConnectable) && !data.locked; + return ( <> {data.data ? ( @@ -107,7 +112,13 @@ export const ImageNode = ({ data, selected }: Node) => {
) : ( - No Image + <> + +
+ + {editable ? t.clickToChooseImage : t.noImage} +
+ )} ); diff --git a/packages/learningmap/src/nodes/TextNode.tsx b/packages/learningmap/src/nodes/TextNode.tsx index 0eb899c..d12c040 100644 --- a/packages/learningmap/src/nodes/TextNode.tsx +++ b/packages/learningmap/src/nodes/TextNode.tsx @@ -1,19 +1,132 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Node } from "@xyflow/react"; import { TextNodeData } from "../types"; +import { useEditorStore } from "../editorStore"; + +/** + * Axis aligned bounding box of a `width` x `height` box rotated by `degrees`. + * + * The node wrapper is sized to this box so React Flow's hit area matches what + * is actually drawn. A CSS `transform` does not affect layout, so without this + * a rotated text node is clickable where it is not visible and vice versa. + */ +function getRotatedBounds(width: number, height: number, degrees: number) { + const radians = (degrees * Math.PI) / 180; + const cos = Math.abs(Math.cos(radians)); + const sin = Math.abs(Math.sin(radians)); + return { + width: width * cos + height * sin, + height: width * sin + height * cos, + }; +} + +export const TextNode = ({ id, data, isConnectable }: Node) => { + const updateNodeData = useEditorStore((state) => state.updateNodeData); + const t = useEditorStore((state) => state.getTranslations)(); + + const contentRef = useRef(null); + const inputRef = useRef(null); + const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(data.text || ""); + + const rotation = data.rotation || 0; + const hasText = Boolean(data.text); + // Falls back to a colour the canvas sets from its background, so a new text + // node is always readable instead of near-white on white. + const color = data.color || "var(--learningmap-text-default, #111827)"; + const editable = Boolean(isConnectable) && !data.locked; + + // Track the unrotated size of the content to derive the wrapper size. + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + + const measure = () => { + const { offsetWidth, offsetHeight } = element; + setContentSize((current) => + current.width === offsetWidth && current.height === offsetHeight + ? current + : { width: offsetWidth, height: offsetHeight }, + ); + }; + + measure(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [data.text, data.fontSize, editing, hasText]); + + useEffect(() => { + setDraft(data.text || ""); + }, [data.text]); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [editing]); + + const commit = () => { + setEditing(false); + if (draft !== (data.text || "")) { + updateNodeData(id, { text: draft }); + } + }; + + const bounds = getRotatedBounds( + contentSize.width, + contentSize.height, + rotation, + ); -export const TextNode = ({ data }: Node) => { return ( - <> +
setEditing(true) : undefined} + >
- {data.text || "No Text"} + {editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + commit(); + } else if (e.key === "Escape") { + setDraft(data.text || ""); + setEditing(false); + } + }} + /> + ) : hasText ? ( + data.text + ) : ( + + {editable ? t.doubleClickToAddText : t.noText} + + )}
- +
); }; diff --git a/packages/learningmap/src/translations.ts b/packages/learningmap/src/translations.ts index 1b722ab..bc7dece 100644 --- a/packages/learningmap/src/translations.ts +++ b/packages/learningmap/src/translations.ts @@ -55,6 +55,8 @@ export interface Translations { cut: string; copy: string; paste: string; + cycleStackedNodes: string; + editTextInline: string; }; // Drawer titles @@ -219,6 +221,22 @@ export interface Translations { // Image caption caption: string; placeholderImageCaption: string; + + // Layers + layers: string; + layersEmpty: string; + bringToFront: string; + bringForward: string; + sendBackward: string; + sendToBack: string; + lockNode: string; + unlockNode: string; + zoomToNode: string; + + // Node placeholders + noImage: string; + clickToChooseImage: string; + doubleClickToAddText: string; } const en: Translations = { @@ -276,6 +294,8 @@ const en: Translations = { cut: "Cut", copy: "Copy", paste: "Paste", + cycleStackedNodes: "Cycle through overlapping nodes", + editTextInline: "Edit a text node in place", }, // Drawer titles @@ -446,6 +466,22 @@ const en: Translations = { // Image caption caption: "Caption", placeholderImageCaption: "Add caption (supports [markdown links](url))", + + // Layers + layers: "Layers", + layersEmpty: "No nodes yet", + bringToFront: "Bring to Front", + bringForward: "Bring Forward", + sendBackward: "Send Backward", + sendToBack: "Send to Back", + lockNode: "Lock", + unlockNode: "Unlock", + zoomToNode: "Zoom to Node", + + // Node placeholders + noImage: "No Image", + clickToChooseImage: "Choose an image", + doubleClickToAddText: "Double-click to add text", }; const de: Translations = { @@ -504,6 +540,8 @@ const de: Translations = { cut: "Ausschneiden", copy: "Kopieren", paste: "Einfügen", + cycleStackedNodes: "Überlappende Knoten durchschalten", + editTextInline: "Textknoten direkt bearbeiten", }, // Drawer titles @@ -676,6 +714,22 @@ const de: Translations = { // Image caption caption: "Bildunterschrift", placeholderImageCaption: "Bildunterschrift hinzufügen (unterstützt [Markdown-Links](url))", + + // Layers + layers: "Ebenen", + layersEmpty: "Noch keine Knoten", + bringToFront: "In den Vordergrund", + bringForward: "Eine Ebene nach vorne", + sendBackward: "Eine Ebene nach hinten", + sendToBack: "In den Hintergrund", + lockNode: "Sperren", + unlockNode: "Entsperren", + zoomToNode: "Zum Knoten zoomen", + + // Node placeholders + noImage: "Kein Bild", + clickToChooseImage: "Bild auswählen", + doubleClickToAddText: "Doppelklicken, um Text hinzuzufügen", }; export const translations: Record = { diff --git a/packages/learningmap/src/types.ts b/packages/learningmap/src/types.ts index 54010ab..473e477 100644 --- a/packages/learningmap/src/types.ts +++ b/packages/learningmap/src/types.ts @@ -26,6 +26,8 @@ export interface Resource { export interface NodeData { state: "locked" | "unlocked" | "started" | "completed" | "mastered"; label: string; + /** Prevents dragging and selecting the node on the canvas. */ + locked?: boolean; description?: string; duration?: string; unlock?: UnlockCondition; @@ -40,6 +42,7 @@ export interface NodeData { export interface ImageNodeData { data?: string; // base64 encoded image caption?: string; // Caption with markdown support for links + locked?: boolean; // Prevents dragging and selecting on the canvas } export interface TextNodeData { @@ -47,6 +50,7 @@ export interface TextNodeData { fontSize?: number; color?: string; rotation?: number; + locked?: boolean; // Prevents dragging and selecting on the canvas } export type BackgroundNodeData = ImageNodeData | TextNodeData; diff --git a/packages/learningmap/src/zIndexHelper.test.ts b/packages/learningmap/src/zIndexHelper.test.ts new file mode 100644 index 0000000..237575d --- /dev/null +++ b/packages/learningmap/src/zIndexHelper.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { Node } from "@xyflow/react"; +import { + getZIndexForNodeType, + getEffectiveZIndex, + getZIndexForNewNode, + reorderNodes, + getNodesAtPosition, +} from "./zIndexHelper"; +import { NodeData } from "./types"; + +const makeNode = ( + id: string, + type: string, + overrides: Partial> = {}, +): Node => + ({ + id, + type, + position: { x: 0, y: 0 }, + data: { label: id, state: "unlocked" }, + ...overrides, + }) as Node; + +const zIndexOf = (nodes: Node[], id: string) => + nodes.find((n) => n.id === id)!.zIndex; + +/** Node ids from bottom to top. */ +const stackOrder = (nodes: Node[]) => + [...nodes] + .sort((a, b) => getEffectiveZIndex(a) - getEffectiveZIndex(b)) + .map((n) => n.id); + +describe("getEffectiveZIndex", () => { + it("falls back to the type default when no zIndex is set", () => { + expect(getEffectiveZIndex(makeNode("a", "image"))).toBe( + getZIndexForNodeType("image"), + ); + }); + + it("prefers an explicit zIndex", () => { + expect(getEffectiveZIndex(makeNode("a", "image", { zIndex: 99 }))).toBe(99); + }); +}); + +describe("getZIndexForNewNode", () => { + it("uses the type default on an empty map", () => { + expect(getZIndexForNewNode([], "text")).toBe(getZIndexForNodeType("text")); + }); + + it("puts images behind everything", () => { + const nodes = [ + makeNode("a", "task", { zIndex: 5 }), + makeNode("b", "image", { zIndex: 2 }), + ]; + expect(getZIndexForNewNode(nodes, "image")).toBe(1); + }); + + it("puts text behind the topic and task nodes", () => { + const nodes = [ + makeNode("a", "task", { zIndex: 7 }), + makeNode("b", "topic", { zIndex: 9 }), + makeNode("c", "image", { zIndex: 1 }), + ]; + expect(getZIndexForNewNode(nodes, "text")).toBe(6); + }); + + it("puts text on top when there is no content node", () => { + const nodes = [makeNode("a", "image", { zIndex: 4 })]; + expect(getZIndexForNewNode(nodes, "text")).toBe(5); + }); + + it("puts topic and task nodes in front of everything", () => { + const nodes = [ + makeNode("a", "image", { zIndex: 1 }), + makeNode("b", "task", { zIndex: 12 }), + ]; + expect(getZIndexForNewNode(nodes, "topic")).toBe(13); + }); +}); + +describe("reorderNodes", () => { + const nodes = [ + makeNode("bottom", "image", { zIndex: 1 }), + makeNode("middle", "text", { zIndex: 2 }), + makeNode("top", "task", { zIndex: 3 }), + ]; + + it("brings a node to the front", () => { + expect(stackOrder(reorderNodes(nodes, ["bottom"], "front"))).toEqual([ + "middle", + "top", + "bottom", + ]); + }); + + it("sends a node to the back", () => { + expect(stackOrder(reorderNodes(nodes, ["top"], "back"))).toEqual([ + "top", + "bottom", + "middle", + ]); + }); + + it("moves a node one step forward", () => { + expect(stackOrder(reorderNodes(nodes, ["bottom"], "forward"))).toEqual([ + "middle", + "bottom", + "top", + ]); + }); + + it("moves a node one step backward", () => { + expect(stackOrder(reorderNodes(nodes, ["top"], "backward"))).toEqual([ + "bottom", + "top", + "middle", + ]); + }); + + it("keeps the top node in place when moving it forward", () => { + expect(stackOrder(reorderNodes(nodes, ["top"], "forward"))).toEqual([ + "bottom", + "middle", + "top", + ]); + }); + + it("keeps a multi selection together", () => { + const result = reorderNodes(nodes, ["bottom", "middle"], "front"); + expect(stackOrder(result)).toEqual(["top", "bottom", "middle"]); + }); + + it("assigns an explicit zIndex to every node", () => { + const result = reorderNodes( + [makeNode("a", "task"), makeNode("b", "task"), makeNode("c", "task")], + ["c"], + "back", + ); + expect(result.every((n) => n.zIndex !== undefined)).toBe(true); + expect(zIndexOf(result, "c")).toBe(1); + }); + + it("breaks ties on the type defaults using the array order", () => { + // Both nodes default to the same zIndex, so the second one renders on top. + const tied = [makeNode("first", "task"), makeNode("second", "task")]; + expect(stackOrder(reorderNodes(tied, ["first"], "forward"))).toEqual([ + "second", + "first", + ]); + }); + + it("preserves the array order so nodes are not remounted", () => { + const result = reorderNodes(nodes, ["bottom"], "front"); + expect(result.map((n) => n.id)).toEqual(["bottom", "middle", "top"]); + }); + + it("returns the nodes untouched without a selection", () => { + expect(reorderNodes(nodes, [], "front")).toBe(nodes); + }); +}); + +describe("getNodesAtPosition", () => { + const sized = ( + id: string, + type: string, + x: number, + y: number, + zIndex: number, + ) => + makeNode(id, type, { + position: { x, y }, + zIndex, + measured: { width: 100, height: 100 }, + } as Partial>); + + it("returns overlapping nodes top-most first", () => { + const nodes = [ + sized("background", "image", 0, 0, 1), + sized("foreground", "task", 20, 20, 5), + ]; + expect(getNodesAtPosition(nodes, { x: 50, y: 50 }).map((n) => n.id)).toEqual( + ["foreground", "background"], + ); + }); + + it("ignores nodes the point is outside of", () => { + const nodes = [sized("a", "task", 0, 0, 1)]; + expect(getNodesAtPosition(nodes, { x: 500, y: 500 })).toEqual([]); + }); + + it("ignores nodes that have not been measured", () => { + expect(getNodesAtPosition([makeNode("a", "task")], { x: 0, y: 0 })).toEqual( + [], + ); + }); +}); diff --git a/packages/learningmap/src/zIndexHelper.ts b/packages/learningmap/src/zIndexHelper.ts index 903c5f2..c18bd84 100644 --- a/packages/learningmap/src/zIndexHelper.ts +++ b/packages/learningmap/src/zIndexHelper.ts @@ -1,5 +1,12 @@ +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; + // zIndex constants for different node types // Ordering: Bottom -> Image (10) -> Text (20) -> Topic/Task (30) -> Top +// +// These values are only the *initial* stacking order for a freshly created +// node. As soon as the user reorders layers explicitly, every node gets its +// own zIndex and the type based defaults no longer apply. export function getZIndexForNodeType(type?: string): number { switch (type) { @@ -14,3 +21,143 @@ export function getZIndexForNodeType(type?: string): number { return 30; // Default to task/topic level } } + +/** The stacking value a node is actually rendered with. */ +export function getEffectiveZIndex(node: Node): number { + return node.zIndex !== undefined + ? node.zIndex + : getZIndexForNodeType(node.type); +} + +/** + * Stacking value for a node that is about to be added to `nodes`. + * + * New nodes are placed where their type is expected to live relative to the + * nodes that already exist, instead of at a fixed value that may collide with + * an explicit order the user has set up: + * - images go behind everything + * - text goes behind the topic/task nodes, but in front of the images + * - topic/task nodes go in front of everything + */ +export function getZIndexForNewNode( + nodes: Node[], + type?: string, +): number { + if (nodes.length === 0) { + return getZIndexForNodeType(type); + } + + const zIndexes = nodes.map(getEffectiveZIndex); + + if (type === "image") { + return Math.min(...zIndexes) - 1; + } + + if (type === "text") { + const contentZIndexes = nodes + .filter((n) => n.type === "topic" || n.type === "task") + .map(getEffectiveZIndex); + return contentZIndexes.length > 0 + ? Math.min(...contentZIndexes) - 1 + : Math.max(...zIndexes) + 1; + } + + return Math.max(...zIndexes) + 1; +} + +export type LayerOperation = "front" | "back" | "forward" | "backward"; + +/** + * Reorders `nodeIds` within the stacking order of `nodes` and returns every + * node with a normalized, explicit zIndex. + * + * Normalizing the whole stack keeps the order unambiguous: without it, nodes + * sharing a type default all have the same zIndex and cannot be told apart. + */ +export function reorderNodes( + nodes: Node[], + nodeIds: string[], + operation: LayerOperation, +): Node[] { + const selected = new Set(nodeIds); + if (selected.size === 0 || nodes.length === 0) { + return nodes; + } + + // Sort bottom -> top, using the array order to break ties so the result is + // stable and matches what is rendered. + const stack = nodes + .map((node, index) => ({ node, index })) + .sort( + (a, b) => + getEffectiveZIndex(a.node) - getEffectiveZIndex(b.node) || + a.index - b.index, + ) + .map(({ node }) => node); + + let reordered: Node[]; + + switch (operation) { + case "front": + reordered = [ + ...stack.filter((n) => !selected.has(n.id)), + ...stack.filter((n) => selected.has(n.id)), + ]; + break; + case "back": + reordered = [ + ...stack.filter((n) => selected.has(n.id)), + ...stack.filter((n) => !selected.has(n.id)), + ]; + break; + case "forward": + reordered = [...stack]; + // Walk downwards so a node is never moved twice in one pass. + for (let i = reordered.length - 2; i >= 0; i--) { + if (selected.has(reordered[i].id) && !selected.has(reordered[i + 1].id)) { + [reordered[i], reordered[i + 1]] = [reordered[i + 1], reordered[i]]; + } + } + break; + case "backward": + reordered = [...stack]; + for (let i = 1; i < reordered.length; i++) { + if (selected.has(reordered[i].id) && !selected.has(reordered[i - 1].id)) { + [reordered[i], reordered[i - 1]] = [reordered[i - 1], reordered[i]]; + } + } + break; + } + + const zIndexById = new Map( + reordered.map((node, index) => [node.id, index + 1]), + ); + + // Keep the original array order so React does not remount every node. + return nodes.map((node) => { + const zIndex = zIndexById.get(node.id); + return zIndex !== undefined && zIndex !== node.zIndex + ? { ...node, zIndex } + : node; + }); +} + +/** Nodes under `point` (flow coordinates), sorted top-most first. */ +export function getNodesAtPosition( + nodes: Node[], + point: { x: number; y: number }, +): Node[] { + return nodes + .filter((node) => { + const width = node.measured?.width ?? node.width ?? 0; + const height = node.measured?.height ?? node.height ?? 0; + if (width === 0 || height === 0) return false; + return ( + point.x >= node.position.x && + point.x <= node.position.x + width && + point.y >= node.position.y && + point.y <= node.position.y + height + ); + }) + .sort((a, b) => getEffectiveZIndex(b) - getEffectiveZIndex(a)); +}