diff --git a/packages/learningmap/package.json b/packages/learningmap/package.json index 96e58ef..b0314ef 100644 --- a/packages/learningmap/package.json +++ b/packages/learningmap/package.json @@ -34,11 +34,14 @@ }, "dependencies": { "@szhsin/react-menu": "^4.5.0", + "@types/dompurify": "^3.2.0", "@xyflow/react": "^12.8.6", + "dompurify": "^3.3.0", "elkjs": "^0.11.0", "fast-deep-equal": "^3.1.3", "html-to-image": "1.11.13", "lucide-react": "^0.545.0", + "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "throttle-debounce": "^5.0.2", diff --git a/packages/learningmap/src/Drawer.tsx b/packages/learningmap/src/Drawer.tsx index 204deed..ec0891f 100644 --- a/packages/learningmap/src/Drawer.tsx +++ b/packages/learningmap/src/Drawer.tsx @@ -1,9 +1,12 @@ import { Node } from "@xyflow/react"; -import { NodeData } from "./types"; +import { NodeData, Resource } from "./types"; import { X, Lock, CheckCircle } from "lucide-react"; import { Video } from "./Video"; import StarCircle from "./icons/StarCircle"; import { getTranslations } from "./translations"; +import { marked } from "marked"; +import { useMemo } from "react"; +import DOMPurify from "dompurify"; interface DrawerProps { open: boolean; @@ -57,7 +60,14 @@ function getCompletionOptional(node: Node, nodes: Node[]): N export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, language = "en" }: DrawerProps) { const t = getTranslations(language); - if (!open) return null; + // Parse markdown description and sanitize HTML + const descriptionHtml = useMemo(() => { + if (!node || !node.data?.description) return ''; + const rawHtml = marked.parse(node.data.description, { async: false }); + return DOMPurify.sanitize(rawHtml); + }, [node, node?.data?.description]); + + if (!open || !node) return null; const locked = node.data?.state === 'locked' || false; const unlocked = node.data?.state === 'unlocked' || false; @@ -95,7 +105,7 @@ export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, lang
- {node.data?.description &&
{node.data?.description}
} + {node.data?.description &&
} {node.data?.video &&
} @@ -103,9 +113,31 @@ export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, lang
{t.resourcesLabel}
    - {node.data?.resources.map((r: any) => ( -
  • {r.label}
  • - ))} + {node.data?.resources.map((r: Resource, idx: number) => { + if (r.type === "book") { + // Format: 📚 Label (Name, Location) + // If name is empty, no comma should be visible + const bookDetails = []; + if (r.bookName) bookDetails.push(r.bookName); + if (r.bookLocation) bookDetails.push(r.bookLocation); + const detailsText = bookDetails.length > 0 ? ` (${bookDetails.join(', ')})` : ''; + + return ( +
  • + 📚 {r.label}{detailsText} +
  • + ); + } + return ( +
  • + 🌐 {r.url ? ( + {r.label} + ) : ( + {r.label} + )} +
  • + ); + })}
)} diff --git a/packages/learningmap/src/EditorCanvas.tsx b/packages/learningmap/src/EditorCanvas.tsx index f459a3a..c85807a 100644 --- a/packages/learningmap/src/EditorCanvas.tsx +++ b/packages/learningmap/src/EditorCanvas.tsx @@ -87,6 +87,7 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps) const handleSelectionChange: OnSelectionChangeFunc = useCallback( ({ nodes: selectedNodes }) => { + // Only select nodes, not edges (as per requirement #6) setSelectedNodeIds(selectedNodes.map(n => n.id)); }, [setSelectedNodeIds] @@ -101,10 +102,10 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps) const defaultEdgeOptions = { animated: false, style: { - stroke: "#94a3b8", + stroke: settings?.defaultEdgeColor || "#94a3b8", strokeWidth: 2, }, - type: "default", + type: settings?.defaultEdgeType || "default", }; return ( @@ -133,6 +134,7 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps) nodesDraggable={true} elevateNodesOnSelect={false} nodesConnectable={true} + selectNodesOnDrag={false} colorMode="light" > {showGrid && } diff --git a/packages/learningmap/src/EditorDrawer.tsx b/packages/learningmap/src/EditorDrawer.tsx index 92d506d..660fbe7 100644 --- a/packages/learningmap/src/EditorDrawer.tsx +++ b/packages/learningmap/src/EditorDrawer.tsx @@ -60,11 +60,38 @@ export const EditorDrawer: React.FC = ({ // Filter out the current node from selectable options const nodeOptions = nodes.filter(n => n.id !== node.id && (n.type === "task" || n.type === "topic")); + // Get edges connected to this node + const edges = useEditorStore.getState().edges; + const connectedNodeIds = new Set(); + edges.forEach(edge => { + if (edge.source === node.id) { + connectedNodeIds.add(edge.target); + } + if (edge.target === node.id) { + connectedNodeIds.add(edge.source); + } + }); + + // Sort node options: connected nodes first, then alphabetically by label + const sortedNodeOptions = [...nodeOptions].sort((a, b) => { + const aConnected = connectedNodeIds.has(a.id); + const bConnected = connectedNodeIds.has(b.id); + + // Connected nodes come first + if (aConnected && !bConnected) return -1; + if (!aConnected && bConnected) return 1; + + // Otherwise sort alphabetically by label + const aLabel = (a.data.label || a.id).toLowerCase(); + const bLabel = (b.data.label || b.id).toLowerCase(); + return aLabel.localeCompare(bLabel); + }); + // Helper for dropdowns const renderNodeSelect = (value: string, onChange: (id: string) => void) => ( handleFieldChange("fontSize", parseInt(e.target.value) || 14)} + placeholder="14" + min="8" + max="72" + /> +
- {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( -
- handleResourceChange(idx, "label", e.target.value)} - placeholder={t.placeholderLabel} - style={{ flex: 1 }} - /> - handleResourceChange(idx, "url", e.target.value)} - placeholder={t.placeholderURL} - style={{ flex: 2 }} - /> - -
- ))} + {(localNode.data.resources || []).map((resource: Resource, idx: number) => { + const isBook = resource.type === "book"; + return ( +
+
+ + handleResourceChange(idx, "label", e.target.value)} + placeholder={t.placeholderLabel} + style={{ flex: 1 }} + /> + +
+ {isBook ? ( + <> + handleResourceChange(idx, "bookName", e.target.value)} + placeholder="Book name (e.g., Lambacher Schweitzer GK)" + style={{ width: "100%", marginBottom: "8px" }} + /> + handleResourceChange(idx, "bookLocation", e.target.value)} + placeholder="Location (e.g., S. 223 Nr. 5)" + style={{ width: "100%" }} + /> + + ) : ( + handleResourceChange(idx, "url", e.target.value)} + placeholder={t.placeholderURL} + style={{ width: "100%" }} + /> + )} +
+ ); + })} diff --git a/packages/learningmap/src/KeyboardShortcuts.tsx b/packages/learningmap/src/KeyboardShortcuts.tsx index 2621f8b..9a7d41e 100644 --- a/packages/learningmap/src/KeyboardShortcuts.tsx +++ b/packages/learningmap/src/KeyboardShortcuts.tsx @@ -15,6 +15,7 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }: // Get store state const helpOpen = useEditorStore(state => state.helpOpen); const selectedNodeIds = useEditorStore(state => state.selectedNodeIds); + const selectedEdge = useEditorStore(state => state.selectedEdge); const nodes = useEditorStore(state => state.nodes); const lastMousePosition = useEditorStore(state => state.lastMousePosition); const settings = useEditorStore(state => state.settings); @@ -33,6 +34,9 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }: const showGrid = useEditorStore(state => state.showGrid); const setShowGrid = useEditorStore(state => state.setShowGrid); const deleteNode = useEditorStore(state => state.deleteNode); + const deleteEdge = useEditorStore(state => state.deleteEdge); + const setSelectedEdge = useEditorStore(state => state.setSelectedEdge); + const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen); const drawerOpen = useEditorStore(state => state.drawerOpen); const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen); const settingsDrawerOpen = useEditorStore(state => state.settingsDrawerOpen); @@ -61,6 +65,15 @@ export const KeyboardShortcuts = ({ jsonStore = "https://json.openpatch.org" }: }; const onDeleteSelected = () => { + // Delete selected edge if any + if (selectedEdge) { + deleteEdge(selectedEdge.id); + setSelectedEdge(null); + setEdgeDrawerOpen(false); + return; + } + + // Otherwise delete selected nodes if (selectedNodeIds.length > 0) { // Delete all selected nodes selectedNodeIds.forEach(nodeId => { diff --git a/packages/learningmap/src/SettingsDrawer.tsx b/packages/learningmap/src/SettingsDrawer.tsx index 0ee6bb4..90efab8 100644 --- a/packages/learningmap/src/SettingsDrawer.tsx +++ b/packages/learningmap/src/SettingsDrawer.tsx @@ -17,6 +17,8 @@ export const SettingsDrawer: React.FC = ({ // Get state from store const isOpen = useEditorStore(state => state.settingsDrawerOpen); const settings = useEditorStore(state => state.settings); + const edges = useEditorStore(state => state.edges); + const setEdges = useEditorStore(state => state.setEdges); // Get actions from store const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen); @@ -57,6 +59,22 @@ export const SettingsDrawer: React.FC = ({ })); }; + const handleUpdateAllEdges = () => { + const defaultType = localSettings?.defaultEdgeType || "default"; + const defaultColor = localSettings?.defaultEdgeColor || "#94a3b8"; + + const updatedEdges = edges.map(edge => ({ + ...edge, + type: defaultType, + style: { + ...edge.style, + stroke: defaultColor, + } + })); + + setEdges(updatedEdges); + }; + return ( <>
@@ -174,6 +192,39 @@ export const SettingsDrawer: React.FC = ({ {t.useCurrentViewport}
+ +
+ + +
+ +
+ setLocalSettings(settings => ({ ...settings, defaultEdgeColor: color }))} + /> +
+ +
+ +
diff --git a/packages/learningmap/src/index.css b/packages/learningmap/src/index.css index 3f3314a..21ce9b2 100644 --- a/packages/learningmap/src/index.css +++ b/packages/learningmap/src/index.css @@ -233,6 +233,79 @@ header.drawer-header { padding: 24px; } +.drawer-resources ul { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.drawer-description { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; +} + +.drawer-description h1, +.drawer-description h2, +.drawer-description h3, +.drawer-description h4, +.drawer-description h5, +.drawer-description h6 { + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; +} + +.drawer-description h1 { font-size: 1.5em; } +.drawer-description h2 { font-size: 1.25em; } +.drawer-description h3 { font-size: 1.1em; } + +.drawer-description p { + margin-bottom: 1em; +} + +.drawer-description ul, +.drawer-description ol { + margin-bottom: 1em; + padding-left: 2em; +} + +.drawer-description li { + margin-bottom: 0.25em; +} + +.drawer-description code { + background-color: #f3f4f6; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; + font-family: monospace; +} + +.drawer-description pre { + background-color: #f3f4f6; + padding: 1em; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1em; +} + +.drawer-description pre code { + background-color: transparent; + padding: 0; +} + +.drawer-description a { + color: var(--learningmap-color-openpatch); + text-decoration: underline; +} + +.drawer-description blockquote { + border-left: 4px solid #d1d5db; + padding-left: 1em; + margin-left: 0; + margin-bottom: 1em; + color: #6b7280; +} + .drawer-footer { padding: 24px; display: flex; @@ -312,7 +385,7 @@ header.drawer-header { .form-group textarea { resize: vertical; - font-family: inherit; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } /* Buttons */ @@ -462,8 +535,9 @@ header.drawer-header { border: 2px solid white; } -.react-flow__edge.selected { - outline: 1px solid var(--learningmap-color-openpatch); +.react-flow__edge.selected path { + stroke: var(--learningmap-color-openpatch) !important; + stroke-width: 3 !important; } .react-flow__node.selected { diff --git a/packages/learningmap/src/nodes/TaskNode.tsx b/packages/learningmap/src/nodes/TaskNode.tsx index 1960629..03f0577 100644 --- a/packages/learningmap/src/nodes/TaskNode.tsx +++ b/packages/learningmap/src/nodes/TaskNode.tsx @@ -7,16 +7,16 @@ export const TaskNode = ({ data, selected, isConnectable, ...props }: Node {isConnectable && } -
-
+
+
{data.label || "Untitled"}
+ {data.summary && ( +
+ {data.summary} +
+ )}
- {data.summary && ( -
- {data.summary} -
- )} {["Bottom", "Top", "Left", "Right"].map((pos) => ( ) => <> {isConnectable && } {data.state === "mastered" && } -
-
+
+
{data.label || "Untitled"}
+ {data.summary && ( +
+ {data.summary} +
+ )}
- {data.summary && ( -
- {data.summary} -
- )} {["Bottom", "Top", "Left", "Right"].map((pos) => ( =8'} + dompurify@3.3.0: + resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -2496,6 +2512,11 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + marked@16.4.1: + resolution: {integrity: sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4753,6 +4774,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.0 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -5355,6 +5380,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@8.6.0: {} dunder-proto@1.0.1: @@ -6087,6 +6116,8 @@ snapshots: make-error@1.3.6: {} + marked@16.4.1: {} + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {}