diff --git a/packages/learningmap/package.json b/packages/learningmap/package.json index 517d57d..96e58ef 100644 --- a/packages/learningmap/package.json +++ b/packages/learningmap/package.json @@ -36,15 +36,20 @@ "@szhsin/react-menu": "^4.5.0", "@xyflow/react": "^12.8.6", "elkjs": "^0.11.0", + "fast-deep-equal": "^3.1.3", "html-to-image": "1.11.13", "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tslib": "^2.8.1" + "throttle-debounce": "^5.0.2", + "tslib": "^2.8.1", + "zundo": "^2.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", + "@types/throttle-debounce": "^5.0.2", "vitest": "^3.0.5" } } diff --git a/packages/learningmap/src/EdgeDrawer.tsx b/packages/learningmap/src/EdgeDrawer.tsx index 00a5a75..e514a56 100644 --- a/packages/learningmap/src/EdgeDrawer.tsx +++ b/packages/learningmap/src/EdgeDrawer.tsx @@ -3,26 +3,45 @@ import { X, Trash2, Save } from "lucide-react"; import { Edge } from "@xyflow/react"; import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; interface EdgeDrawerProps { - edge: Edge | null; - isOpen: boolean; - onClose: () => void; - onUpdate: (edge: Edge) => void; - onDelete: () => void; - language?: string; + defaultLanguage?: string; } export const EdgeDrawer: React.FC = ({ - edge: selectedEdge, - isOpen: edgeDrawerOpen, - onClose: closeDrawer, - onUpdate: updateEdge, - onDelete: deleteEdge, - language = "en", + defaultLanguage = "en", }) => { + // Get edge and drawer state from store + const selectedEdge = useEditorStore(state => state.selectedEdge); + const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen); + const settings = useEditorStore(state => state.settings); + + // Get actions from store + const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen); + const setSelectedEdge = useEditorStore(state => state.setSelectedEdge); + const updateEdge = useEditorStore(state => state.updateEdge); + const deleteEdge = useEditorStore(state => state.deleteEdge); + + const language = settings?.language || defaultLanguage; const t = getTranslations(language); + const closeDrawer = () => { + setEdgeDrawerOpen(false); + setSelectedEdge(null); + }; + + const onUpdate = (edge: Edge) => { + updateEdge(edge.id, edge); + }; + + const onDelete = () => { + if (selectedEdge && confirm(t.resetMapWarning)) { + deleteEdge(selectedEdge.id); + closeDrawer(); + } + }; + if (!selectedEdge || !edgeDrawerOpen) return null; return (
@@ -48,12 +67,12 @@ export const EdgeDrawer: React.FC = ({ } else if (field === "type") { updated = { ...updated, type: value }; } - updateEdge(updated); + onUpdate(updated); }} language={language} />
- + +
+ + + + + + + + + {keyboardShortcuts.map((item) => ( + + + + + ))} + +
{t.action}{t.shortcut}
{item.action}{item.shortcut}
+
+
+ +
+ + + { + setLoadExternalDialogOpen(false); + setPendingExternalId(null); + }} + onDownloadCurrent={onDownload} + onReplace={() => { + if (pendingExternalId) { + onLoadFromStore(pendingExternalId); + } + }} + /> + + ); +}); + +EditorDialogs.displayName = 'EditorDialogs'; diff --git a/packages/learningmap/src/EditorDrawer.tsx b/packages/learningmap/src/EditorDrawer.tsx index ada1062..4c380f6 100644 --- a/packages/learningmap/src/EditorDrawer.tsx +++ b/packages/learningmap/src/EditorDrawer.tsx @@ -1,43 +1,64 @@ import React, { useState, useEffect } from "react"; import { X, Trash2, Save } from "lucide-react"; -import { Node, useReactFlow } from "@xyflow/react"; +import { Node } from "@xyflow/react"; import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; import { EditorDrawerTopicContent } from "./EditorDrawerTopicContent"; import { EditorDrawerImageContent } from "./EditorDrawerImageContent"; import { EditorDrawerTextContent } from "./EditorDrawerTextContent"; import { Completion, NodeData } from "./types"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; interface EditorDrawerProps { - node: Node | null; - isOpen: boolean; - onClose: () => void; - onUpdate: (node: Node) => void; - onDelete: () => void; - language?: string; + defaultLanguage?: string; } export const EditorDrawer: React.FC = ({ - node, - isOpen, - onClose, - onUpdate, - onDelete, - language = "en", + defaultLanguage = "en", }) => { + // Get node and all nodes from store + const selectedNodeId = useEditorStore(state => state.selectedNodeId); + const nodes = useEditorStore(state => state.nodes); + const isOpen = useEditorStore(state => state.drawerOpen); + const settings = useEditorStore(state => state.settings); + + // Get actions from store + const setDrawerOpen = useEditorStore(state => state.setDrawerOpen); + const setSelectedNodeId = useEditorStore(state => state.setSelectedNodeId); + const updateNode = useEditorStore(state => state.updateNode); + const deleteNode = useEditorStore(state => state.deleteNode); + + const language = settings?.language || defaultLanguage; const t = getTranslations(language); + + const node = nodes.find(n => n.id === selectedNodeId) || null; + const [localNode, setLocalNode] = useState | null>(node); - const { getNodes } = useReactFlow(); - const allNodes = getNodes(); useEffect(() => { setLocalNode(node); }, [node]); + const onClose = () => { + setDrawerOpen(false); + setSelectedNodeId(null); + }; + + const onUpdate = (updatedNode: Node) => { + updateNode(updatedNode.id, updatedNode); + }; + + const onDelete = () => { + if (node && confirm(t.resetMapWarning)) { + deleteNode(node.id); + onClose(); + } + }; + if (!isOpen || !node || !localNode) return null; // Filter out the current node from selectable options - const nodeOptions = allNodes.filter(n => n.id !== node.id && n.type === "task" || n.type === "topic"); + const nodeOptions = nodes.filter(n => n.id !== node.id && (n.type === "task" || n.type === "topic")); // Helper for dropdowns const renderNodeSelect = (value: string, onChange: (id: string) => void) => ( diff --git a/packages/learningmap/src/EditorToolbar.tsx b/packages/learningmap/src/EditorToolbar.tsx index 4268581..82c7a18 100644 --- a/packages/learningmap/src/EditorToolbar.tsx +++ b/packages/learningmap/src/EditorToolbar.tsx @@ -4,48 +4,103 @@ import "@szhsin/react-menu/dist/index.css"; import '@szhsin/react-menu/dist/transitions/zoom.css'; import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2, RotateCcw } from "lucide-react"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; interface EditorToolbarProps { - debugMode: boolean; - previewMode: boolean; - showCompletionNeeds: boolean; - showCompletionOptional: boolean; - showUnlockAfter: boolean; - onToggleDebugMode: () => void; - onTogglePreviewMode: () => void; - onSetShowCompletionNeeds: (checked: boolean) => void; - onSetShowCompletionOptional: (checked: boolean) => void; - onSetShowUnlockAfter: (checked: boolean) => void; - onAddNewNode: (type: "task" | "topic" | "image" | "text") => void; - onOpenSettingsDrawer: () => void; - onDownlad: () => void; - onOpen: () => void; - onShare: () => void; - onReset: () => void; - language?: string; + defaultLanguage?: string; } export const EditorToolbar: React.FC = ({ - debugMode, - previewMode, - showCompletionNeeds, - showCompletionOptional, - showUnlockAfter, - onTogglePreviewMode, - onToggleDebugMode, - onSetShowCompletionNeeds, - onSetShowCompletionOptional, - onSetShowUnlockAfter, - onAddNewNode, - onOpenSettingsDrawer, - onDownlad, - onOpen, - onShare, - onReset, - language = "en", + defaultLanguage = "en", }) => { + // Get state directly from store + const settings = useEditorStore(state => state.settings); + const debugMode = useEditorStore(state => state.debugMode); + const previewMode = useEditorStore(state => state.previewMode); + const showCompletionNeeds = useEditorStore(state => state.showCompletionNeeds); + const showCompletionOptional = useEditorStore(state => state.showCompletionOptional); + const showUnlockAfter = useEditorStore(state => state.showUnlockAfter); + + // Get actions directly from store + const setDebugMode = useEditorStore(state => state.setDebugMode); + const setPreviewMode = useEditorStore(state => state.setPreviewMode); + const setShowCompletionNeeds = useEditorStore(state => state.setShowCompletionNeeds); + const setShowCompletionOptional = useEditorStore(state => state.setShowCompletionOptional); + const setShowUnlockAfter = useEditorStore(state => state.setShowUnlockAfter); + const addNode = useEditorStore(state => state.addNode); + const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen); + const getRoadmapData = useEditorStore(state => state.getRoadmapData); + const setShareDialogOpen = useEditorStore(state => state.setShareDialogOpen); + const reset = useEditorStore(state => state.reset); + + const language = settings?.language || defaultLanguage; const t = getTranslations(language); + const onToggleDebugMode = () => setDebugMode(!debugMode); + const onTogglePreviewMode = () => setPreviewMode(!previewMode); + const onSetShowCompletionNeeds = (checked: boolean) => setShowCompletionNeeds(checked); + const onSetShowCompletionOptional = (checked: boolean) => setShowCompletionOptional(checked); + const onSetShowUnlockAfter = (checked: boolean) => setShowUnlockAfter(checked); + + const onAddNewNode = (type: "task" | "topic" | "image" | "text") => { + const position = { x: Math.random() * 500, y: Math.random() * 500 }; + 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 onOpenSettingsDrawer = () => setSettingsDrawerOpen(true); + + const onDownload = () => { + const roadmapData = getRoadmapData(); + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", "learningmap.json"); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }; + + const onOpen = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = (e: any) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e: any) => { + try { + const data = JSON.parse(e.target.result); + useEditorStore.getState().loadRoadmapData(data); + } catch (error) { + console.error("Failed to parse JSON file", error); + } + }; + reader.readAsText(file); + } + }; + input.click(); + }; + + const onShare = () => setShareDialogOpen(true); + + const onReset = () => { + if (confirm(t.resetMapWarning)) { + reset(); + } + }; + return (
@@ -76,7 +131,7 @@ export const EditorToolbar: React.FC = ({ {t.open} - + {t.download} diff --git a/packages/learningmap/src/KeyboardShortcuts.tsx b/packages/learningmap/src/KeyboardShortcuts.tsx new file mode 100644 index 0000000..e797bc7 --- /dev/null +++ b/packages/learningmap/src/KeyboardShortcuts.tsx @@ -0,0 +1,233 @@ +import { useEffect } from "react"; +import { useReactFlow } from "@xyflow/react"; +import { useEditorStore, useTemporalStore } from "./editorStore"; +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; +import { getTranslations } from "./translations"; + +interface KeyboardShortcutsProps { + jsonStore?: string; +} + +export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }: KeyboardShortcutsProps) => { + const { zoomIn, zoomOut, setCenter, fitView, screenToFlowPosition } = useReactFlow(); + + // Get store state + const helpOpen = useEditorStore(state => state.helpOpen); + const selectedNodeIds = useEditorStore(state => state.selectedNodeIds); + const nodes = useEditorStore(state => state.nodes); + const lastMousePosition = useEditorStore(state => state.lastMousePosition); + const settings = useEditorStore(state => state.settings); + + // Get store actions + const setHelpOpen = useEditorStore(state => state.setHelpOpen); + const addNode = useEditorStore(state => state.addNode); + const getRoadmapData = useEditorStore(state => state.getRoadmapData); + const setPreviewMode = useEditorStore(state => state.setPreviewMode); + const setDebugMode = useEditorStore(state => state.setDebugMode); + const setClipboard = useEditorStore(state => state.setClipboard); + const clipboard = useEditorStore(state => state.clipboard); + const setNodes = useEditorStore(state => state.setNodes); + const setEdges = useEditorStore(state => state.setEdges); + const setSelectedNodeIds = useEditorStore(state => state.setSelectedNodeIds); + const showGrid = useEditorStore(state => state.showGrid); + const setShowGrid = useEditorStore(state => state.setShowGrid); + const deleteNode = useEditorStore(state => state.deleteNode); + + const language = settings?.language || "en"; + const t = getTranslations(language); + + // Temporal store for undo/redo + const { undo, redo } = useTemporalStore((state) => ({ + undo: state.undo, + 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 onDeleteSelected = () => { + if (selectedNodeIds.length > 0) { + // Delete all selected nodes + selectedNodeIds.forEach(nodeId => { + deleteNode(nodeId); + }); + setSelectedNodeIds([]); + } + }; + + const onSave = () => { + const roadmapData = getRoadmapData(); + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", "learningmap.json"); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }; + + const onTogglePreview = () => { + const currentPreviewMode = useEditorStore.getState().previewMode; + setPreviewMode(!currentPreviewMode); + }; + + const onToggleDebug = () => { + const currentDebugMode = useEditorStore.getState().debugMode; + setDebugMode(!currentDebugMode); + }; + + const onResetMap = () => { + if (confirm(t.resetMapWarning)) { + setNodes([]); + setEdges([]); + } + }; + + const onCut = () => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + setClipboard({ nodes: selectedNodes, edges: [] }); + // Delete all selected nodes + selectedNodeIds.forEach(nodeId => { + deleteNode(nodeId); + }); + setSelectedNodeIds([]); + } + }; + + const onCopy = () => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + setClipboard({ nodes: selectedNodes, edges: [] }); + } + }; + + const onPaste = () => { + if (clipboard && clipboard.nodes.length > 0) { + const newNodes = clipboard.nodes.map(n => ({ + ...n, + id: `node-${Date.now()}-${Math.random()}`, + position: { x: n.position.x + 50, y: n.position.y + 50 }, + })); + setNodes([...nodes, ...newNodes]); + setSelectedNodeIds(newNodes.map(n => n.id)); + } + }; + + const onSelectAll = () => { + setSelectedNodeIds(nodes.map(n => n.id)); + }; + + const onZoomIn = () => zoomIn(); + const onZoomOut = () => zoomOut(); + const onResetZoom = () => setCenter(0, 0, { zoom: 1 }); + const onFitView = () => fitView(); + + const onZoomToSelection = () => { + if (selectedNodeIds.length > 0) { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + fitView({ nodes: selectedNodes, duration: 300 }); + } + } + }; + + const onToggleGrid = () => setShowGrid(!showGrid); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === '1') { + e.preventDefault(); + onAddNode("task"); + } else if (e.key === '2') { + e.preventDefault(); + onAddNode("topic"); + } else if (e.key === '3') { + e.preventDefault(); + onAddNode("image"); + } else if (e.key === '4') { + e.preventDefault(); + onAddNode("text"); + } else if (e.key === 's') { + e.preventDefault(); + onSave(); + } else if (e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undo(); + } else if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { + e.preventDefault(); + redo(); + } else if ((e.key === '?' || (e.shiftKey && e.key === '/'))) { + e.preventDefault(); + setHelpOpen(!helpOpen); + } else if (e.key.toLowerCase() === 'p' && !e.shiftKey) { + e.preventDefault(); + onTogglePreview(); + } else if (e.key.toLowerCase() === 'd' && !e.shiftKey) { + e.preventDefault(); + onToggleDebug(); + } else if (e.key === '+' || e.key === '=') { + e.preventDefault(); + onZoomIn(); + } else if (e.key === '-') { + e.preventDefault(); + onZoomOut(); + } else if (e.key === '0') { + e.preventDefault(); + onResetZoom(); + } else if (e.key === "'") { + e.preventDefault(); + onToggleGrid(); + } else if (e.key === 'Delete') { + e.preventDefault(); + onResetMap(); + } else if (e.key.toLowerCase() === 'x') { + e.preventDefault(); + onCut(); + } else if (e.key.toLowerCase() === 'c') { + e.preventDefault(); + onCopy(); + } else if (e.key.toLowerCase() === 'v') { + e.preventDefault(); + onPaste(); + } else if (e.key.toLowerCase() === 'a') { + e.preventDefault(); + onSelectAll(); + } + } else if (e.shiftKey) { + if (e.key === '!') { + e.preventDefault(); + onFitView(); + } else if (e.key === '@') { + e.preventDefault(); + onZoomToSelection(); + } + } else if (e.key === 'Delete') { + e.preventDefault(); + onDeleteSelected(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onAddNode, onDeleteSelected, onSave, undo, redo, helpOpen, setHelpOpen, onTogglePreview, onToggleDebug, + onZoomIn, onZoomOut, onResetZoom, onFitView, onZoomToSelection, onToggleGrid, + onResetMap, onCut, onCopy, onPaste, onSelectAll]); + + return null; +}; diff --git a/packages/learningmap/src/LearningMap.tsx b/packages/learningmap/src/LearningMap.tsx index 11a15ac..098d049 100644 --- a/packages/learningmap/src/LearningMap.tsx +++ b/packages/learningmap/src/LearningMap.tsx @@ -1,14 +1,15 @@ -import { Controls, Edge, Node, Panel, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react"; +import { Controls, Edge, Node, Panel, ReactFlow, ReactFlowProvider, useReactFlow } from "@xyflow/react"; import { ImageNode } from "./nodes/ImageNode"; import { TaskNode } from "./nodes/TaskNode"; import { TextNode } from "./nodes/TextNode"; import { TopicNode } from "./nodes/TopicNode"; import { NodeData, RoadmapData, RoadmapState, Settings } from "./types"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { parseRoadmapData } from "./helper"; import { Drawer } from "./Drawer"; import { ProgressTracker } from "./ProgressTracker"; import { getTranslations } from "./translations"; +import { useViewerStore } from "./viewerStore"; const nodeTypes = { topic: TopicNode, @@ -17,73 +18,6 @@ const nodeTypes = { text: TextNode, }; -const getStateMap = (nodes: Node[]) => { - const stateMap: Record = {}; - nodes.forEach(n => { - if (n.data?.state) { - stateMap[n.id] = n.data.state; - } - }); - return stateMap; -} - -const isCompleteState = (state: string) => state === 'completed' || state === 'mastered'; - -const updateNodesStates = (nodes: Node[]) => { - for (let i = 0; i < 2; i++) { - const stateMap = getStateMap(nodes); - for (const node of nodes) { - node.data.state = node.data?.state || 'locked'; - // check unlock conditions - if (node.data?.unlock?.after) { - const unlocked = node.data.unlock.after.every((depId: string) => isCompleteState(stateMap[depId])); - if (unlocked) { - if (node.data.state === "locked") { - node.data.state = 'unlocked'; - } - } else { - node.data.state = 'locked'; - } - } - if (node.data?.unlock?.date) { - const unlockDate = new Date(node.data.unlock.date); - const now = new Date(); - if (now >= unlockDate) { - if (node.data.state === "locked") { - node.data.state = 'unlocked'; - } - } else { - node.data.state = 'locked'; - } - } - if (!node.data?.unlock?.after && !node.data?.unlock?.date) { - if (node.data.state === "locked") { - node.data.state = 'unlocked'; - } - } - if (node.type != "topic") continue; - if (node.data?.completion?.needs) { - const noNeeds = node.data.completion.needs.every((need: string) => isCompleteState(stateMap[need])); - if (node.data.state === "unlocked" && noNeeds) { - node.data.state = 'completed'; - } - } else if (!node.data?.completion?.needs && node.data.state === "unlocked") { - node.data.state = 'completed'; - } - if (node.data?.completion?.optional) { - const noOptional = node.data.completion.optional.every((opt: string) => isCompleteState(stateMap[opt])); - if (node.data.state === "completed" && noOptional) { - node.data.state = 'mastered'; - } - } else if (!node.data?.completion?.optional && node.data.state === "completed") { - node.data.state = 'mastered'; - } - } - } - - return nodes; -}; - const isInteractableNode = (node: Node) => { return node.type === "task" || node.type === "topic"; } @@ -120,11 +54,20 @@ export function LearningMap({ language = "en", initialState }: LearningMapProps) { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [selectedNode, setSelectedNode] = useState | null>(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [settings, setSettings] = useState(); + // Use Zustand store + const nodes = useViewerStore(state => state.nodes); + const edges = useViewerStore(state => state.edges); + const settings = useViewerStore(state => state.settings); + const selectedNode = useViewerStore(state => state.selectedNode); + const drawerOpen = useViewerStore(state => state.drawerOpen); + const onNodesChange = useViewerStore(state => state.onNodesChange); + const setSelectedNode = useViewerStore(state => state.setSelectedNode); + const setDrawerOpen = useViewerStore(state => state.setDrawerOpen); + const loadRoadmapData = useViewerStore(state => state.loadRoadmapData); + const getRoadmapState = useViewerStore(state => state.getRoadmapState); + const updateNodesStates = useViewerStore(state => state.updateNodesStates); + const updateNodeState = useViewerStore(state => state.updateNodeState); + const { fitView, getViewport, setViewport } = useReactFlow(); // Use language from settings if available, otherwise use prop @@ -136,37 +79,12 @@ export function LearningMap({ const parsedRoadmap = parseRoadmapData(roadmapData); useEffect(() => { - async function loadRoadmap() { - const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; - const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; - - setSettings(parsedRoadmap?.settings || {}); - - let rawNodes = nodesArr.map((n) => { - return { - ...n, - draggable: false, - connectable: false, - selectable: isInteractableNode(n), - focusable: isInteractableNode(n), - data: { - ...n.data, - state: initialState?.nodes?.[n.id]?.state, - } - } - }); - - rawNodes = updateNodesStates(rawNodes); - - setViewport({ - x: initialState?.x || 0, - y: initialState?.y || 0, - zoom: initialState?.zoom || 1, - }); - setEdges(edgesArr); - setNodes(rawNodes); - } - loadRoadmap(); + loadRoadmapData(parsedRoadmap, initialState); + setViewport({ + x: initialState?.x || 0, + y: initialState?.y || 0, + zoom: initialState?.zoom || 1, + }); }, [roadmapData, initialState]); const onNodeClick = useCallback((_: any, node: Node, focus: boolean = false) => { @@ -177,34 +95,27 @@ export function LearningMap({ if (focus) { fitView({ nodes: [node], duration: 150 }); } - }, [fitView]); + }, [fitView, setSelectedNode, setDrawerOpen]); const closeDrawer = useCallback(() => { setDrawerOpen(false); setSelectedNode(null); - }, []); + }, [setDrawerOpen, setSelectedNode]); const updateNode = useCallback( (updatedNode: Node) => { - setNodes((nds) => { - let newNodes = nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) - newNodes = updateNodesStates(newNodes); - return newNodes; + if (updatedNode.data.state) { + updateNodeState(updatedNode.id, updatedNode.data.state); } - ); setSelectedNode(updatedNode); }, - [setNodes] + [updateNodeState, setSelectedNode] ); useEffect(() => { const viewport = getViewport(); - const minimalState: RoadmapState = { nodes: {}, x: viewport.x, y: viewport.y, zoom: viewport.zoom }; - nodes.forEach((n) => { - if (n.data.state && n.type === "task") { - minimalState.nodes[n.id] = { state: n.data.state }; - } - }); + const minimalState = getRoadmapState(viewport); + if (onChange) { onChange(minimalState); } else { @@ -213,7 +124,7 @@ export function LearningMap({ root.dispatchEvent(new CustomEvent("change", { detail: minimalState })); } } - }, [nodes]); + }, [nodes, onChange, getViewport, getRoadmapState]); const defaultEdgeOptions = { animated: false, @@ -245,7 +156,6 @@ export function LearningMap({ }; })} edges={edges} - onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onNodesChange={onNodesChange} nodeTypes={nodeTypes} diff --git a/packages/learningmap/src/LearningMapEditor.tsx b/packages/learningmap/src/LearningMapEditor.tsx index a740508..c69d66d 100644 --- a/packages/learningmap/src/LearningMapEditor.tsx +++ b/packages/learningmap/src/LearningMapEditor.tsx @@ -1,53 +1,17 @@ -import { useState, useCallback, useEffect } from "react"; import { - ReactFlow, - Controls, - useNodesState, - useEdgesState, - ColorMode, - useReactFlow, - Node, - addEdge, - Connection, - Edge, - Background, - ControlButton, - OnNodesChange, - OnEdgesChange, - Panel, - OnSelectionChangeFunc, ReactFlowProvider, } from "@xyflow/react"; import { EditorDrawer } from "./EditorDrawer"; import { EdgeDrawer } from "./EdgeDrawer"; -import { TaskNode } from "./nodes/TaskNode"; -import { TopicNode } from "./nodes/TopicNode"; -import { ImageNode } from "./nodes/ImageNode"; -import { TextNode } from "./nodes/TextNode"; -import { RoadmapData, NodeData, ImageNodeData, TextNodeData, Settings } from "./types"; +import { RoadmapData } from "./types"; import { SettingsDrawer } from "./SettingsDrawer"; -import FloatingEdge from "./FloatingEdge"; import { EditorToolbar } from "./EditorToolbar"; -import { parseRoadmapData } from "./helper"; import { LearningMap } from "./LearningMap"; -import { Info, Redo, Undo, RotateCw, ShieldAlert, X } from "lucide-react"; -import useUndoable from "./useUndoable"; -import { MultiNodePanel } from "./MultiNodePanel"; -import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; import { WelcomeMessage } from "./WelcomeMessage"; -import { ShareDialog } from "./ShareDialog"; -import { LoadExternalDialog } from "./LoadExternalDialog"; - -const nodeTypes = { - topic: TopicNode, - task: TaskNode, - image: ImageNode, - text: TextNode, -}; - -const edgeTypes = { - floating: FloatingEdge -}; +import { EditorCanvas } from "./EditorCanvas"; +import { EditorDialogs } from "./EditorDialogs"; +import { KeyboardShortcuts } from "./KeyboardShortcuts"; export interface LearningMapEditorProps { roadmapData?: string | RoadmapData; @@ -56,936 +20,47 @@ export interface LearningMapEditorProps { jsonStore?: string; } -const getDefaultFilename = () => { - const now = new Date(); - const pad = (n: number) => n.toString().padStart(2, '0'); - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; -}; - export function LearningMapEditor({ - roadmapData, language = "en", - onChange, jsonStore = "https://json.openpatch.org", }: LearningMapEditorProps) { + // Only get minimal state needed in this component + const nodes = useEditorStore(state => state.nodes); + const edges = useEditorStore(state => state.edges); + const settings = useEditorStore(state => state.settings); + const previewMode = useEditorStore(state => state.previewMode); - const parsedRoadmap = parseRoadmapData(roadmapData || ""); - const { screenToFlowPosition, zoomIn, zoomOut, setCenter, fitView } = useReactFlow(); - const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable(parsedRoadmap); - - const [saved, setSaved] = useState(true); - const [didUndoRedo, setDidUndoRedo] = useState(false); - const [previewMode, setPreviewMode] = useState(false); - const [debugMode, setDebugMode] = useState(false); - const [nodes, setNodes, onNodesChange] = useNodesState(parsedRoadmap.nodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(parsedRoadmap.edges); - const [settings, setSettings] = useState(parsedRoadmap.settings); - const [showGrid, setShowGrid] = useState(false); - const [clipboard, setClipboard] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null); - const [lastMousePosition, setLastMousePosition] = useState<{ x: number; y: number } | null>(null); + // Store actions + const getRoadmapData = useEditorStore(state => state.getRoadmapData); // Use language from settings if available, otherwise use prop const effectiveLanguage = settings?.language || language; - const t = getTranslations(effectiveLanguage); - - const keyboardShortcuts = [ - { action: t.shortcuts.undo, shortcut: "Ctrl+Z" }, - { action: t.shortcuts.redo, shortcut: "Ctrl+Y or Ctrl+Shift+Z" }, - { action: t.shortcuts.addTaskNode, shortcut: "Ctrl+1" }, - { action: t.shortcuts.addTopicNode, shortcut: "Ctrl+2" }, - { action: t.shortcuts.addImageNode, shortcut: "Ctrl+3" }, - { action: t.shortcuts.addTextNode, shortcut: "Ctrl+4" }, - { action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" }, - { 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.selectAllNodes, shortcut: "Ctrl+A" }, - { action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" }, - { action: t.shortcuts.zoomIn, shortcut: "Ctrl++" }, - { action: t.shortcuts.zoomOut, shortcut: "Ctrl+-" }, - { action: t.shortcuts.resetZoom, shortcut: "Ctrl+0" }, - { action: t.shortcuts.fitView, shortcut: "Shift+1" }, - { action: t.shortcuts.zoomToSelection, shortcut: "Shift+2" }, - { action: t.shortcuts.toggleGrid, shortcut: "Ctrl+'" }, - { action: t.shortcuts.resetMap, shortcut: "Ctrl+Delete" }, - { action: t.shortcuts.cut, shortcut: "Ctrl+X" }, - { action: t.shortcuts.copy, shortcut: "Ctrl+C" }, - { action: t.shortcuts.paste, shortcut: "Ctrl+V" }, - ]; - - const [helpOpen, setHelpOpen] = useState(false); - const [colorMode] = useState("light"); - const [selectedNodeId, setSelectedNodeId] = useState | null>(null); - const [selectedNodeIds, setSelectedNodeIds] = useState([]); - const [drawerOpen, setDrawerOpen] = useState(false); - const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false); - const [nextNodeId, setNextNodeId] = useState(1); - - // Debug settings state - const [showCompletionNeeds, setShowCompletionNeeds] = useState(true); - const [showCompletionOptional, setShowCompletionOptional] = useState(true); - const [showUnlockAfter, setShowUnlockAfter] = useState(true); - - // Share dialog state - const [shareDialogOpen, setShareDialogOpen] = useState(false); - const [shareLink, setShareLink] = useState(""); - const [loadExternalDialogOpen, setLoadExternalDialogOpen] = useState(false); - const [pendingExternalId, setPendingExternalId] = useState(null); - - // Edge drawer state - const [selectedEdge, setSelectedEdge] = useState(null); - const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false); - - useEffect(() => { - loadRoadmapStateIntoReactFlowState(parsedRoadmap); - resetInitialState(parsedRoadmap); - }, [roadmapData]) - - const loadRoadmapStateIntoReactFlowState = useCallback((roadmapState: RoadmapData) => { - const nodesArr = Array.isArray(roadmapState?.nodes) ? roadmapState.nodes : []; - const edgesArr = Array.isArray(roadmapState?.edges) ? roadmapState.edges : []; - - setSettings(roadmapState?.settings || { background: { color: "#ffffff" } }); - - const rawNodes = nodesArr.map((n) => ({ - ...n, - draggable: true, - className: n.data.color ? n.data.color : n.className, - data: { ...n.data }, - })); - - setEdges(edgesArr); - setNodes(rawNodes); - - // Calculate next node ID - if (nodesArr.length > 0) { - const maxId = Math.max( - ...nodesArr - .map((n) => parseInt(n.id.replace(/\D/g, ""), 10)) - .filter((id) => !isNaN(id)) - ); - setNextNodeId(maxId + 1); - } - }, [setNodes, setEdges, setSettings]); - - useEffect(() => { - if (didUndoRedo) { - setDidUndoRedo(false); - loadRoadmapStateIntoReactFlowState(roadmapState); - } - }, [roadmapState, didUndoRedo, loadRoadmapStateIntoReactFlowState]); - - useEffect(() => { - const newEdges: Edge[] = edges.filter((e) => !e.id.startsWith("debug-")); - if (debugMode) { - nodes.forEach((node) => { - if (showCompletionNeeds && node.type === "topic" && node.data?.completion?.needs) { - node.data.completion.needs.forEach((needId: string) => { - const edgeId = `debug-edge-${needId}-to-${node.id}`; - newEdges.push({ - id: edgeId, - target: needId, - source: node.id, - animated: true, - style: { stroke: "#f97316", strokeWidth: 2, strokeDasharray: "5,5" }, - type: "floating", - }); - }); - } - if (showCompletionOptional && node.data?.completion?.optional) { - node.data.completion.optional.forEach((optionalId: string) => { - const edgeId = `debug-edge-optional-${optionalId}-to-${node.id}`; - newEdges.push({ - id: edgeId, - target: optionalId, - source: node.id, - animated: true, - style: { stroke: "#eab308", strokeWidth: 2, strokeDasharray: "5,5" }, - type: "floating", - }); - }); - } - }); - nodes.forEach((node) => { - if (showUnlockAfter && node.data.unlock?.after) { - node.data.unlock.after.forEach((unlockId: string) => { - const edgeId = `debug-edge-${unlockId}-to-${node.id}`; - newEdges.push({ - id: edgeId, - target: unlockId, - source: node.id, - animated: true, - style: { stroke: "#10b981", strokeWidth: 2, strokeDasharray: "5,5" }, - type: "floating", - }); - }); - } - }); - } - setEdges(newEdges); - }, [nodes, setEdges, debugMode, showCompletionNeeds, showCompletionOptional, showUnlockAfter]); - - // Event handlers - const onNodeClick = useCallback((_: any, node: Node) => { - setSelectedNodeId(node.id); - setDrawerOpen(true); - }, []); - - const onEdgeClick = useCallback((_: any, edge: Edge) => { - setSelectedEdge(edge); - setEdgeDrawerOpen(true); - }, []); - - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - setSaved(false); - }, - [setEdges, setSaved] - ); - - const toggleDebugMode = useCallback(() => { - setDebugMode((mode) => !mode); - }, [setDebugMode]); - - const closeDrawer = useCallback(() => { - setDrawerOpen(false); - setSelectedNodeId(null); - setEdgeDrawerOpen(false); - setSelectedEdge(null); - setSettingsDrawerOpen(false) - }, []); - - const updateNode = useCallback( - (updatedNode: Node) => { - setNodes((nds) => - nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) - ); - setSaved(false); - }, - [setNodes, setSaved] - ); - - const updateNodes = useCallback( - (updatedNodes: Node[]) => { - setNodes((nds) => nds.map(n => { - const updated = updatedNodes.find(un => un.id === n.id); - return updated ? updated : n; - })); - setSaved(false); - }, - [setNodes, setSaved] - ); - - const updateEdge = useCallback( - (updatedEdge: Edge) => { - setEdges((eds) => - eds.map((e) => (e.id === updatedEdge.id ? { ...e, ...updatedEdge } : e)) - ); - setSelectedEdge(updatedEdge); - setSaved(false); - }, - [setEdges, setSelectedEdge, setSaved] - ); - - // Delete selected edge - const deleteEdge = useCallback(() => { - if (!selectedEdge) return; - setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id)); - setSaved(false); - closeDrawer(); - }, [selectedEdge, setEdges, closeDrawer]); - - const deleteNode = useCallback(() => { - if (!selectedNodeId) return; - setNodes((nds) => nds.filter((n) => n.id !== selectedNodeId)); - setEdges((eds) => - eds.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId) - ); - setSaved(false); - closeDrawer(); - }, [selectedNodeId, setNodes, setEdges, closeDrawer, setSaved]); - - const addNewNode = useCallback( - (type: "task" | "topic" | "image" | "text") => { - // Use last mouse position if available, otherwise use center of screen - const position = lastMousePosition - ? screenToFlowPosition(lastMousePosition) - : screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - - if (type === "task") { - const newNode: Node = { - id: `node${nextNodeId}`, - type, - position, - data: { - label: t.newTask, - summary: "", - description: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } else if (type === "topic") { - const newNode: Node = { - id: `node${nextNodeId}`, - type, - position, - data: { - label: t.newTopic, - summary: "", - description: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } - else if (type === "image") { - const newNode: Node = { - id: `background-node${nextNodeId}`, - type, - zIndex: -2, - position, - data: { - src: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } else if (type === "text") { - const newNode: Node = { - id: `background-node${nextNodeId}`, - type, - position, - zIndex: -1, - data: { - text: t.backgroundTextDefault, - fontSize: 32, - color: "#e5e7eb", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } - setSaved(false); - }, - [nextNodeId, lastMousePosition, screenToFlowPosition, setNodes, setSaved, t] - ); - - const handleSave = useCallback(() => { - const roadmapData: RoadmapData = { - nodes: nodes.map((n) => ({ - id: n.id, - type: n.type, - position: n.position, - width: n.width, - height: n.height, - zIndex: n.zIndex, - data: n.data, - })), - edges: edges.filter((e) => !e.id.startsWith("debug-")) - .map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle, - targetHandle: e.targetHandle, - animated: e.animated, - type: e.type, - style: e.style, - })), - settings, - version: 1 - }; - - setRoadmapState(roadmapData); - setSaved(true); - - if (onChange) { - onChange(roadmapData); - return; - } else { - const root = document.querySelector("hyperbook-learningmap-editor"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); - } - } - }, [nodes, edges, settings]); - - // Auto-save when changes are made - useEffect(() => { - if (!saved) { - setTimeout(() => { - handleSave(); - }, 2000); - } - }, [saved, handleSave]); - - const togglePreviewMode = useCallback(() => { - handleSave(); - setPreviewMode((mode) => { - const newMode = !mode; - if (newMode) { - setDebugMode(false); - closeDrawer(); - } - return newMode; - }); - }, [setPreviewMode, handleSave]); - - const handleDownload = useCallback(() => { - const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapState, null, 2)); - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute("href", dataStr); - downloadAnchorNode.setAttribute("download", `${roadmapState.settings.title?.trim() ?? getDefaultFilename()}.learningmap`); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); - }, [roadmapState]); - - const handleShare = useCallback(() => { - // Check if map is empty (no nodes) - if (!roadmapState.nodes || roadmapState.nodes.length === 0) { - alert(t.emptyMapCannotBeShared); - return; - } - - // Upload to JSON store - fetch(`${jsonStore}/api/v2/post`, { - method: "POST", - mode: "cors", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(roadmapState), - }) - .then((r) => r.json()) - .then((json) => { - const link = window.location.origin + window.location.pathname + "#json=" + json.id; - setShareLink(link); - setShareDialogOpen(true); - }) - .catch(() => { - alert(t.uploadFailed); - }); - }, [roadmapState, jsonStore, t]); - - const loadFromJsonStore = useCallback((id: string) => { - fetch(`${jsonStore}/api/v2/${id}`, { - method: "GET", - mode: "cors", - }) - .then((r) => r.text()) - .then((text) => { - const json = JSON.parse(text); - setRoadmapState(json); - loadRoadmapStateIntoReactFlowState(json); - setLoadExternalDialogOpen(false); - setPendingExternalId(null); - }) - .catch(() => { - alert(t.loadFailed); - }); - }, [jsonStore, t, setRoadmapState]); - - const handleLoadExternal = useCallback((id: string) => { - setPendingExternalId(id); - setLoadExternalDialogOpen(true); - }, []); - // Check for external JSON in URL hash on mount - useEffect(() => { - const hash = window.location.hash; - if (hash.startsWith("#json=")) { - const id = hash.substring(6); - handleLoadExternal(id); - } - }, [handleLoadExternal]); - - - const defaultEdgeOptions = { - animated: false, - style: { - stroke: "#94a3b8", - strokeWidth: 2, - }, - type: "default", - }; - - const handleOpen = useCallback(() => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.learningmap,application/json'; - input.onchange = (e: any) => { - const file = e.target.files[0]; - if (!file) return; - - if (!window.confirm(t.openFileWarning)) { - return; - } - - const reader = new FileReader(); - reader.onload = (evt) => { - try { - const content = evt.target?.result; - if (typeof content === 'string') { - const json = JSON.parse(content); - setRoadmapState(json); - loadRoadmapStateIntoReactFlowState(json); - } - } catch (err) { - alert(t.failedToLoadFile); - } - }; - reader.readAsText(file); - }; - input.click(); - }, [setRoadmapState, setDidUndoRedo, t]); - - // Toolbar handler wrappers for EditorToolbar props - const handleOpenSettingsDrawer = useCallback(() => setSettingsDrawerOpen(true), []); - const handleSetShowCompletionNeeds = useCallback((checked: boolean) => setShowCompletionNeeds(checked), []); - const handleSetShowCompletionOptional = useCallback((checked: boolean) => setShowCompletionOptional(checked), []); - const handleSetShowUnlockAfter = useCallback((checked: boolean) => setShowUnlockAfter(checked), []); - - const handleNodesChange: OnNodesChange = useCallback( - (changes) => { - setSaved(false); - onNodesChange(changes); - }, - [onNodesChange, setSaved] - ); - - const handleEdgesChange: OnEdgesChange = useCallback( - (changes) => { - setSaved(false); - onEdgesChange(changes); - }, - [onEdgesChange, setSaved] - ); - - const handleUndo = useCallback(() => { - if (canUndo) { - undo(); - setDidUndoRedo(true); - } - }, [canUndo, undo]); - - const handleRedo = useCallback(() => { - if (canRedo) { - redo(); - setDidUndoRedo(true); - } - }, [canRedo, redo]); - - const handleReset = useCallback(() => { - reset(); - setDidUndoRedo(true); - }, [reset]); - - const handleCut = useCallback(() => { - const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); - if (selectedNodes.length > 0) { - const selectedNodeIdSet = new Set(selectedNodeIds); - const relatedEdges = edges.filter(e => - selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) - ); - setClipboard({ nodes: selectedNodes, edges: relatedEdges }); - // Delete the selected nodes - setNodes(nds => nds.filter(n => !selectedNodeIdSet.has(n.id))); - setEdges(eds => eds.filter(e => - !selectedNodeIdSet.has(e.source) && !selectedNodeIdSet.has(e.target) - )); - setSelectedNodeIds([]); - setSaved(false); - } - }, [nodes, edges, selectedNodeIds, setNodes, setEdges, setSelectedNodeIds, setSaved]); - - const handleCopy = useCallback(() => { - const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); - if (selectedNodes.length > 0) { - const selectedNodeIdSet = new Set(selectedNodeIds); - const relatedEdges = edges.filter(e => - selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) - ); - setClipboard({ nodes: selectedNodes, edges: relatedEdges }); - } - }, [nodes, edges, selectedNodeIds]); - - const handlePaste = useCallback(() => { - if (!clipboard) return; - - // Create a mapping from old node IDs to new node IDs - const idMapping: Record = {}; - let newNextNodeId = nextNodeId; - - const newNodes = clipboard.nodes.map(node => { - const newId = node.id.startsWith('background-node') - ? `background-node${newNextNodeId}` - : `node${newNextNodeId}`; - idMapping[node.id] = newId; - newNextNodeId++; - - return { - ...node, - id: newId, - position: { - x: node.position.x + 50, - y: node.position.y + 50, - }, - }; - }); - - const newEdges = clipboard.edges.map((edge, idx) => ({ - ...edge, - id: `e${Date.now()}-${idx}`, - source: idMapping[edge.source] || edge.source, - target: idMapping[edge.target] || edge.target, - })); - - setNodes(nds => [...nds, ...newNodes]); - setEdges(eds => [...eds, ...newEdges]); - setNextNodeId(newNextNodeId); - setSelectedNodeIds(newNodes.map(n => n.id)); - setSaved(false); - }, [clipboard, nextNodeId, setNodes, setEdges, setNextNodeId, setSelectedNodeIds, setSaved]); - - const handleZoomIn = useCallback(() => { - zoomIn(); - }, [zoomIn]); - - const handleZoomOut = useCallback(() => { - zoomOut(); - }, [zoomOut]); - - const handleResetZoom = useCallback(() => { - setCenter(0, 0, { zoom: 1, duration: 300 }); - }, [setCenter]); - - const handleFitView = useCallback(() => { - fitView({ duration: 300 }); - }, [fitView]); - - const handleZoomToSelection = useCallback(() => { - if (selectedNodeIds.length > 0) { - fitView({ nodes: selectedNodeIds.map(s => ({ id: s })), duration: 300, padding: 0.2 }); - } - }, [selectedNodeIds, fitView]); - - const handleToggleGrid = useCallback(() => { - setShowGrid(prev => !prev); - }, []); - - const handleResetMap = useCallback(() => { - if (confirm(t.resetMapWarning)) { - setNodes([]); - setEdges([]); - setNextNodeId(1); - setSaved(false); - } - }, [setNodes, setEdges, setNextNodeId, setSaved, t]); - - const handleSelectAll = useCallback(() => { - setNodes(nds => nds.map(n => ({ - ...n, - selected: true, - }))) - }, [nodes, setSelectedNodeIds]); - - const handleSelectionChange: OnSelectionChangeFunc = useCallback( - ({ nodes: selectedNodes }) => { - setSelectedNodeIds(selectedNodes.map(n => n.id)); - }, - [setSelectedNodeIds] - ); - - // Track mouse position for node placement - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - setLastMousePosition({ x: e.clientX, y: e.clientY }); - }; - window.addEventListener("mousemove", handleMouseMove); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - }; - }, []); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - //save shortcut - if ((e.ctrlKey || e.metaKey) && e.key === 's' && !e.shiftKey) { - e.preventDefault(); - handleSave(); - } - // undo shortcut - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - handleUndo(); - } - // redo shortcut - if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { - e.preventDefault(); - handleRedo(); - } - // add task node shortcut - if ((e.ctrlKey || e.metaKey) && e.key === '1' && !e.shiftKey) { - e.preventDefault(); - addNewNode("task"); - } - // add topic node shortcut - if ((e.ctrlKey || e.metaKey) && e.key === '2' && !e.shiftKey) { - e.preventDefault(); - addNewNode("topic"); - } - // add image node shortcut - if ((e.ctrlKey || e.metaKey) && e.key === '3' && !e.shiftKey) { - e.preventDefault(); - addNewNode("image"); - } - if ((e.ctrlKey || e.metaKey) && e.key === '4' && !e.shiftKey) { - e.preventDefault(); - addNewNode("text"); - } + return ( + <> + {/* Keyboard shortcuts handler */} + - if ((e.ctrlKey || e.metaKey) && (e.key === '?' || (e.shiftKey && e.key === '/'))) { - e.preventDefault(); - setHelpOpen(h => !h); - } - //preview toggle shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p' && !e.shiftKey) { - e.preventDefault(); - togglePreviewMode(); - } - //debug toggle shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd' && !e.shiftKey) { - e.preventDefault(); - toggleDebugMode(); - } + {/* Toolbar */} + - // Zoom in shortcut - if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=') && !e.shiftKey) { - e.preventDefault(); - handleZoomIn(); - } - // Zoom out shortcut - if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_') && !e.shiftKey) { - e.preventDefault(); - handleZoomOut(); - } - // Reset zoom shortcut - if ((e.ctrlKey || e.metaKey) && e.key === '0' && !e.shiftKey) { - e.preventDefault(); - handleResetZoom(); - } - // Fit view shortcut - if (e.shiftKey && e.code === 'Digit1' && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - handleFitView(); - } - // Zoom to selection shortcut - if (e.shiftKey && e.code === 'Digit2' && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - handleZoomToSelection(); - } + {/* Preview or Edit mode */} + {previewMode && } + {!previewMode && <> + {/* Welcome message when empty */} + {nodes.length === 0 && edges.length === 0 && } - // Toggle grid shortcut - if ((e.ctrlKey || e.metaKey) && e.code === "Backslash") { - e.preventDefault(); - handleToggleGrid(); - } - // Reset map shortcut - if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') { - e.preventDefault(); - handleResetMap(); - } - // Cut shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { - e.preventDefault(); - handleCut(); - } - // Copy shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && !e.shiftKey) { - e.preventDefault(); - handleCopy(); - } - // Paste shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v' && !e.shiftKey) { - e.preventDefault(); - handlePaste(); - } - // Select all shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && !e.shiftKey) { - e.preventDefault(); - handleSelectAll(); - } + {/* Main canvas */} + - // Dismiss with Escape - if (helpOpen && e.key === 'Escape') { - setHelpOpen(false); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode, - handleZoomIn, handleZoomOut, handleResetZoom, handleFitView, handleZoomToSelection, handleToggleGrid, - handleResetMap, handleCut, handleCopy, handlePaste, handleSelectAll]); + {/* Drawers */} + + + - return ( - <> - - {previewMode && } - {!previewMode && <> -
- {nodes.length === 0 && edges.length === 0 && addNewNode("topic")} - onShowHelp={() => setHelpOpen(true)} - language={effectiveLanguage} - />} - - {showGrid && } - - - - - - - - - - - setHelpOpen(true)}> - - - - {!saved && { handleSave(); }}> - - } - {selectedNodeIds.length > 1 && selectedNodeIds.includes(n.id))} onUpdate={updateNodes} />} - -
- n.id === selectedNodeId)} - isOpen={drawerOpen} - onClose={closeDrawer} - onUpdate={updateNode} - onDelete={deleteNode} - language={effectiveLanguage} - /> - - - setHelpOpen(false)} - > -
-

{t.keyboardShortcuts}

- -
-
- - - - - - - - - {keyboardShortcuts.map((item) => ( - - - - - ))} - -
{t.action}{t.shortcut}
{item.action}{item.shortcut}
-
-
- -
-
- setShareDialogOpen(false)} - shareLink={shareLink} - language={effectiveLanguage} - /> - { - setLoadExternalDialogOpen(false); - setPendingExternalId(null); - }} - onDownloadCurrent={handleDownload} - onReplace={() => { - if (pendingExternalId) { - loadFromJsonStore(pendingExternalId); - } - }} - language={effectiveLanguage} - /> - - } + {/* Dialogs */} + + } ); } diff --git a/packages/learningmap/src/LoadExternalDialog.tsx b/packages/learningmap/src/LoadExternalDialog.tsx index 3a3c275..8930077 100644 --- a/packages/learningmap/src/LoadExternalDialog.tsx +++ b/packages/learningmap/src/LoadExternalDialog.tsx @@ -1,22 +1,24 @@ import React from "react"; import { X, Download, AlertTriangle } from "lucide-react"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; interface LoadExternalDialogProps { - open: boolean; onClose: () => void; onDownloadCurrent: () => void; onReplace: () => void; - language?: string; } export function LoadExternalDialog({ - open, onClose, onDownloadCurrent, onReplace, - language = "en", }: LoadExternalDialogProps) { + // Get state from store + const open = useEditorStore(state => state.loadExternalDialogOpen); + const settings = useEditorStore(state => state.settings); + + const language = settings?.language || "en"; const t = getTranslations(language); if (!open) return null; diff --git a/packages/learningmap/src/MultiNodePanel.tsx b/packages/learningmap/src/MultiNodePanel.tsx index 4843c23..0c0f961 100644 --- a/packages/learningmap/src/MultiNodePanel.tsx +++ b/packages/learningmap/src/MultiNodePanel.tsx @@ -2,13 +2,17 @@ import { Node, Panel } from "@xyflow/react"; import { NodeData } from "./types"; import { FC } from "react"; import { AlignCenterVertical, AlignCenterHorizontal, AlignEndHorizontal, AlignEndVertical, AlignStartVertical, AlignStartHorizontal, RulerDimensionLine, AlignVerticalDistributeCenter, AlignHorizontalDistributeCenter } from "lucide-react"; +import { useEditorStore } from "./editorStore"; -interface Props { - nodes: Node[]; - onUpdate: (nodes: Node[]) => void; -} - -export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { +export const MultiNodePanel: FC = () => { + // Get selected nodes from store + const selectedNodeIds = useEditorStore(state => state.selectedNodeIds); + const allNodes = useEditorStore(state => state.nodes); + const updateNodes = useEditorStore(state => state.updateNodes); + + const nodes = allNodes.filter(n => selectedNodeIds.includes(n.id)); + + if (nodes.length < 2) return null; const alignLeftVertical = () => { if (nodes.length < 2) return; @@ -17,7 +21,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, x: minX } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const alignCenterVertical = () => { @@ -27,7 +31,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, x: avgX - (n.width || n.measured.width) / 2 } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const alignRightVertical = () => { @@ -37,7 +41,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, x: maxX - (n.width || n.measured.width) } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const alignLeft = () => { @@ -47,7 +51,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, y: minY } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const alignCenter = () => { @@ -57,7 +61,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, y: avgY - (n.height || n.measured.height) / 2 } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const alignRight = () => { @@ -67,7 +71,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, position: { ...n.position, y: maxY - (n.height || n.measured.height) } })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const distributeVertical = () => { @@ -88,7 +92,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { currentY += (n.height || n.measured.height) + gap; return updatedNode; }); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const distributeHorizontal = () => { @@ -108,7 +112,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { currentX += (n.width || n.measured.width) + gap; return updatedNode; }); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const sameWidth = () => { @@ -118,7 +122,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, width: maxWidth })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; const sameHeight = () => { @@ -128,7 +132,7 @@ export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { ...n, height: maxHeight })); - onUpdate(updatedNodes); + updateNodes(updatedNodes); }; return diff --git a/packages/learningmap/src/SettingsDrawer.tsx b/packages/learningmap/src/SettingsDrawer.tsx index b0ea60f..d292cd6 100644 --- a/packages/learningmap/src/SettingsDrawer.tsx +++ b/packages/learningmap/src/SettingsDrawer.tsx @@ -4,23 +4,26 @@ import { Settings } from "./types"; import { ColorSelector } from "./ColorSelector"; import { getTranslations } from "./translations"; import { useReactFlow } from "@xyflow/react"; +import { useEditorStore } from "./editorStore"; interface SettingsDrawerProps { - isOpen: boolean; - onClose: () => void; - settings: Settings; - onUpdate: (s: Settings) => void; - language?: string; + defaultLanguage?: string; } export const SettingsDrawer: React.FC = ({ - isOpen, - onClose, - settings, - onUpdate, - language = "en", + defaultLanguage = "en", }) => { + // Get state from store + const isOpen = useEditorStore(state => state.settingsDrawerOpen); + const settings = useEditorStore(state => state.settings); + + // Get actions from store + const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen); + const setSettings = useEditorStore(state => state.setSettings); + + const language = settings?.language || defaultLanguage; const t = getTranslations(language); + const [localSettings, setLocalSettings] = useState(settings); const { getViewport } = useReactFlow(); @@ -28,6 +31,12 @@ export const SettingsDrawer: React.FC = ({ setLocalSettings(settings); }, [settings]); + const onClose = () => setSettingsDrawerOpen(false); + + const onUpdate = (s: Settings) => { + setSettings(s); + }; + if (!isOpen) return null; const handleSave = () => { diff --git a/packages/learningmap/src/ShareDialog.tsx b/packages/learningmap/src/ShareDialog.tsx index eb03bc5..c006775 100644 --- a/packages/learningmap/src/ShareDialog.tsx +++ b/packages/learningmap/src/ShareDialog.tsx @@ -1,17 +1,21 @@ import React, { useState } from "react"; import { X, Link2, Check } from "lucide-react"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; -interface ShareDialogProps { - open: boolean; - onClose: () => void; - shareLink: string; - language?: string; -} +export function ShareDialog() { + const [copied, setCopied] = useState(false); + + // Get state from store + const open = useEditorStore(state => state.shareDialogOpen); + const shareLink = useEditorStore(state => state.shareLink); + const settings = useEditorStore(state => state.settings); + const setShareDialogOpen = useEditorStore(state => state.setShareDialogOpen); -export function ShareDialog({ open, onClose, shareLink, language = "en" }: ShareDialogProps) { + const language = settings?.language || "en"; const t = getTranslations(language); - const [copied, setCopied] = useState(false); + + const onClose = () => setShareDialogOpen(false); if (!open) return null; diff --git a/packages/learningmap/src/WelcomeMessage.tsx b/packages/learningmap/src/WelcomeMessage.tsx index 35fc8a6..8beb4f9 100644 --- a/packages/learningmap/src/WelcomeMessage.tsx +++ b/packages/learningmap/src/WelcomeMessage.tsx @@ -1,23 +1,65 @@ import React from "react"; import { FolderOpen, Plus, Info } from "lucide-react"; import { getTranslations } from "./translations"; +import { useEditorStore } from "./editorStore"; +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; import logo from "./logo.svg"; interface WelcomeMessageProps { - onOpenFile: () => void; - onAddTopic: () => void; - onShowHelp: () => void; - language?: string; + defaultLanguage?: string; } export const WelcomeMessage: React.FC = ({ - onOpenFile, - onAddTopic, - onShowHelp, - language = "en", + defaultLanguage = "en", }) => { + // Get state and actions from store + const settings = useEditorStore(state => state.settings); + const addNode = useEditorStore(state => state.addNode); + const setHelpOpen = useEditorStore(state => state.setHelpOpen); + const loadRoadmapData = useEditorStore(state => state.loadRoadmapData); + + const language = settings?.language || defaultLanguage; const t = getTranslations(language); + const onOpenFile = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = (e: any) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e: any) => { + try { + const data = JSON.parse(e.target.result); + loadRoadmapData(data); + } catch (error) { + console.error("Failed to parse JSON file", error); + } + }; + reader.readAsText(file); + } + }; + input.click(); + }; + + 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); + }; + + const onShowHelp = () => setHelpOpen(true); + return (
diff --git a/packages/learningmap/src/editorStore.ts b/packages/learningmap/src/editorStore.ts new file mode 100644 index 0000000..62cc3a3 --- /dev/null +++ b/packages/learningmap/src/editorStore.ts @@ -0,0 +1,469 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { temporal } from "zundo"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import isDeepEqual from "fast-deep-equal"; +import { throttle } from "throttle-debounce"; +import type { TemporalState } from "zundo"; +import { + Node, + Edge, + applyNodeChanges, + applyEdgeChanges, + addEdge, + NodeChange, + EdgeChange, + Connection, +} from "@xyflow/react"; +import { NodeData, RoadmapData, Settings } from "./types"; + +// 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 { + // Core data + nodes: Node[]; + edges: Edge[]; + settings: Settings; + + // UI state + previewMode: boolean; + debugMode: boolean; + showGrid: boolean; + helpOpen: boolean; + drawerOpen: boolean; + settingsDrawerOpen: boolean; + edgeDrawerOpen: boolean; + shareDialogOpen: boolean; + loadExternalDialogOpen: boolean; + + // Selected items + selectedNodeId: string | null; + selectedNodeIds: string[]; + selectedEdge: Edge | null; + + // Other state + nextNodeId: number; + clipboard: { nodes: Node[]; edges: Edge[] } | null; + lastMousePosition: { x: number; y: number } | null; + shareLink: string; + pendingExternalId: string | null; + + // Debug settings + showCompletionNeeds: boolean; + showCompletionOptional: boolean; + showUnlockAfter: boolean; + + // Actions + onNodesChange: (changes: NodeChange>[]) => void; + onEdgesChange: (changes: EdgeChange[]) => void; + onConnect: (connection: Connection) => void; + setNodes: (nodes: Node[]) => void; + setEdges: (edges: Edge[]) => void; + setSettings: (settings: Settings) => void; + updateNode: (nodeId: string, updates: Partial>) => void; + updateNodeData: (nodeId: string, dataUpdates: Partial) => void; + updateNodes: (updates: Node[]) => void; + updateEdge: (edgeId: string, updates: Partial) => void; + updateDebugEdges: () => void; + deleteNode: (nodeId: string) => void; + deleteEdge: (edgeId: string) => void; + addNode: (node: Node) => void; + + // UI state setters + setPreviewMode: (previewMode: boolean) => void; + setDebugMode: (debugMode: boolean) => void; + setShowGrid: (showGrid: boolean) => void; + setHelpOpen: (helpOpen: boolean) => void; + setDrawerOpen: (drawerOpen: boolean) => void; + setSettingsDrawerOpen: (settingsDrawerOpen: boolean) => void; + setEdgeDrawerOpen: (edgeDrawerOpen: boolean) => void; + setShareDialogOpen: (shareDialogOpen: boolean) => void; + setLoadExternalDialogOpen: (loadExternalDialogOpen: boolean) => void; + setSelectedNodeId: (nodeId: string | null) => void; + setSelectedNodeIds: (nodeIds: string[]) => void; + setSelectedEdge: (edge: Edge | null) => void; + setNextNodeId: (nextNodeId: number) => void; + setClipboard: ( + clipboard: { nodes: Node[]; edges: Edge[] } | null, + ) => void; + setLastMousePosition: (position: { x: number; y: number } | null) => void; + setShareLink: (shareLink: string) => void; + setPendingExternalId: (pendingExternalId: string | null) => void; + setShowCompletionNeeds: (showCompletionNeeds: boolean) => void; + setShowCompletionOptional: (showCompletionOptional: boolean) => void; + setShowUnlockAfter: (showUnlockAfter: boolean) => void; + + // Bulk operations + loadRoadmapData: (roadmapData: RoadmapData) => void; + getRoadmapData: () => RoadmapData; + closeAllDrawers: () => void; + reset: () => void; +} + +const initialState = { + nodes: [], + edges: [], + settings: { background: { color: "#ffffff" } }, + previewMode: false, + debugMode: false, + showGrid: false, + helpOpen: false, + drawerOpen: false, + settingsDrawerOpen: false, + edgeDrawerOpen: false, + shareDialogOpen: false, + loadExternalDialogOpen: false, + selectedNodeId: null, + selectedNodeIds: [], + selectedEdge: null, + nextNodeId: 1, + clipboard: null, + lastMousePosition: null, + shareLink: "", + pendingExternalId: null, + showCompletionNeeds: true, + showCompletionOptional: true, + showUnlockAfter: true, +}; + +export const useEditorStore = create()( + persist( + temporal( + (set, get) => ({ + ...initialState, + + // ReactFlow handlers + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes), + }); + }, + + onEdgesChange: (changes) => { + set({ + edges: applyEdgeChanges(changes, get().edges), + }); + }, + + onConnect: (connection) => { + set({ + edges: addEdge(connection, get().edges), + }); + }, + + // Node operations + setNodes: (nodes) => { + set({ nodes }); + }, + + setEdges: (edges) => { + set({ edges }); + }, + + setSettings: (settings) => { + set({ settings }); + }, + + updateDebugEdges: () => { + const debugMode = get().debugMode; + const nodes = get().nodes; + const showCompletionNeeds = get().showCompletionNeeds; + const showCompletionOptional = get().showCompletionOptional; + const showUnlockAfter = get().showUnlockAfter; + // Filter out existing debug edges + const baseEdges = get().edges.filter( + (e) => !e.id.startsWith("debug-"), + ); + const newEdges: Edge[] = [...baseEdges]; + + if (debugMode) { + nodes.forEach((node) => { + if ( + showCompletionNeeds && + node.type === "topic" && + node.data?.completion?.needs + ) { + node.data.completion.needs.forEach((needId: string) => { + const edgeId = `debug-edge-${needId}-to-${node.id}`; + newEdges.push({ + id: edgeId, + target: needId, + source: node.id, + animated: true, + style: { + stroke: "#f97316", + strokeWidth: 2, + strokeDasharray: "5,5", + }, + type: "floating", + }); + }); + } + if (showCompletionOptional && node.data?.completion?.optional) { + node.data.completion.optional.forEach((optionalId: string) => { + const edgeId = `debug-edge-optional-${optionalId}-to-${node.id}`; + newEdges.push({ + id: edgeId, + target: optionalId, + source: node.id, + animated: true, + style: { + stroke: "#eab308", + strokeWidth: 2, + strokeDasharray: "5,5", + }, + type: "floating", + }); + }); + } + }); + nodes.forEach((node) => { + if (showUnlockAfter && node.data.unlock?.after) { + node.data.unlock.after.forEach((unlockId: string) => { + const edgeId = `debug-edge-${unlockId}-to-${node.id}`; + newEdges.push({ + id: edgeId, + target: unlockId, + source: node.id, + animated: true, + style: { + stroke: "#10b981", + strokeWidth: 2, + strokeDasharray: "5,5", + }, + type: "floating", + }); + }); + } + }); + } + set({ edges: newEdges }); + }, + + updateNode: (nodeId, updates) => { + set({ + nodes: get().nodes.map((n) => + n.id === nodeId ? { ...n, ...updates } : n, + ), + }); + }, + + updateNodeData: (nodeId, dataUpdates) => { + set({ + nodes: get().nodes.map((n) => + n.id === nodeId + ? { ...n, data: { ...n.data, ...dataUpdates } } + : n, + ), + }); + }, + + updateNodes: (updates) => { + set({ + nodes: get().nodes.map((n) => { + const updated = updates.find((un) => un.id === n.id); + return updated ? updated : n; + }), + }); + }, + + updateEdge: (edgeId, updates) => { + set({ + edges: get().edges.map((e) => + e.id === edgeId ? { ...e, ...updates } : e, + ), + }); + // Update selected edge if it's the one being updated + const selectedEdge = get().selectedEdge; + if (selectedEdge && selectedEdge.id === edgeId) { + set({ selectedEdge: { ...selectedEdge, ...updates } }); + } + }, + + deleteNode: (nodeId) => { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter( + (e) => e.source !== nodeId && e.target !== nodeId, + ), + }); + }, + + deleteEdge: (edgeId) => { + set({ + edges: get().edges.filter((e) => e.id !== edgeId), + }); + }, + + addNode: (node) => { + set({ + nodes: [...get().nodes, node], + }); + }, + + // UI state setters + setPreviewMode: (previewMode) => set({ previewMode }), + setDebugMode: (debugMode) => { + get().updateDebugEdges(); + set({ debugMode }); + }, + setShowGrid: (showGrid) => set({ showGrid }), + setHelpOpen: (helpOpen) => set({ helpOpen }), + setDrawerOpen: (drawerOpen) => set({ drawerOpen }), + setSettingsDrawerOpen: (settingsDrawerOpen) => + set({ settingsDrawerOpen }), + setEdgeDrawerOpen: (edgeDrawerOpen) => set({ edgeDrawerOpen }), + setShareDialogOpen: (shareDialogOpen) => set({ shareDialogOpen }), + setLoadExternalDialogOpen: (loadExternalDialogOpen) => + set({ loadExternalDialogOpen }), + setSelectedNodeId: (selectedNodeId) => + set({ + selectedNodeId, + nodes: get().nodes.map((n) => ({ + ...n, + selected: n.id === selectedNodeId, + })), + }), + setSelectedNodeIds: (selectedNodeIds) => + set({ + selectedNodeIds, + nodes: get().nodes.map((n) => ({ + ...n, + selected: selectedNodeIds.includes(n.id), + })), + }), + setSelectedEdge: (selectedEdge) => set({ selectedEdge }), + setNextNodeId: (nextNodeId) => set({ nextNodeId }), + setClipboard: (clipboard) => set({ clipboard }), + setLastMousePosition: (lastMousePosition) => set({ lastMousePosition }), + setShareLink: (shareLink) => set({ shareLink }), + setPendingExternalId: (pendingExternalId) => set({ pendingExternalId }), + setShowCompletionNeeds: (showCompletionNeeds) => { + get().updateDebugEdges(); + set({ showCompletionNeeds }); + }, + setShowCompletionOptional: (showCompletionOptional) => { + get().updateDebugEdges(); + set({ showCompletionOptional }); + }, + setShowUnlockAfter: (showUnlockAfter) => { + get().updateDebugEdges(); + set({ showUnlockAfter }); + }, + + // Bulk operations + loadRoadmapData: (roadmapData) => { + const nodesArr = Array.isArray(roadmapData?.nodes) + ? roadmapData.nodes + : []; + const edgesArr = Array.isArray(roadmapData?.edges) + ? roadmapData.edges + : []; + + const rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: true, + className: n.data.color ? n.data.color : n.className, + data: { ...n.data }, + })); + + // Calculate next node ID + let nextNodeId = 1; + if (nodesArr.length > 0) { + const maxId = Math.max( + ...nodesArr + .map((n) => parseInt(n.id.replace(/\D/g, ""), 10)) + .filter((id) => !isNaN(id)), + ); + nextNodeId = maxId + 1; + } + + set({ + nodes: rawNodes, + edges: edgesArr, + settings: roadmapData?.settings || { + background: { color: "#ffffff" }, + }, + nextNodeId, + }); + }, + + getRoadmapData: () => { + const state = get(); + return { + nodes: state.nodes.map((n) => ({ + id: n.id, + type: n.type, + position: n.position, + width: n.width, + height: n.height, + zIndex: n.zIndex, + data: n.data, + })), + edges: state.edges + .filter((e) => !e.id.startsWith("debug-")) + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + animated: e.animated, + type: e.type, + style: e.style, + })), + settings: state.settings, + version: 1, + }; + }, + + closeAllDrawers: () => { + set({ + drawerOpen: false, + selectedNodeId: null, + edgeDrawerOpen: false, + selectedEdge: null, + settingsDrawerOpen: false, + }); + }, + + reset: () => { + set(initialState); + }, + }), + { + equality: (oldState, newState) => isDeepEqual(oldState, newState), + handleSet: (handleSet) => + throttle( + 1000, + (state) => { + handleSet(state); + }, + { noLeading: true, noTrailing: false }, + ), + partialize: (state): any => { + const { nodes, edges, settings } = state; + return { nodes, edges, settings }; + }, + }, + ), + { + name: "learningmap-data", // name of the item in storage + version: 1, + partialize: (state) => { + const { nodes, edges, settings } = state; + return { nodes, edges, settings }; + }, + }, + ), +); + +type PartialEditorState = Pick; + +// Hook for accessing temporal store (undo/redo) +export function useTemporalStore( + selector?: (state: TemporalState) => T, + equality?: (a: T, b: T) => boolean, +) { + return useStoreWithEqualityFn(useEditorStore.temporal, selector!, equality); +} diff --git a/packages/learningmap/src/index.css b/packages/learningmap/src/index.css index fdee311..3f3314a 100644 --- a/packages/learningmap/src/index.css +++ b/packages/learningmap/src/index.css @@ -262,6 +262,7 @@ header.drawer-header { opacity: 0; transform: translate(-50%, -50%) scale(0.95); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); @@ -326,6 +327,7 @@ header.drawer-header { cursor: pointer; display: flex; align-items: center; + justify-content: center; gap: 6px; transition: background 0.2s; } @@ -645,6 +647,10 @@ dialog.help[open] { align-items: center; border-bottom: 1px solid #e5e7eb; + button { + width: auto; + } + h2 { margin: 0; font-size: 24px; diff --git a/packages/learningmap/src/index.ts b/packages/learningmap/src/index.ts index 0de8987..6a0aa5e 100644 --- a/packages/learningmap/src/index.ts +++ b/packages/learningmap/src/index.ts @@ -7,3 +7,5 @@ export type { RoadmapData, RoadmapState } from "./types"; export type { LearningMapProps } from "./LearningMap"; export type { LearningMapEditorProps } from "./LearningMapEditor"; export { LearningMap, LearningMapEditor }; +export { useEditorStore, useTemporalStore } from "./editorStore"; +export { useViewerStore } from "./viewerStore"; diff --git a/packages/learningmap/src/useEditorActions.ts b/packages/learningmap/src/useEditorActions.ts new file mode 100644 index 0000000..054ed18 --- /dev/null +++ b/packages/learningmap/src/useEditorActions.ts @@ -0,0 +1,415 @@ +import { useCallback } from "react"; +import { useReactFlow } from "@xyflow/react"; +import { Node, Edge } from "@xyflow/react"; +import { useEditorStore } from "./editorStore"; +import { NodeData, ImageNodeData, TextNodeData } from "./types"; + +export const useEditorActions = (t: any, screenToFlowPosition: any, jsonStore: string) => { + const { zoomIn, zoomOut, setCenter, fitView } = useReactFlow(); + + // Get store state + const nodes = useEditorStore(state => state.nodes); + const edges = useEditorStore(state => state.edges); + const selectedNodeId = useEditorStore(state => state.selectedNodeId); + const selectedNodeIds = useEditorStore(state => state.selectedNodeIds); + const selectedEdge = useEditorStore(state => state.selectedEdge); + const clipboard = useEditorStore(state => state.clipboard); + const nextNodeId = useEditorStore(state => state.nextNodeId); + const lastMousePosition = useEditorStore(state => state.lastMousePosition); + const debugMode = useEditorStore(state => state.debugMode); + const previewMode = useEditorStore(state => state.previewMode); + const showGrid = useEditorStore(state => state.showGrid); + + // Get store actions + const setSelectedNodeId = useEditorStore(state => state.setSelectedNodeId); + const setDrawerOpen = useEditorStore(state => state.setDrawerOpen); + const setSelectedEdge = useEditorStore(state => state.setSelectedEdge); + const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen); + const updateNode = useEditorStore(state => state.updateNode); + const updateNodes = useEditorStore(state => state.updateNodes); + const updateEdge = useEditorStore(state => state.updateEdge); + const deleteNode = useEditorStore(state => state.deleteNode); + const deleteEdge = useEditorStore(state => state.deleteEdge); + const addNode = useEditorStore(state => state.addNode); + const setNextNodeId = useEditorStore(state => state.setNextNodeId); + const setClipboard = useEditorStore(state => state.setClipboard); + const setSelectedNodeIds = useEditorStore(state => state.setSelectedNodeIds); + const setNodes = useEditorStore(state => state.setNodes); + const setEdges = useEditorStore(state => state.setEdges); + const getRoadmapData = useEditorStore(state => state.getRoadmapData); + const loadRoadmapData = useEditorStore(state => state.loadRoadmapData); + const setShareDialogOpen = useEditorStore(state => state.setShareDialogOpen); + const setShareLink = useEditorStore(state => state.setShareLink); + const setLoadExternalDialogOpen = useEditorStore(state => state.setLoadExternalDialogOpen); + const setPendingExternalId = useEditorStore(state => state.setPendingExternalId); + const closeAllDrawers = useEditorStore(state => state.closeAllDrawers); + const setShowGrid = useEditorStore(state => state.setShowGrid); + const setDebugMode = useEditorStore(state => state.setDebugMode); + const setPreviewMode = useEditorStore(state => state.setPreviewMode); + const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen); + + const handleNodeClick = useCallback((_: any, node: Node) => { + setSelectedNodeId(node.id); + setDrawerOpen(true); + }, [setSelectedNodeId, setDrawerOpen]); + + const handleEdgeClick = useCallback((_: any, edge: Edge) => { + setSelectedEdge(edge); + setEdgeDrawerOpen(true); + }, [setSelectedEdge, setEdgeDrawerOpen]); + + const closeDrawer = useCallback(() => { + closeAllDrawers(); + }, [closeAllDrawers]); + + const handleUpdateNode = useCallback( + (updatedNode: Node) => { + updateNode(updatedNode.id, updatedNode); + }, + [updateNode] + ); + + const handleUpdateNodes = useCallback( + (updatedNodes: Node[]) => { + updateNodes(updatedNodes); + }, + [updateNodes] + ); + + const handleUpdateEdge = useCallback( + (updatedEdge: Edge) => { + updateEdge(updatedEdge.id, updatedEdge); + }, + [updateEdge] + ); + + const handleDeleteEdge = useCallback(() => { + if (!selectedEdge) return; + deleteEdge(selectedEdge.id); + closeAllDrawers(); + }, [selectedEdge, deleteEdge, closeAllDrawers]); + + const handleDeleteNode = useCallback(() => { + if (!selectedNodeId) return; + deleteNode(selectedNodeId); + closeAllDrawers(); + }, [selectedNodeId, deleteNode, closeAllDrawers]); + + const addNewNode = useCallback( + (type: "task" | "topic" | "image" | "text") => { + const position = lastMousePosition + ? screenToFlowPosition(lastMousePosition) + : screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + + if (type === "task") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position, + data: { + label: t.newTask, + summary: "", + description: "", + }, + }; + addNode(newNode); + setNextNodeId(nextNodeId + 1); + } else if (type === "topic") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position, + data: { + label: t.newTopic, + summary: "", + description: "", + }, + }; + addNode(newNode); + setNextNodeId(nextNodeId + 1); + } + else if (type === "image") { + const newNode: Node = { + id: `background-node${nextNodeId}`, + type, + zIndex: -2, + position, + data: { + src: "", + }, + }; + addNode(newNode); + setNextNodeId(nextNodeId + 1); + } else if (type === "text") { + const newNode: Node = { + id: `background-node${nextNodeId}`, + type, + position, + zIndex: -1, + data: { + text: t.backgroundTextDefault, + fontSize: 32, + color: "#e5e7eb", + }, + }; + addNode(newNode); + setNextNodeId(nextNodeId + 1); + } + }, + [nextNodeId, lastMousePosition, screenToFlowPosition, addNode, setNextNodeId, t] + ); + + const handleSave = useCallback(() => { + const roadmapData = getRoadmapData(); + return roadmapData; + }, [getRoadmapData]); + + const togglePreviewMode = useCallback(() => { + const newMode = !previewMode; + setPreviewMode(newMode); + if (newMode) { + setDebugMode(false); + closeDrawer(); + } + }, [previewMode, setPreviewMode, setDebugMode, closeDrawer]); + + const toggleDebugMode = useCallback(() => { + setDebugMode(!debugMode); + }, [debugMode, setDebugMode]); + + const handleDownload = useCallback(() => { + const roadmapData = getRoadmapData(); + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", `${roadmapData.settings.title?.trim() ?? getDefaultFilename()}.learningmap`); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }, [getRoadmapData]); + + const handleShare = useCallback(() => { + const roadmapData = getRoadmapData(); + + if (!roadmapData.nodes || roadmapData.nodes.length === 0) { + alert(t.emptyMapCannotBeShared); + return; + } + + fetch(`${jsonStore}/api/v2/post`, { + method: "POST", + mode: "cors", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(roadmapData), + }) + .then((r) => r.json()) + .then((json) => { + const link = window.location.origin + window.location.pathname + "#json=" + json.id; + setShareLink(link); + setShareDialogOpen(true); + }) + .catch(() => { + alert(t.uploadFailed); + }); + }, [getRoadmapData, jsonStore, t, setShareLink, setShareDialogOpen]); + + const loadFromJsonStore = useCallback((id: string) => { + fetch(`${jsonStore}/api/v2/${id}`, { + method: "GET", + mode: "cors", + }) + .then((r) => r.text()) + .then((text) => { + const json = JSON.parse(text); + loadRoadmapData(json); + setLoadExternalDialogOpen(false); + setPendingExternalId(null); + }) + .catch(() => { + alert(t.loadFailed); + }); + }, [jsonStore, t, loadRoadmapData, setLoadExternalDialogOpen, setPendingExternalId]); + + const handleLoadExternal = useCallback((id: string) => { + setPendingExternalId(id); + setLoadExternalDialogOpen(true); + }, [setPendingExternalId, setLoadExternalDialogOpen]); + + const handleOpen = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.learningmap,application/json'; + input.onchange = (e: any) => { + const file = e.target.files[0]; + if (!file) return; + + if (!window.confirm(t.openFileWarning)) { + return; + } + + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const content = evt.target?.result; + if (typeof content === 'string') { + const json = JSON.parse(content); + loadRoadmapData(json); + } + } catch (err) { + alert(t.failedToLoadFile); + } + }; + reader.readAsText(file); + }; + input.click(); + }, [loadRoadmapData, t]); + + const handleOpenSettingsDrawer = useCallback(() => setSettingsDrawerOpen(true), [setSettingsDrawerOpen]); + + const handleCut = useCallback(() => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + const selectedNodeIdSet = new Set(selectedNodeIds); + const relatedEdges = edges.filter(e => + selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) + ); + setClipboard({ nodes: selectedNodes, edges: relatedEdges }); + selectedNodeIds.forEach(id => deleteNode(id)); + setSelectedNodeIds([]); + } + }, [nodes, edges, selectedNodeIds, deleteNode, setSelectedNodeIds, setClipboard]); + + const handleCopy = useCallback(() => { + const selectedNodes = nodes.filter(n => selectedNodeIds.includes(n.id)); + if (selectedNodes.length > 0) { + const selectedNodeIdSet = new Set(selectedNodeIds); + const relatedEdges = edges.filter(e => + selectedNodeIdSet.has(e.source) && selectedNodeIdSet.has(e.target) + ); + setClipboard({ nodes: selectedNodes, edges: relatedEdges }); + } + }, [nodes, edges, selectedNodeIds, setClipboard]); + + const handlePaste = useCallback(() => { + if (!clipboard) return; + + const idMapping: Record = {}; + let newNextNodeId = nextNodeId; + + const newNodes = clipboard.nodes.map(node => { + const newId = node.id.startsWith('background-node') + ? `background-node${newNextNodeId}` + : `node${newNextNodeId}`; + idMapping[node.id] = newId; + newNextNodeId++; + + return { + ...node, + id: newId, + position: { + x: node.position.x + 50, + y: node.position.y + 50, + }, + }; + }); + + const newEdges = clipboard.edges.map((edge, idx) => ({ + ...edge, + id: `e${Date.now()}-${idx}`, + source: idMapping[edge.source] || edge.source, + target: idMapping[edge.target] || edge.target, + })); + + newNodes.forEach(node => addNode(node)); + setEdges([...edges, ...newEdges]); + setNextNodeId(newNextNodeId); + setSelectedNodeIds(newNodes.map(n => n.id)); + }, [clipboard, nextNodeId, edges, addNode, setEdges, setNextNodeId, setSelectedNodeIds]); + + const handleZoomIn = useCallback(() => { + zoomIn(); + }, [zoomIn]); + + const handleZoomOut = useCallback(() => { + zoomOut(); + }, [zoomOut]); + + const handleResetZoom = useCallback(() => { + setCenter(0, 0, { zoom: 1, duration: 300 }); + }, [setCenter]); + + const handleFitView = useCallback(() => { + fitView({ duration: 300 }); + }, [fitView]); + + const handleZoomToSelection = useCallback(() => { + if (selectedNodeIds.length > 0) { + fitView({ nodes: selectedNodeIds.map(s => ({ id: s })), duration: 300, padding: 0.2 }); + } + }, [selectedNodeIds, fitView]); + + const handleToggleGrid = useCallback(() => { + setShowGrid(!showGrid); + }, [showGrid, setShowGrid]); + + const handleResetMap = useCallback(() => { + if (confirm(t.resetMapWarning)) { + setNodes([]); + setEdges([]); + setNextNodeId(1); + } + }, [setNodes, setEdges, setNextNodeId, t]); + + const handleSelectAll = useCallback(() => { + setSelectedNodeIds(nodes.map(n => n.id)); + }, [nodes, setSelectedNodeIds]); + + const handleDeleteSelected = useCallback(() => { + if (selectedEdge) { + handleDeleteEdge(); + } else if (selectedNodeId) { + handleDeleteNode(); + } + }, [selectedEdge, selectedNodeId, handleDeleteEdge, handleDeleteNode]); + + return { + handleNodeClick, + handleEdgeClick, + closeDrawer, + handleUpdateNode, + handleUpdateNodes, + handleUpdateEdge, + handleDeleteEdge, + handleDeleteNode, + addNewNode, + handleSave, + togglePreviewMode, + toggleDebugMode, + handleDownload, + handleShare, + loadFromJsonStore, + handleLoadExternal, + handleOpen, + handleOpenSettingsDrawer, + handleCut, + handleCopy, + handlePaste, + handleZoomIn, + handleZoomOut, + handleResetZoom, + handleFitView, + handleZoomToSelection, + handleToggleGrid, + handleResetMap, + handleSelectAll, + handleDeleteSelected, + }; +}; + +const getDefaultFilename = () => { + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; +}; diff --git a/packages/learningmap/src/useUndoable/errors.ts b/packages/learningmap/src/useUndoable/errors.ts deleted file mode 100644 index 6cb0102..0000000 --- a/packages/learningmap/src/useUndoable/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const payloadError = (func: string) => { - return new Error(`NoPayloadError: ${func} requires a payload.`); -}; - -export const invalidBehaviorError = (behavior: string) => { - return new Error( - `Mutation behavior must be one of: mergePastReversed, mergePast, keepFuture, or destroyFuture. Not: ${behavior}`, - ); -}; diff --git a/packages/learningmap/src/useUndoable/index.ts b/packages/learningmap/src/useUndoable/index.ts deleted file mode 100644 index 228a67b..0000000 --- a/packages/learningmap/src/useUndoable/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useReducer, useCallback } from "react"; - -import { reducer } from "./reducer"; - -import type { - Action, - MutationBehavior, - Options, - State, - UseUndoable, -} from "./types"; - -const initialState = { - past: [], - present: null, - future: [], -}; - -const defaultOptions: Options = { - behavior: "mergePastReversed", - historyLimit: 100, - ignoreIdenticalMutations: true, - cloneState: false, -}; - -const compileMutateOptions = (options: Options) => ({ - ...defaultOptions, - ...options, -}); - -const useUndoable = ( - initialPresent: T, - options: Options = defaultOptions, -): UseUndoable => { - const [state, dispatch] = useReducer, [Action]>(reducer, { - ...initialState, - present: initialPresent, - }); - - const canUndo = state.past.length !== 0; - const canRedo = state.future.length !== 0; - - const undo = useCallback(() => { - if (canUndo) { - dispatch({ type: "undo" }); - } - }, [canUndo]); - - const redo = useCallback(() => { - if (canRedo) { - dispatch({ type: "redo" }); - } - }, [canRedo]); - - const reset = useCallback( - (payload = initialPresent) => dispatch({ type: "reset", payload }), - [], - ); - const resetInitialState = useCallback( - (payload: T) => dispatch({ type: "resetInitialState", payload }), - [], - ); - - const update = useCallback( - (payload: T, mutationBehavior: MutationBehavior, ignoreAction: boolean) => - dispatch({ - type: "update", - payload, - behavior: mutationBehavior, - ignoreAction, - ...compileMutateOptions(options), - }), - [], - ); - - // We can ignore the undefined type error here because - // we are setting a default value to options. - const setState = useCallback( - ( - payload: any, - - // @ts-ignore - mutationBehavior: MutationBehavior = options.behavior, - ignoreAction: boolean = false, - ) => { - return update(payload, mutationBehavior, ignoreAction); - }, - [state], - ); - - // In some rare cases, the fact that the above setState - // function changes on every render can be problematic. - // Since we can't really avoid this (setState uses - // state.present), we must export another function that - // doesn't depend on the present state (and thus doesn't - // need to change). - const static_setState = ( - payload: any, - - // @ts-ignore - mutationBehavior: MutationBehavior = options.behavior, - ignoreAction: boolean = false, - ) => { - update(payload, mutationBehavior, ignoreAction); - }; - - return [ - state.present, - setState, - { - past: state.past, - future: state.future, - - undo, - canUndo, - redo, - canRedo, - - reset, - resetInitialState, - static_setState, - }, - ]; -}; - -export default useUndoable; diff --git a/packages/learningmap/src/useUndoable/mutate.ts b/packages/learningmap/src/useUndoable/mutate.ts deleted file mode 100644 index b5b9b85..0000000 --- a/packages/learningmap/src/useUndoable/mutate.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { payloadError, invalidBehaviorError } from "./errors"; - -import type { Action, State } from "./types"; - -const ensureLimit = (limit: number | undefined, arr: any[]) => { - // Ensures that the `past` array doesn't exceed - // the specified `limit` amount. This is referred - // to as the `historyLimit` within the public API. - - // The conditional check in the `mutate` function - // might pass a potentially `undefined` value, - // therefore we check if it's valid here. - if (!limit) return arr; - - let n = [...arr]; - - if (n.length <= limit) return arr; - - const exceedsBy = n.length - limit; - - if (exceedsBy === 1) { - // This isn't faster than splice, but it works; - // therefore, we're leaving it. - // https://www.measurethat.net/Benchmarks/Show/3454/0/slice-vs-splice-vs-shift-who-is-the-fastest-to-keep-con - n.shift(); - } else { - // This shouldn't ever happen, I think. - n.splice(0, exceedsBy); - } - - return n; -}; - -const mutate = (state: State, action: Action): State => { - const { past, present, future } = state; - const { - payload, - behavior, - historyLimit, - ignoreIdenticalMutations, - cloneState, - ignoreAction, - } = action; - - if (!payload || payload === undefined) { - // A mutation call requires a payload. - // I guess we _could_ simply set the state - // to `undefined` with an empty payload, - // but this would probably be considered - // unexpected behavior. - // - // If you want to set the state to `undefined`, - // pass that explicitly. - throw payloadError("mutate"); - } - - if (ignoreAction) { - return { - past, - present: payload, - future, - }; - } - - let mPast = [...past]; - - if (historyLimit !== "infinium" && historyLimit !== "infinity") { - mPast = ensureLimit(historyLimit, past); - } - - const isEqual = JSON.stringify(payload) === JSON.stringify(present); - - if (ignoreIdenticalMutations && isEqual) { - return cloneState ? { ...state } : state; - } - - // We need to clone the array here because - // calling `future.reverse()` will mutate the - // existing array, causing the `mergePast` and - // `mergePastReversed` behaviors to work the same - // way. - const futureClone = [...future]; - - const behaviorMap = { - mergePastReversed: { - past: [...mPast, ...futureClone.reverse(), present], - present: payload, - future: [], - }, - mergePast: { - past: [...mPast, ...future, present], - present: payload, - future: [], - }, - destroyFuture: { - past: [...mPast, present], - present: payload, - future: [], - }, - keepFuture: { - past: [...mPast, present], - present: payload, - future, - }, - }; - - // Defaults should handle this case; mostly to make TS happy - if (typeof behavior === "undefined") { - return behaviorMap.mergePastReversed; - } - - if (!behaviorMap.hasOwnProperty(behavior)) - throw invalidBehaviorError(behavior); - return behaviorMap[behavior]; -}; - -export { mutate }; diff --git a/packages/learningmap/src/useUndoable/reducer.ts b/packages/learningmap/src/useUndoable/reducer.ts deleted file mode 100644 index 8bfeed0..0000000 --- a/packages/learningmap/src/useUndoable/reducer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mutate } from "./mutate"; -import { payloadError } from "./errors"; - -import type { Action, State } from "./types"; - -export const reducer = (state: State, action: Action): State => { - const { past, present, future } = state; - - const undo = (): State => { - if (past.length === 0) { - return state; - } - - const previous = past[past.length - 1]; - const newPast = past.slice(0, past.length - 1); - - return { - past: newPast, - present: previous, - future: [present, ...future], - }; - }; - - const redo = (): State => { - if (future.length === 0) { - return state; - } - - const next = future[0]; - const newFuture = future.slice(1); - - return { - past: [...past, present], - present: next, - future: newFuture, - }; - }; - - // Transform functional updater to raw value by applying it - const transform = (action: Action) => { - action.payload = - typeof action.payload === "function" - ? action.payload(present) - : action.payload; - - return action; - }; - - const update = (): State => mutate(state, transform(action)); - - const reset = (): State => { - const { payload } = action; - - return { - past: [], - present: payload || state.present, - future: [], - }; - }; - - const resetInitialState = (): State => { - const { payload } = action; - - if (!payload) { - throw payloadError("resetInitialState"); - } - - // Duplicate the past for mutation - let mPast = [...past]; - mPast[0] = payload; - - return { - past: [...mPast], - present, - future: [...future], - }; - }; - - const actions = { - undo, - redo, - update, - reset, - resetInitialState, - }; - - return actions[action.type](); -}; diff --git a/packages/learningmap/src/useUndoable/types.ts b/packages/learningmap/src/useUndoable/types.ts deleted file mode 100644 index 5fd95b0..0000000 --- a/packages/learningmap/src/useUndoable/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -export type ActionType = - | "undo" - | "redo" - | "update" - | "reset" - | "resetInitialState"; - -export type HistoryLimit = number | "infinium" | "infinity"; - -export type MutationBehavior = - | "mergePastReversed" - | "mergePast" - | "destroyFuture" - | "keepFuture"; - -export interface Action { - type: ActionType; - payload?: T; - behavior?: MutationBehavior; - historyLimit?: HistoryLimit; - ignoreIdenticalMutations?: boolean; - cloneState?: boolean; - ignoreAction?: boolean; -} - -export interface State { - past: T[]; - present: T; - future: T[]; -} - -export interface Options { - behavior?: MutationBehavior; - historyLimit?: HistoryLimit; - ignoreIdenticalMutations?: boolean; - cloneState?: boolean; -} - -export type UseUndoable = [ - T, - ( - payload: T | ((oldValue: T) => T), - behavior?: MutationBehavior, - ignoreAction?: boolean, - ) => void, - { - past: T[]; - future: T[]; - - undo: () => void; - canUndo: boolean; - redo: () => void; - canRedo: boolean; - - reset: (initialState?: T) => void; - resetInitialState: (newInitialState: T) => void; - static_setState: ( - payload: T, - behavior?: MutationBehavior, - ignoreAction?: boolean, - ) => void; - }, -]; diff --git a/packages/learningmap/src/viewerStore.ts b/packages/learningmap/src/viewerStore.ts new file mode 100644 index 0000000..d42858d --- /dev/null +++ b/packages/learningmap/src/viewerStore.ts @@ -0,0 +1,215 @@ +import { create } from 'zustand'; +import { Node, Edge, applyNodeChanges, NodeChange } from '@xyflow/react'; +import { NodeData, RoadmapData, RoadmapState, Settings } from './types'; + +// Note: This is a global store. If you need multiple independent LearningMap instances +// on the same page, consider creating store instances per component or using context. +export interface ViewerState { + // Core data + nodes: Node[]; + edges: Edge[]; + settings: Settings; + + // UI state + selectedNode: Node | null; + drawerOpen: boolean; + + // Actions + onNodesChange: (changes: NodeChange>[]) => void; + setNodes: (nodes: Node[]) => void; + setEdges: (edges: Edge[]) => void; + setSettings: (settings: Settings) => void; + updateNodeState: (nodeId: string, state: string) => void; + setSelectedNode: (node: Node | null) => void; + setDrawerOpen: (drawerOpen: boolean) => void; + + // Bulk operations + loadRoadmapData: (roadmapData: RoadmapData, initialState?: RoadmapState) => void; + getRoadmapState: (viewport: { x: number; y: number; zoom: number }) => RoadmapState; + updateNodesStates: () => void; +} + +const getStateMap = (nodes: Node[]) => { + const stateMap: Record = {}; + nodes.forEach((n) => { + if (n.data?.state) { + stateMap[n.id] = n.data.state; + } + }); + return stateMap; +}; + +const isCompleteState = (state: string) => + state === 'completed' || state === 'mastered'; + +const isInteractableNode = (node: Node) => { + return node.type === 'task' || node.type === 'topic'; +}; + +const calculateNodesStates = (nodes: Node[]) => { + const updatedNodes = [...nodes]; + + // Run twice to ensure all dependencies are resolved + for (let i = 0; i < 2; i++) { + const stateMap = getStateMap(updatedNodes); + + for (const node of updatedNodes) { + node.data.state = node.data?.state || 'locked'; + + // Check unlock conditions + if (node.data?.unlock?.after) { + const unlocked = node.data.unlock.after.every((depId: string) => + isCompleteState(stateMap[depId]) + ); + if (unlocked) { + if (node.data.state === 'locked') { + node.data.state = 'unlocked'; + } + } else { + node.data.state = 'locked'; + } + } + + if (node.data?.unlock?.date) { + const unlockDate = new Date(node.data.unlock.date); + const now = new Date(); + if (now >= unlockDate) { + if (node.data.state === 'locked') { + node.data.state = 'unlocked'; + } + } else { + node.data.state = 'locked'; + } + } + + if (!node.data?.unlock?.after && !node.data?.unlock?.date) { + if (node.data.state === 'locked') { + node.data.state = 'unlocked'; + } + } + + // Handle topic completion + if (node.type !== 'topic') continue; + + if (node.data?.completion?.needs) { + const noNeeds = node.data.completion.needs.every((need: string) => + isCompleteState(stateMap[need]) + ); + if (node.data.state === 'unlocked' && noNeeds) { + node.data.state = 'completed'; + } + } else if (!node.data?.completion?.needs && node.data.state === 'unlocked') { + node.data.state = 'completed'; + } + + if (node.data?.completion?.optional) { + const noOptional = node.data.completion.optional.every((opt: string) => + isCompleteState(stateMap[opt]) + ); + if (node.data.state === 'completed' && noOptional) { + node.data.state = 'mastered'; + } + } else if (!node.data?.completion?.optional && node.data.state === 'completed') { + node.data.state = 'mastered'; + } + } + } + + return updatedNodes; +}; + +export const useViewerStore = create()((set, get) => ({ + // Initial state + nodes: [], + edges: [], + settings: {}, + selectedNode: null, + drawerOpen: false, + + // ReactFlow handlers + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes), + }); + }, + + // Basic setters + setNodes: (nodes) => { + set({ nodes }); + }, + + setEdges: (edges) => { + set({ edges }); + }, + + setSettings: (settings) => { + set({ settings }); + }, + + updateNodeState: (nodeId, state) => { + const updatedNodes = get().nodes.map((n) => + n.id === nodeId ? { ...n, data: { ...n.data, state } } : n + ); + + // Recalculate all node states + const recalculatedNodes = calculateNodesStates(updatedNodes); + set({ nodes: recalculatedNodes }); + }, + + setSelectedNode: (selectedNode) => { + set({ selectedNode }); + }, + + setDrawerOpen: (drawerOpen) => { + set({ drawerOpen }); + }, + + // Bulk operations + loadRoadmapData: (roadmapData, initialState) => { + const nodesArr = Array.isArray(roadmapData?.nodes) ? roadmapData.nodes : []; + const edgesArr = Array.isArray(roadmapData?.edges) ? roadmapData.edges : []; + + let rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: false, + connectable: false, + selectable: isInteractableNode(n), + focusable: isInteractableNode(n), + data: { + ...n.data, + state: initialState?.nodes?.[n.id]?.state || n.data?.state, + }, + })); + + // Calculate node states + rawNodes = calculateNodesStates(rawNodes); + + set({ + nodes: rawNodes, + edges: edgesArr, + settings: roadmapData?.settings || {}, + }); + }, + + getRoadmapState: (viewport) => { + const minimalState: RoadmapState = { + nodes: {}, + x: viewport.x, + y: viewport.y, + zoom: viewport.zoom, + }; + + get().nodes.forEach((n) => { + if (n.data.state && n.type === 'task') { + minimalState.nodes[n.id] = { state: n.data.state }; + } + }); + + return minimalState; + }, + + updateNodesStates: () => { + const updatedNodes = calculateNodesStates(get().nodes); + set({ nodes: updatedNodes }); + }, +})); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index b0e6902..a3d859a 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -1,34 +1,11 @@ import './App.css' -import { LearningMapEditor, type LearningMapEditorProps } from '@learningmap/learningmap'; +import { LearningMapEditor } from '@learningmap/learningmap'; import "@learningmap/learningmap/index.css"; -import { useEffect, useState } from 'react'; function App() { - const [roadmapData, setRoadmapData] = useState(undefined); - - useEffect(() => { - // Don't load from localStorage if loading from external source - const hash = window.location.hash; - if (hash.startsWith("#json=")) { - return; - } - - const savedState = localStorage.getItem("learningmap-editor-state"); - if (savedState) { - const state = JSON.parse(savedState); - setRoadmapData(state); - } - }, []); - - const handleChange: LearningMapEditorProps["onChange"] = (state) => { - localStorage.setItem("learningmap-editor-state", JSON.stringify(state)); - } - return ( - ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a62e0d..d5068d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: elkjs: specifier: ^0.11.0 version: 0.11.0 + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 html-to-image: specifier: 1.11.13 version: 1.11.13 @@ -80,9 +83,18 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + throttle-debounce: + specifier: ^5.0.2 + version: 5.0.2 tslib: specifier: ^2.8.1 version: 2.8.1 + zundo: + specifier: ^2.3.0 + version: 2.3.0(zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))) + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) devDependencies: '@types/react': specifier: ^19.2.2 @@ -90,6 +102,9 @@ importers: '@types/react-dom': specifier: ^19.2.1 version: 19.2.1(@types/react@19.2.2) + '@types/throttle-debounce': + specifier: ^5.0.2 + version: 5.0.2 vitest: specifier: ^3.0.5 version: 3.2.4(@types/node@24.7.2)(terser@5.44.0) @@ -833,6 +848,9 @@ packages: '@types/react@19.2.2': resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/throttle-debounce@5.0.2': + resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==} + '@typescript-eslint/eslint-plugin@8.46.0': resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2035,6 +2053,10 @@ packages: engines: {node: '>=10'} hasBin: true + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2270,6 +2292,11 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -2285,6 +2312,24 @@ packages: react: optional: true + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@babel/code-frame@7.27.1': @@ -2965,6 +3010,8 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/throttle-debounce@5.0.2': {} + '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0)(typescript@5.9.3))(eslint@9.37.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -4168,6 +4215,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + throttle-debounce@5.0.2: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4408,9 +4457,19 @@ snapshots: zod@4.1.12: {} + zundo@2.3.0(zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))): + dependencies: + zustand: 5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + zustand@4.5.7(@types/react@19.2.2)(react@19.2.0): dependencies: use-sync-external-store: 1.6.0(react@19.2.0) optionalDependencies: '@types/react': 19.2.2 react: 19.2.0 + + zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0)