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)); +}