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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/learningmap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 38 additions & 6 deletions packages/learningmap/src/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -57,7 +60,14 @@ function getCompletionOptional(node: Node<NodeData>, nodes: Node<NodeData>[]): 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;
Expand Down Expand Up @@ -95,17 +105,39 @@ export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, lang
</button>
</header>
<div className="drawer-content">
{node.data?.description && <div className="drawer-description" style={{ marginBottom: 16 }}>{node.data?.description}</div>}
{node.data?.description && <div className="drawer-description" style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: descriptionHtml }} />}
{node.data?.video && <div className="drawer-video" style={{ marginBottom: 16 }}>
<Video url={node.data?.video} />
</div>}
{node.data?.resources && node.data?.resources.length > 0 && (
<div className="drawer-resources" style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t.resourcesLabel}</div>
<ul>
{node.data?.resources.map((r: any) => (
<li key={r.url}><a href={r.url} target="_blank" rel="noopener noreferrer">{r.label}</a></li>
))}
{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 (
<li key={idx}>
πŸ“š <strong>{r.label}</strong>{detailsText}
</li>
);
}
return (
<li key={idx}>
🌐 {r.url ? (
<a href={r.url} target="_blank" rel="noopener noreferrer">{r.label}</a>
) : (
<span>{r.label}</span>
)}
</li>
);
})}
</ul>
</div>
)}
Expand Down
6 changes: 4 additions & 2 deletions packages/learningmap/src/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 (
Expand Down Expand Up @@ -133,6 +134,7 @@ export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps)
nodesDraggable={true}
elevateNodesOnSelect={false}
nodesConnectable={true}
selectNodesOnDrag={false}
colorMode="light"
>
{showGrid && <Background />}
Expand Down
31 changes: 29 additions & 2 deletions packages/learningmap/src/EditorDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,38 @@ export const EditorDrawer: React.FC<EditorDrawerProps> = ({
// 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<string>();
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) => (
<select value={value} onChange={e => onChange(e.target.value)}>
<option value="">{t.selectNode}</option>
{nodeOptions.map(n => (
{sortedNodeOptions.map(n => (
<option key={n.id} value={n.id}>
{n.data.label || n.id}
</option>
Expand Down Expand Up @@ -149,7 +176,7 @@ export const EditorDrawer: React.FC<EditorDrawerProps> = ({

const addResource = () => {
if (!localNode) return;
const resources = [...(localNode.data.resources || []), { label: "", url: "" }];
const resources = [...(localNode.data.resources || []), { label: "", type: "url", url: "" }];
handleFieldChange("resources", resources);
};

Expand Down
87 changes: 65 additions & 22 deletions packages/learningmap/src/EditorDrawerTaskContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Node } from "@xyflow/react";
import { Plus, Trash2 } from "lucide-react";
import { NodeData } from "./types";
import { NodeData, Resource } from "./types";
import { getTranslations } from "./translations";

interface Props {
Expand Down Expand Up @@ -93,6 +93,17 @@ export function EditorDrawerTaskContent({
placeholder={t.placeholderNodeLabel}
/>
</div>
<div className="form-group">
<label>Font Size (px)</label>
<input
type="number"
value={localNode.data.fontSize || 14}
onChange={(e) => handleFieldChange("fontSize", parseInt(e.target.value) || 14)}
placeholder="14"
min="8"
max="72"
/>
</div>
<div className="form-group">
<label>{t.summary}</label>
<input
Expand Down Expand Up @@ -131,27 +142,59 @@ export function EditorDrawerTaskContent({
</div>
<div className="form-group">
<label>{t.resources}</label>
{(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => (
<div key={idx} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
<input
type="text"
value={resource.label || ""}
onChange={(e) => handleResourceChange(idx, "label", e.target.value)}
placeholder={t.placeholderLabel}
style={{ flex: 1 }}
/>
<input
type="text"
value={resource.url || ""}
onChange={(e) => handleResourceChange(idx, "url", e.target.value)}
placeholder={t.placeholderURL}
style={{ flex: 2 }}
/>
<button onClick={() => removeResource(idx)} className="icon-button">
<Trash2 size={16} />
</button>
</div>
))}
{(localNode.data.resources || []).map((resource: Resource, idx: number) => {
const isBook = resource.type === "book";
return (
<div key={idx} style={{ marginBottom: "16px", padding: "12px", border: "1px solid #e5e7eb", borderRadius: "6px" }}>
<div style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
<select
value={resource.type || "url"}
onChange={(e) => handleResourceChange(idx, "type", e.target.value)}
style={{ flex: 0.5 }}
>
<option value="url">URL</option>
<option value="book">Book</option>
</select>
<input
type="text"
value={resource.label || ""}
onChange={(e) => handleResourceChange(idx, "label", e.target.value)}
placeholder={t.placeholderLabel}
style={{ flex: 1 }}
/>
<button onClick={() => removeResource(idx)} className="icon-button">
<Trash2 size={16} />
</button>
</div>
{isBook ? (
<>
<input
type="text"
value={resource.bookName || ""}
onChange={(e) => handleResourceChange(idx, "bookName", e.target.value)}
placeholder="Book name (e.g., Lambacher Schweitzer GK)"
style={{ width: "100%", marginBottom: "8px" }}
/>
<input
type="text"
value={resource.bookLocation || ""}
onChange={(e) => handleResourceChange(idx, "bookLocation", e.target.value)}
placeholder="Location (e.g., S. 223 Nr. 5)"
style={{ width: "100%" }}
/>
</>
) : (
<input
type="text"
value={resource.url || ""}
onChange={(e) => handleResourceChange(idx, "url", e.target.value)}
placeholder={t.placeholderURL}
style={{ width: "100%" }}
/>
)}
</div>
);
})}
<button onClick={addResource} className="secondary-button">
<Plus size={16} /> {t.addResource}
</button>
Expand Down
13 changes: 13 additions & 0 deletions packages/learningmap/src/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 => {
Expand Down
51 changes: 51 additions & 0 deletions packages/learningmap/src/SettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
// 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);
Expand Down Expand Up @@ -57,6 +59,22 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
}));
};

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 (
<>
<div className="drawer-overlay" onClick={onClose} />
Expand Down Expand Up @@ -174,6 +192,39 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
{t.useCurrentViewport}
</button>
</div>

<div className="form-group">
<label>Default Edge Type</label>
<select
value={localSettings?.defaultEdgeType || "default"}
onChange={(e) => setLocalSettings(settings => ({ ...settings, defaultEdgeType: e.target.value }))}
>
<option value="default">Default</option>
<option value="straight">Straight</option>
<option value="step">Step</option>
<option value="smoothstep">Smooth Step</option>
<option value="simplebezier">Simple Bezier</option>
</select>
</div>

<div className="form-group">
<ColorSelector
label="Default Edge Color"
value={localSettings?.defaultEdgeColor || "#94a3b8"}
onChange={color => setLocalSettings(settings => ({ ...settings, defaultEdgeColor: color }))}
/>
</div>

<div className="form-group">
<button
onClick={handleUpdateAllEdges}
className="secondary-button"
style={{ width: '100%' }}
type="button"
>
Update All Edges to Default Settings
</button>
</div>
</div>

<div className="drawer-footer">
Expand Down
Loading