From 1faded87cd5f908a32e9525abd366be3e09b515b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:50:37 +0000 Subject: [PATCH 01/19] Initial plan From c1b2851178870a7831223570cc0b0192770c918f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:07:01 +0000 Subject: [PATCH 02/19] Create learningmap-editor web component with editing capabilities Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../CHANGELOG.md | 13 + .../README.md | 74 ++ .../package.json | 52 ++ .../src/HyperbookLearningmapEditor.tsx | 796 ++++++++++++++++++ .../src/autoLayoutElk.ts | 43 + .../src/index.css | 296 +++++++ .../src/index.ts | 16 + .../tsconfig.json | 10 + .../vite.config.ts | 22 + pnpm-lock.yaml | 100 ++- 10 files changed, 1395 insertions(+), 27 deletions(-) create mode 100644 packages/web-component-learningmap-editor/CHANGELOG.md create mode 100644 packages/web-component-learningmap-editor/README.md create mode 100644 packages/web-component-learningmap-editor/package.json create mode 100644 packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx create mode 100644 packages/web-component-learningmap-editor/src/autoLayoutElk.ts create mode 100644 packages/web-component-learningmap-editor/src/index.css create mode 100644 packages/web-component-learningmap-editor/src/index.ts create mode 100644 packages/web-component-learningmap-editor/tsconfig.json create mode 100644 packages/web-component-learningmap-editor/vite.config.ts diff --git a/packages/web-component-learningmap-editor/CHANGELOG.md b/packages/web-component-learningmap-editor/CHANGELOG.md new file mode 100644 index 00000000..5db19d3c --- /dev/null +++ b/packages/web-component-learningmap-editor/CHANGELOG.md @@ -0,0 +1,13 @@ +# @hyperbook/web-component-learningmap-editor + +## 0.1.0 + +### Minor Changes + +- Initial release of the learningmap editor web component +- Visual editor for creating and editing learning maps +- Support for drag-and-drop node positioning +- Configurable node settings (label, description, resources, unlock rules, completion rules) +- Background customization (color and image) +- Auto-layout support using ELK algorithm +- Change event fires when saving diff --git a/packages/web-component-learningmap-editor/README.md b/packages/web-component-learningmap-editor/README.md new file mode 100644 index 00000000..a6e32328 --- /dev/null +++ b/packages/web-component-learningmap-editor/README.md @@ -0,0 +1,74 @@ +# @hyperbook/web-component-learningmap-editor + +A web component for editing learning maps/roadmaps with drag-and-drop nodes, customizable settings, and visual editing capabilities. + +## Features + +- **Visual Editor**: Drag-and-drop interface for creating and positioning nodes +- **Node Types**: Support for Task and Topic nodes +- **Node Settings**: Edit labels, descriptions, resources, durations, and more +- **Unlock Rules**: Configure password, date, and dependency-based unlocking +- **Completion Rules**: Set completion needs and optional dependencies +- **Background Customization**: Configure background color and images +- **Auto-Layout**: Automatic node positioning using ELK algorithm +- **Edge Management**: Connect nodes with customizable edges + +## Usage + +```html + +``` + +## Events + +The component fires a `change` event when the Save button is pressed: + +```javascript +const editor = document.querySelector('hyperbook-learningmap-editor'); +editor.addEventListener('change', (event) => { + const roadmapData = event.detail; + console.log('Roadmap data:', roadmapData); +}); +``` + +## Roadmap Data Format + +```json +{ + "nodes": [ + { + "id": "node1", + "type": "task", + "position": { "x": 0, "y": 0 }, + "data": { + "label": "Introduction", + "description": "Learn the basics", + "resources": [ + { "label": "Documentation", "url": "https://example.com" } + ], + "unlock": { + "password": "secret", + "date": "2024-01-01", + "after": ["node0"] + }, + "completion": { + "needs": [{ "id": "node0" }], + "optional": [{ "id": "node2" }] + } + } + } + ], + "edges": [], + "background": { + "color": "#ffffff", + "image": { + "src": "bg.png", + "x": 0, + "y": 0 + } + } +} +``` diff --git a/packages/web-component-learningmap-editor/package.json b/packages/web-component-learningmap-editor/package.json new file mode 100644 index 00000000..98dda42c --- /dev/null +++ b/packages/web-component-learningmap-editor/package.json @@ -0,0 +1,52 @@ +{ + "name": "@hyperbook/web-component-learningmap-editor", + "version": "0.1.0", + "author": "Mike Barkmin", + "homepage": "https://github.com/openpatch/hyperbook#readme", + "license": "MIT", + "type": "module", + "main": "dist/index.umd.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.umd.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openpatch/hyperbook.git", + "directory": "packages/web-component-learningmap-editor" + }, + "bugs": { + "url": "https://github.com/openpatch/hyperbook/issues" + }, + "scripts": { + "version": "pnpm build", + "lint": "tsc --noEmit", + "build": "vite build" + }, + "dependencies": { + "@r2wc/react-to-web-component": "^2.0.4", + "@xyflow/react": "^12.8.6", + "elkjs": "^0.11.0", + "js-yaml": "^4.1.0", + "lucide-react": "^0.544.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.1.2", + "@types/js-yaml": "^4.0.9", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.1.0" + } +} diff --git a/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx b/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx new file mode 100644 index 00000000..f14c3cb1 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx @@ -0,0 +1,796 @@ +import { useState, useCallback, useEffect } from "react"; +import { getAutoLayoutedNodesElk } from "./autoLayoutElk"; +import * as yaml from "js-yaml"; +import { + ReactFlow, + Controls, + useNodesState, + useEdgesState, + ReactFlowProvider, + Handle, + Position, + ColorMode, + useReactFlow, + Node, + addEdge, + Connection, + Edge, +} from "@xyflow/react"; +import { + Save, + Plus, + Settings, + Trash2, + X, +} from "lucide-react"; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +interface UnlockCondition { + after?: string[]; + date?: string; + password?: string; +} + +interface CompletionNeed { + id: string; + source?: string; + target?: string; +} + +interface Completion { + needs?: CompletionNeed[]; + optional?: CompletionNeed[]; +} + +interface NodeData { + label: string; + description?: string; + duration?: string; + unlock?: UnlockCondition; + completion?: Completion; + video?: string; + resources?: { label: string; url: string }[]; + summary?: string; + [key: string]: any; +} + +interface BackgroundConfig { + color?: string; + image?: { + src: string; + x?: number; + y?: number; + }; +} + +interface EdgeConfig { + animated?: boolean; + color?: string; + width?: number; + type?: string; +} + +interface RoadmapData { + nodes?: Node[]; + edges?: Edge[]; + background?: BackgroundConfig; + edgeConfig?: EdgeConfig; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +const parseRoadmapData = (roadmapData: string | RoadmapData): RoadmapData => { + if (typeof roadmapData !== "string") { + return roadmapData || {}; + } + + try { + return JSON.parse(roadmapData); + } catch { + try { + return yaml.load(roadmapData) as RoadmapData; + } catch (err) { + console.error("Failed to parse roadmap data:", err); + return {}; + } + } +}; + +const generateEdgesFromCompletionNeeds = (nodes: Node[], edgeConfig: EdgeConfig) => { + const edges: Edge[] = []; + nodes.forEach((node) => { + const needs = node.data?.completion?.needs || []; + needs.forEach((need: CompletionNeed) => { + if (need.id) { + edges.push({ + id: `${need.id}->${node.id}`, + source: need.id, + target: node.id, + sourceHandle: need.source || "bottom", + targetHandle: need.target || "top", + }); + } + }); + const optional = node.data?.completion?.optional || []; + optional.forEach((opt: CompletionNeed) => { + if (opt.id) { + edges.push({ + id: `${opt.id}->${node.id}-optional`, + source: opt.id, + target: node.id, + sourceHandle: opt.source || "bottom", + targetHandle: opt.target || "top", + style: { + strokeDasharray: "5,5", + strokeWidth: edgeConfig.width ?? 2, + stroke: edgeConfig.color ?? "#94a3b8" + }, + }); + } + }); + }); + return edges; +}; + +// ============================================================================ +// NODE COMPONENTS +// ============================================================================ + +const EditorNode = ({ data, type }: { data: NodeData; type: string }) => { + return ( + + + + {data.label || "Untitled"} + + + {data.summary && ( + + {data.summary} + + )} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + ); +}; + +const BackgroundNode = ({ data }: { data: { image?: { src: string } } }) => { + if (!data.image?.src) return null; + return ; +}; + +// ============================================================================ +// EDITOR DRAWER +// ============================================================================ + +const EditorDrawer = ({ + node, + isOpen, + onClose, + onUpdate, + onDelete +}: { + node: Node | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + onDelete: () => void; +}) => { + const [localNode, setLocalNode] = useState | null>(node); + + useEffect(() => { + setLocalNode(node); + }, [node]); + + if (!isOpen || !node) return null; + + const handleSave = () => { + if (!localNode) return; + onUpdate(localNode); + onClose(); + }; + + const handleFieldChange = (field: string, value: any) => { + setLocalNode((prev: Node | null) => ({ + ...prev!, + data: { ...prev!.data, [field]: value }, + })); + }; + + const handleResourceChange = (index: number, field: string, value: string) => { + if (!localNode) return; + const resources = [...(localNode.data.resources || [])]; + resources[index] = { ...resources[index], [field]: value }; + handleFieldChange("resources", resources); + }; + + const addResource = () => { + if (!localNode) return; + const resources = [...(localNode.data.resources || []), { label: "", url: "" }]; + handleFieldChange("resources", resources); + }; + + const removeResource = (index: number) => { + if (!localNode) return; + const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index); + handleFieldChange("resources", resources); + }; + + const handleUnlockAfterChange = (value: string) => { + if (!localNode) return; + const after = value.split(",").map((s) => s.trim()).filter(Boolean); + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + + const handleCompletionNeedsChange = (value: string) => { + if (!localNode) return; + const needs = value.split(",").map((s) => s.trim()).filter(Boolean).map((id) => ({ id })); + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + + const handleCompletionOptionalChange = (value: string) => { + if (!localNode) return; + const optional = value.split(",").map((s) => s.trim()).filter(Boolean).map((id) => ({ id })); + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + + return ( + <> + + + + Edit Node + + + + + + + + Node Type + setLocalNode({ ...localNode, type: e.target.value })} + > + Task + Topic + + + + + Label * + handleFieldChange("label", e.target.value)} + placeholder="Node label" + /> + + + + Summary + handleFieldChange("summary", e.target.value)} + placeholder="Short summary" + /> + + + + Description + handleFieldChange("description", e.target.value)} + placeholder="Detailed description" + rows={4} + /> + + + + Duration + handleFieldChange("duration", e.target.value)} + placeholder="e.g., 30 min" + /> + + + + Video URL + handleFieldChange("video", e.target.value)} + placeholder="YouTube or video URL" + /> + + + + Resources + {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( + + handleResourceChange(idx, "label", e.target.value)} + placeholder="Label" + style={{ flex: 1 }} + /> + handleResourceChange(idx, "url", e.target.value)} + placeholder="URL" + style={{ flex: 2 }} + /> + removeResource(idx)} className="icon-button"> + + + + ))} + + Add Resource + + + + + Unlock Password + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })} + placeholder="Optional password" + /> + + + + Unlock Date + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })} + /> + + + + Unlock After (comma-separated node IDs) + handleUnlockAfterChange(e.target.value)} + placeholder="e.g., node1, node2" + /> + + + + Completion Needs (comma-separated node IDs) + n.id).join(", ")} + onChange={(e) => handleCompletionNeedsChange(e.target.value)} + placeholder="e.g., node1, node2" + /> + + + + Completion Optional (comma-separated node IDs) + n.id).join(", ")} + onChange={(e) => handleCompletionOptionalChange(e.target.value)} + placeholder="e.g., node3, node4" + /> + + + + + + Delete Node + + + Save Changes + + + + > + ); +}; + +// ============================================================================ +// BACKGROUND SETTINGS DRAWER +// ============================================================================ + +const BackgroundDrawer = ({ + isOpen, + onClose, + background, + onUpdate +}: { + isOpen: boolean; + onClose: () => void; + background: BackgroundConfig; + onUpdate: (bg: BackgroundConfig) => void; +}) => { + const [localBg, setLocalBg] = useState(background); + + useEffect(() => { + setLocalBg(background); + }, [background]); + + if (!isOpen) return null; + + const handleSave = () => { + onUpdate(localBg); + onClose(); + }; + + return ( + <> + + + + Background Settings + + + + + + + + Background Color + setLocalBg({ ...localBg, color: e.target.value })} + /> + + + + Background Image URL + + setLocalBg({ + ...localBg, + image: { ...(localBg?.image || {}), src: e.target.value }, + }) + } + placeholder="Image URL" + /> + + + + Image X Position + + setLocalBg({ + ...localBg, + image: { src: localBg?.image?.src || "", ...(localBg?.image || {}), x: Number(e.target.value) }, + }) + } + /> + + + + Image Y Position + + setLocalBg({ + ...localBg, + image: { src: localBg?.image?.src || "", ...(localBg?.image || {}), y: Number(e.target.value) }, + }) + } + /> + + + + + + Save Changes + + + + > + ); +}; + +// ============================================================================ +// MAIN EDITOR COMPONENT +// ============================================================================ + +function HyperbookLearningmapEditorInner({ + roadmapData, + language = "en" +}: { + roadmapData: string | RoadmapData; + language?: string; +}) { + const { screenToFlowPosition } = useReactFlow(); + + const [nodes, setNodes] = useNodesState([]); + const [edges, setEdges] = useEdgesState([]); + const [colorMode] = useState("light"); + const [selectedNode, setSelectedNode] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [backgroundDrawerOpen, setBackgroundDrawerOpen] = useState(false); + const [background, setBackground] = useState({ color: "#ffffff" }); + const [edgeConfig, setEdgeConfig] = useState({}); + const [nextNodeId, setNextNodeId] = useState(1); + + const parsedRoadmap = parseRoadmapData(roadmapData); + + // Initialize from roadmap data + useEffect(() => { + async function loadRoadmap() { + const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; + const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; + + setBackground(parsedRoadmap?.background || { color: "#ffffff" }); + setEdgeConfig(parsedRoadmap?.edgeConfig || {}); + + const rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: true, + data: { ...n.data }, + })); + + const rawEdges: Edge[] = edgesArr.length > 0 + ? edgesArr as Edge[] + : generateEdgesFromCompletionNeeds(rawNodes, edgeConfig); + + setEdges(rawEdges); + + const needsLayout = rawNodes.some((n: Node) => !n.position); + if (needsLayout) { + const autoNodes = await getAutoLayoutedNodesElk(rawNodes, rawEdges); + setNodes(autoNodes); + } else { + 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); + } + } + loadRoadmap(); + }, [roadmapData]); + + // Event handlers + const onNodeClick = useCallback((_: any, node: Node) => { + if (node.type === "background") return; + setSelectedNode(node); + setDrawerOpen(true); + }, []); + + const onConnect = useCallback( + (connection: Connection) => { + setEdges((eds) => addEdge(connection, eds)); + }, + [setEdges] + ); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + setSelectedNode(null); + }, []); + + const updateNode = useCallback( + (updatedNode: Node) => { + setNodes((nds) => + nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + ); + setSelectedNode(updatedNode); + }, + [setNodes] + ); + + const deleteNode = useCallback(() => { + if (!selectedNode) return; + setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); + setEdges((eds) => + eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id) + ); + closeDrawer(); + }, [selectedNode, setNodes, setEdges, closeDrawer]); + + const addNewNode = useCallback( + (type: "task" | "topic") => { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position: screenToFlowPosition({ x: 100, y: 100 }), + data: { + label: `New ${type}`, + summary: "", + description: "", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + }, + [nextNodeId, screenToFlowPosition, setNodes] + ); + + const handleSave = useCallback(() => { + const roadmapData: RoadmapData = { + nodes: nodes.map((n) => ({ + id: n.id, + type: n.type, + position: n.position, + data: n.data, + })), + edges: edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + style: e.style, + })), + background, + edgeConfig, + }; + + const root = document.querySelector("hyperbook-learningmap-editor"); + if (root) { + root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); + } + }, [nodes, edges, background, edgeConfig]); + + // Create background node if configured + const backgroundNode = background?.image?.src + ? { + id: "background-image", + type: "background", + position: { x: background.image.x ?? 0, y: background.image.y ?? 0 }, + data: { image: { src: background.image.src } }, + draggable: false, + selectable: false, + focusable: false, + zIndex: -1, + } + : null; + + const displayNodes = [...(backgroundNode ? [backgroundNode] : []), ...nodes]; + + const nodeTypes = { + topic: (props: any) => , + task: (props: any) => , + background: BackgroundNode, + }; + + const defaultEdgeOptions = { + animated: edgeConfig.animated ?? false, + style: { + stroke: edgeConfig.color ?? "#94a3b8", + strokeWidth: edgeConfig.width ?? 2, + }, + type: edgeConfig.type ?? "default", + }; + + return ( + + {/* Toolbar */} + + + addNewNode("task")} className="toolbar-button"> + Add Task + + addNewNode("topic")} className="toolbar-button"> + Add Topic + + setBackgroundDrawerOpen(true)} className="toolbar-button"> + Background + + + + Save + + + + {/* Editor Canvas */} + + + + + + + {/* Drawers */} + + setBackgroundDrawerOpen(false)} + background={background} + onUpdate={setBackground} + /> + + ); +} + +export function HyperbookLearningmapEditor({ + roadmapData, + language +}: { + roadmapData: string | RoadmapData; + language?: string; +}) { + return ( + + + + ); +} diff --git a/packages/web-component-learningmap-editor/src/autoLayoutElk.ts b/packages/web-component-learningmap-editor/src/autoLayoutElk.ts new file mode 100644 index 00000000..73cd091e --- /dev/null +++ b/packages/web-component-learningmap-editor/src/autoLayoutElk.ts @@ -0,0 +1,43 @@ +import ELK from "elkjs/lib/elk.bundled.js"; +import type { Node, Edge } from "@xyflow/react"; + +export async function getAutoLayoutedNodesElk( + nodes: Node[], + edges: Edge[], + nodeWidth = 320, + nodeHeight = 120 +) { + const elk = new ELK(); + const elkNodes = nodes.map((node: Node) => ({ + id: node.id, + width: nodeWidth, + height: nodeHeight, + ...node, + })); + const elkEdges = edges.map((edge: Edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })); + const elkGraph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": "100", + "elk.spacing.nodeNode": "80", + }, + children: elkNodes, + edges: elkEdges, + }; + const layout: any = await elk.layout(elkGraph); + return nodes.map((node: Node) => { + if (node.position) return node; + const layoutNode = layout.children.find((n: any) => n.id === node.id); + return { + ...node, + position: { x: layoutNode.x, y: layoutNode.y }, + autoPositioned: true, + }; + }); +} diff --git a/packages/web-component-learningmap-editor/src/index.css b/packages/web-component-learningmap-editor/src/index.css new file mode 100644 index 00000000..347e56f8 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/index.css @@ -0,0 +1,296 @@ +/* Container */ +.hyperbook-learningmap-editor-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--color-nav, #f9fafb); +} + +/* Toolbar */ +.editor-toolbar { + width: 100%; + background: var(--color-nav, #ffffff); + border-bottom: 1px solid var(--color-nav-border, #e5e7eb); + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 10; + box-sizing: border-box; +} + +.toolbar-group { + display: flex; + gap: 8px; +} + +.toolbar-button { + padding: 8px 16px; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; +} + +.toolbar-button:hover { + background: var(--color-spacer, #f3f4f6); + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.primary { + background: var(--color-brand, #3b82f6); + color: white; + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.primary:hover { + background: #2563eb; + border-color: #2563eb; +} + +/* Editor Canvas */ +.editor-canvas { + flex: 1; + min-height: 0; + position: relative; + width: 100%; +} + +/* Editor Nodes */ +.editor-node { + cursor: pointer; + transition: all 0.2s; +} + +.editor-node:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1) !important; +} + +.react-flow__node-background img { + max-width: none; +} + +/* Drawer Styles */ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1002; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 500px; + max-width: 90vw; + background: var(--color-nav, white); + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1); + z-index: 1003; + display: flex; + flex-direction: column; + animation: slideIn 0.3s ease; +} + +.drawer-header { + padding: 24px; + border-bottom: 1px solid var(--color-nav-border, #e5e7eb); + display: flex; + justify-content: space-between; + align-items: center; +} + +.drawer-title { + font-size: 24px; + font-weight: 700; + margin: 0; + border: none; +} + +.close-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--color-text, #6b7280); + transition: color 0.2s; +} + +.close-button:hover { + color: var(--color-text, #1f2937); +} + +.drawer-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.drawer-footer { + padding: 24px; + border-top: 1px solid var(--color-nav-border, #e5e7eb); + display: flex; + gap: 12px; + justify-content: flex-end; +} + +/* Form Styles */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; + color: var(--color-text, #374151); +} + +.form-group input[type="text"], +.form-group input[type="date"], +.form-group input[type="number"], +.form-group input[type="color"], +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + font-size: 14px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + box-sizing: border-box; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-brand, #3b82f6); +} + +.form-group textarea { + resize: vertical; + font-family: inherit; +} + +/* Buttons */ +.primary-button { + padding: 10px 20px; + background: var(--color-brand, #3b82f6); + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; +} + +.primary-button:hover { + background: #2563eb; +} + +.secondary-button { + padding: 8px 16px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + width: 100%; + justify-content: center; +} + +.secondary-button:hover { + background: var(--color-spacer, #f3f4f6); +} + +.danger-button { + padding: 10px 20px; + background: #ef4444; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; +} + +.danger-button:hover { + background: #dc2626; +} + +.icon-button { + padding: 6px; + background: none; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 4px; + cursor: pointer; + color: var(--color-text, #6b7280); + transition: all 0.2s; +} + +.icon-button:hover { + background: var(--color-spacer, #f3f4f6); + color: #ef4444; +} + +/* React Flow Controls */ +.react-flow__controls-button { + color: #000 !important; +} + +/* React Flow Handles */ +.react-flow__handle { + width: 10px; + height: 10px; + background: var(--color-brand, #3b82f6); + border: 2px solid white; +} diff --git a/packages/web-component-learningmap-editor/src/index.ts b/packages/web-component-learningmap-editor/src/index.ts new file mode 100644 index 00000000..c4e55941 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/index.ts @@ -0,0 +1,16 @@ +import r2wc from "@r2wc/react-to-web-component"; +import { HyperbookLearningmapEditor } from "./HyperbookLearningmapEditor"; +import "@xyflow/react/dist/style.css"; +import "./index.css"; + +const LearningmapEditorWC = r2wc(HyperbookLearningmapEditor, { + props: { + roadmapData: "string", + language: "string", + }, + events: { + change: true, + }, +}); + +customElements.define("hyperbook-learningmap-editor", LearningmapEditorWC); diff --git a/packages/web-component-learningmap-editor/tsconfig.json b/packages/web-component-learningmap-editor/tsconfig.json new file mode 100644 index 00000000..60e6789c --- /dev/null +++ b/packages/web-component-learningmap-editor/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/web-component-learningmap-editor/vite.config.ts b/packages/web-component-learningmap-editor/vite.config.ts new file mode 100644 index 00000000..41552974 --- /dev/null +++ b/packages/web-component-learningmap-editor/vite.config.ts @@ -0,0 +1,22 @@ +import { resolve } from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import typescript from "@rollup/plugin-typescript"; + +export default defineConfig(() => ({ + plugins: [react(), typescript()], + build: { + outDir: "./dist", + emptyOutDir: true, + lib: { + formats: ["umd"], + entry: resolve(__dirname, "src/index.ts"), + name: "index", + fileName: (format) => `index.${format}.js`, + }, + }, + define: { + "process.env.NODE_ENV": "'production'", + "process.env.IS_PREACT": JSON.stringify("false"), + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 994052ee..0a86040f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,52 @@ importers: specifier: ^6.1.0 version: 6.1.0(@types/node@22.13.1)(lightningcss@1.28.2)(sass@1.84.0)(terser@5.38.1)(yaml@2.7.1) + packages/web-component-learningmap-editor: + dependencies: + '@r2wc/react-to-web-component': + specifier: ^2.0.4 + version: 2.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@xyflow/react': + specifier: ^12.8.6 + version: 12.8.6(@types/react@19.1.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + elkjs: + specifier: ^0.11.0 + version: 0.11.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 + lucide-react: + specifier: ^0.544.0 + version: 0.544.0(react@19.0.0) + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@rollup/plugin-typescript': + specifier: ^12.1.2 + version: 12.1.2(rollup@4.34.4)(tslib@2.8.1)(typescript@5.7.3) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/react': + specifier: ^19.0.0 + version: 19.1.2 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.1.2(@types/react@19.1.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.1.0(@types/node@22.13.1)(lightningcss@1.28.2)(sass@1.84.0)(terser@5.38.1)(yaml@2.7.1)) + vite: + specifier: ^6.1.0 + version: 6.1.0(@types/node@22.13.1)(lightningcss@1.28.2)(sass@1.84.0)(terser@5.38.1)(yaml@2.7.1) + platforms/vscode: dependencies: '@hyperbook/fs': @@ -512,22 +558,22 @@ importers: version: 6.0.3 copy-webpack-plugin: specifier: 12.0.2 - version: 12.0.2(webpack@5.97.1) + version: 12.0.2(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) css-loader: specifier: 7.1.2 - version: 7.1.2(webpack@5.97.1) + version: 7.1.2(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) eslint: specifier: 9.19.0 version: 9.19.0 file-loader: specifier: 6.2.0 - version: 6.2.0(webpack@5.97.1) + version: 6.2.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) glob: specifier: 11.0.1 version: 11.0.1 node-polyfill-webpack-plugin: specifier: ^4.1.0 - version: 4.1.0(webpack@5.97.1) + version: 4.1.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) npm-run-all: specifier: 4.1.5 version: 4.1.5 @@ -542,13 +588,13 @@ importers: version: 1.84.0 sass-loader: specifier: 16.0.4 - version: 16.0.4(sass@1.84.0)(webpack@5.97.1) + version: 16.0.4(sass@1.84.0)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) style-loader: specifier: 4.0.0 - version: 4.0.0(webpack@5.97.1) + version: 4.0.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) ts-loader: specifier: 9.5.2 - version: 9.5.2(typescript@5.7.3)(webpack@5.97.1) + version: 9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) typescript: specifier: 5.7.3 version: 5.7.3 @@ -7782,7 +7828,7 @@ snapshots: '@azure/abort-controller@2.1.2': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 '@azure/core-auth@1.7.2': dependencies: @@ -7804,7 +7850,7 @@ snapshots: '@azure/core-tracing': 1.1.2 '@azure/core-util': 1.9.0 '@azure/logger': 1.1.2 - tslib: 2.6.2 + tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -9775,17 +9821,17 @@ snapshots: '@webcoder49/code-input@2.2.1': {} - '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.97.1)': + '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1))': dependencies: webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.97.1) - '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.97.1)': + '@webpack-cli/info@3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1))': dependencies: webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.97.1) - '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack@5.97.1)': + '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1))': dependencies: webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.97.1) @@ -10564,7 +10610,7 @@ snapshots: graceful-fs: 4.2.11 p-event: 6.0.1 - copy-webpack-plugin@12.0.2(webpack@5.97.1): + copy-webpack-plugin@12.0.2(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: fast-glob: 3.3.2 glob-parent: 6.0.2 @@ -10686,7 +10732,7 @@ snapshots: randombytes: 2.1.0 randomfill: 1.0.4 - css-loader@7.1.2(webpack@5.97.1): + css-loader@7.1.2(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: icss-utils: 5.1.0(postcss@8.5.1) postcss: 8.5.1 @@ -11503,7 +11549,7 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-loader@6.2.0(webpack@5.97.1): + file-loader@6.2.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: loader-utils: 2.0.4 schema-utils: 3.1.1 @@ -13804,7 +13850,7 @@ snapshots: title-case: 3.0.3 upper-case: 2.0.2 - node-polyfill-webpack-plugin@4.1.0(webpack@5.97.1): + node-polyfill-webpack-plugin@4.1.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: node-stdlib-browser: 1.3.1 type-fest: 4.33.0 @@ -14927,7 +14973,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.4(sass@1.84.0)(webpack@5.97.1): + sass-loader@16.0.4(sass@1.84.0)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -15367,7 +15413,7 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@4.0.0(webpack@5.97.1): + style-loader@4.0.0(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) @@ -15423,26 +15469,26 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)): + terser-webpack-plugin@5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.38.1 - webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2) + webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) optionalDependencies: '@swc/core': 1.10.4 esbuild: 0.24.2 - terser-webpack-plugin@5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1): + terser-webpack-plugin@5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.38.1 - webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1) + webpack: 5.97.1(@swc/core@1.10.4)(esbuild@0.24.2) optionalDependencies: '@swc/core': 1.10.4 esbuild: 0.24.2 @@ -15522,7 +15568,7 @@ snapshots: ts-dedent@2.2.0: {} - ts-loader@9.5.2(typescript@5.7.3)(webpack@5.97.1): + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.1 @@ -16051,9 +16097,9 @@ snapshots: webpack-cli@6.0.1(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.6.3 - '@webpack-cli/configtest': 3.0.1(webpack-cli@6.0.1)(webpack@5.97.1) - '@webpack-cli/info': 3.0.1(webpack-cli@6.0.1)(webpack@5.97.1) - '@webpack-cli/serve': 3.0.1(webpack-cli@6.0.1)(webpack@5.97.1) + '@webpack-cli/configtest': 3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) + '@webpack-cli/info': 3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) + '@webpack-cli/serve': 3.0.1(webpack-cli@6.0.1(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) colorette: 2.0.20 commander: 12.1.0 cross-spawn: 7.0.6 @@ -16125,7 +16171,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1) + terser-webpack-plugin: 5.3.11(@swc/core@1.10.4)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.4)(esbuild@0.24.2)(webpack-cli@6.0.1)) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: From 02ea50e3f5cfb722d9ae5de621a1ca296f5345b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:08:17 +0000 Subject: [PATCH 03/19] Add test HTML file for learningmap-editor component Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../index.html | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/web-component-learningmap-editor/index.html diff --git a/packages/web-component-learningmap-editor/index.html b/packages/web-component-learningmap-editor/index.html new file mode 100644 index 00000000..0a532b21 --- /dev/null +++ b/packages/web-component-learningmap-editor/index.html @@ -0,0 +1,98 @@ + + + + + + Learningmap Editor Test + + + + + + + Last Save: + No data yet. Click Save in the editor. + + + + + + From 54050a27c41b7adbaaf588ca7e3ad0fc678b3578 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:09:38 +0000 Subject: [PATCH 04/19] Add comprehensive test guide for learningmap-editor Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../TEST_GUIDE.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 packages/web-component-learningmap-editor/TEST_GUIDE.md diff --git a/packages/web-component-learningmap-editor/TEST_GUIDE.md b/packages/web-component-learningmap-editor/TEST_GUIDE.md new file mode 100644 index 00000000..ad73151d --- /dev/null +++ b/packages/web-component-learningmap-editor/TEST_GUIDE.md @@ -0,0 +1,151 @@ +# Hyperbook Learningmap Editor - Complete Test Guide + +This test file demonstrates all the features of the learningmap-editor web component. + +## Setup + +1. Build the component: + ```bash + pnpm build + ``` + +2. Open `index.html` in a web browser + +## Features to Test + +### 1. Node Management +- ✅ Click "Add Task" button to create a new task node +- ✅ Click "Add Topic" button to create a new topic node +- ✅ Click on any node to open the editor drawer +- ✅ Drag nodes to reposition them + +### 2. Node Editing (in the drawer) +- ✅ Change node type between Task and Topic +- ✅ Edit label (required field) +- ✅ Edit summary (shown on the node) +- ✅ Edit description (detailed text) +- ✅ Set duration (e.g., "30 min") +- ✅ Set video URL (YouTube or direct video link) + +### 3. Resources +- ✅ Click "Add Resource" to add a new resource +- ✅ Fill in label and URL for each resource +- ✅ Click trash icon to remove a resource + +### 4. Unlock Rules +- ✅ Set a password to unlock the node +- ✅ Set a date when the node unlocks +- ✅ List comma-separated node IDs that must be completed first + +### 5. Completion Rules +- ✅ Set "Completion Needs" - nodes that must be completed +- ✅ Set "Completion Optional" - optional nodes for full completion + +### 6. Background Settings +- ✅ Click "Background" button in toolbar +- ✅ Change background color using color picker +- ✅ Add background image URL +- ✅ Adjust image X and Y positions + +### 7. Edge Management +- ✅ Drag from a node's handle to another node to create an edge +- ✅ Edges are automatically created from completion needs if not specified + +### 8. Save Functionality +- ✅ Click "Save" button in toolbar +- ✅ Check the output box for the saved roadmap data +- ✅ Verify the change event is fired with complete data + +### 9. Delete Nodes +- ✅ Click "Delete Node" button in the editor drawer +- ✅ Verify node and connected edges are removed + +### 10. Initial Data Loading +- ✅ Component initializes with the sample data +- ✅ Two nodes are visible: "Getting Started" (task) and "Advanced Topics" (topic) +- ✅ Background has a light blue color (#f0f9ff) + +## Data Structure + +The component expects and outputs data in this format: + +```json +{ + "nodes": [ + { + "id": "node1", + "type": "task", + "position": { "x": 100, "y": 100 }, + "data": { + "label": "Node Label", + "summary": "Short summary", + "description": "Detailed description", + "duration": "30 min", + "video": "https://youtube.com/...", + "resources": [ + { "label": "Resource Name", "url": "https://..." } + ], + "unlock": { + "password": "secret", + "date": "2024-01-01", + "after": ["node0"] + }, + "completion": { + "needs": [{ "id": "node0" }], + "optional": [{ "id": "node2" }] + } + } + } + ], + "edges": [ + { + "id": "node1->node2", + "source": "node1", + "target": "node2" + } + ], + "background": { + "color": "#ffffff", + "image": { + "src": "bg.png", + "x": 0, + "y": 0 + } + }, + "edgeConfig": { + "animated": false, + "color": "#94a3b8", + "width": 2, + "type": "default" + } +} +``` + +## Known Limitations + +1. Node IDs are auto-generated as "node1", "node2", etc. +2. The component doesn't validate unlock/completion references +3. Background image must be publicly accessible +4. Video URLs work best with YouTube or direct video files + +## Browser Console Testing + +Open browser console and try: + +```javascript +// Get the editor element +const editor = document.getElementById('editor'); + +// Listen for changes +editor.addEventListener('change', (e) => { + console.log('Roadmap saved:', e.detail); +}); + +// Programmatically set data +const newData = { + nodes: [], + edges: [], + background: { color: "#fff" } +}; +editor.setAttribute('roadmap-data', JSON.stringify(newData)); +``` From 94dfd2c311fe0cd350820418bf6c18cdedce9a8a Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Wed, 8 Oct 2025 21:03:05 +0200 Subject: [PATCH 05/19] editor --- .../index.html | 45 +- .../package.json | 3 +- .../src/BackgroundDrawer.tsx | 61 ++ .../src/ColorSelector.tsx | 28 + .../src/Drawer.tsx | 0 .../src/DrawerTaskContent.tsx | 0 .../src/DrawerTopicContent.tsx | 0 .../src/EdgeDrawer.tsx | 60 ++ .../src/EditorDrawer.tsx | 241 ++++++ .../src/EditorDrawerEdgeContent.tsx | 48 ++ .../src/EditorDrawerImageContent.tsx | 43 + .../src/EditorDrawerTaskContent.tsx | 214 +++++ .../src/EditorDrawerTextContent.tsx | 44 + .../src/EditorDrawerTopicContent.tsx | 6 + .../src/EditorToolbar.tsx | 73 ++ .../src/FloatingEdge.tsx | 39 + .../src/HyperbookLearningmapEditor.tsx | 792 +----------------- .../src/LearningMap.tsx | 126 +++ .../src/LearningMapEditor.tsx | 425 ++++++++++ .../src/RotationInput.tsx | 38 + .../src/helper.ts | 86 ++ .../src/index.css | 141 +++- .../src/nodes/ImageNode.tsx | 25 + .../src/nodes/TaskNode.tsx | 45 + .../src/nodes/TextNode.tsx | 19 + .../src/nodes/TopicNode.tsx | 42 + .../src/types.ts | 85 ++ 27 files changed, 1900 insertions(+), 829 deletions(-) create mode 100644 packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx create mode 100644 packages/web-component-learningmap-editor/src/ColorSelector.tsx create mode 100644 packages/web-component-learningmap-editor/src/Drawer.tsx create mode 100644 packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EdgeDrawer.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawer.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/EditorToolbar.tsx create mode 100644 packages/web-component-learningmap-editor/src/FloatingEdge.tsx create mode 100644 packages/web-component-learningmap-editor/src/LearningMap.tsx create mode 100644 packages/web-component-learningmap-editor/src/LearningMapEditor.tsx create mode 100644 packages/web-component-learningmap-editor/src/RotationInput.tsx create mode 100644 packages/web-component-learningmap-editor/src/helper.ts create mode 100644 packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx create mode 100644 packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx create mode 100644 packages/web-component-learningmap-editor/src/nodes/TextNode.tsx create mode 100644 packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx create mode 100644 packages/web-component-learningmap-editor/src/types.ts diff --git a/packages/web-component-learningmap-editor/index.html b/packages/web-component-learningmap-editor/index.html index 0a532b21..e3c3b581 100644 --- a/packages/web-component-learningmap-editor/index.html +++ b/packages/web-component-learningmap-editor/index.html @@ -1,55 +1,32 @@ + Learningmap Editor Test + - - - - Last Save: - No data yet. Click Save in the editor. - + + + diff --git a/packages/web-component-learningmap-editor/package.json b/packages/web-component-learningmap-editor/package.json index 98dda42c..db93ca0e 100644 --- a/packages/web-component-learningmap-editor/package.json +++ b/packages/web-component-learningmap-editor/package.json @@ -33,9 +33,9 @@ }, "dependencies": { "@r2wc/react-to-web-component": "^2.0.4", + "@szhsin/react-menu": "^4.5.0", "@xyflow/react": "^12.8.6", "elkjs": "^0.11.0", - "js-yaml": "^4.1.0", "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -43,7 +43,6 @@ }, "devDependencies": { "@rollup/plugin-typescript": "^12.1.2", - "@types/js-yaml": "^4.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx b/packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx new file mode 100644 index 00000000..98d61a58 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from "react"; +import { X, Save } from "lucide-react"; +import { BackgroundConfig } from "./types"; +import { ColorSelector } from "./ColorSelector"; + +interface BackgroundDrawerProps { + isOpen: boolean; + onClose: () => void; + background: BackgroundConfig; + onUpdate: (bg: BackgroundConfig) => void; +} + +export const BackgroundDrawer: React.FC = ({ + isOpen, + onClose, + background, + onUpdate, +}) => { + const [localBg, setLocalBg] = useState(background); + + useEffect(() => { + setLocalBg(background); + }, [background]); + + if (!isOpen) return null; + + const handleSave = () => { + onUpdate(localBg); + onClose(); + }; + + return ( + <> + + + + Background Settings + + + + + + + + setLocalBg({ ...localBg, color })} + /> + + + + + + Save Changes + + + + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/ColorSelector.tsx b/packages/web-component-learningmap-editor/src/ColorSelector.tsx new file mode 100644 index 00000000..911ee2f4 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/ColorSelector.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface ColorSelectorProps { + value: string; + onChange: (color: string) => void; + label?: string; +} + +export const ColorSelector: React.FC = ({ value, onChange, label }) => { + return ( + + {label && {label}} + onChange(e.target.value)} + style={{ width: 32, height: 32, border: "none", background: "none", padding: 0 }} + /> + onChange(e.target.value)} + placeholder="#e5e7eb" + style={{ width: 100 }} + /> + + ); +}; diff --git a/packages/web-component-learningmap-editor/src/Drawer.tsx b/packages/web-component-learningmap-editor/src/Drawer.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx b/packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx b/packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx b/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx new file mode 100644 index 00000000..b8357d0a --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { X, Trash2, Save } from "lucide-react"; +import { Edge } from "@xyflow/react"; +import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent"; + +interface EdgeDrawerProps { + edge: Edge | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (edge: Edge) => void; + onDelete: () => void; +} + +export const EdgeDrawer: React.FC = ({ + edge: selectedEdge, + isOpen: edgeDrawerOpen, + onClose: closeDrawer, + onUpdate: updateEdge, + onDelete: deleteEdge, +}) => { + if (!selectedEdge || !edgeDrawerOpen) return null; + return ( + + + + + Edit Edge + + + + + { + let updated = { ...selectedEdge }; + if (field === "color") { + updated = { + ...updated, + style: { ...updated.style, stroke: value }, + }; + } else if (field === "animated") { + updated = { ...updated, animated: value }; + } else if (field === "type") { + updated = { ...updated, type: value }; + } + updateEdge(updated); + }} + /> + + + Delete Edge + + + Save Changes + + + + + ); +}; diff --git a/packages/web-component-learningmap-editor/src/EditorDrawer.tsx b/packages/web-component-learningmap-editor/src/EditorDrawer.tsx new file mode 100644 index 00000000..0bdec2bd --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawer.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from "react"; +import { X, Trash2, Save } from "lucide-react"; +import { Node, useReactFlow } from "@xyflow/react"; +import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; +import { EditorDrawerTopicContent } from "./EditorDrawerTopicContent"; +import { EditorDrawerImageContent } from "./EditorDrawerImageContent"; +import { EditorDrawerTextContent } from "./EditorDrawerTextContent"; +import { NodeData } from "./types"; + +interface EditorDrawerProps { + node: Node | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + onDelete: () => void; +} + +export const EditorDrawer: React.FC = ({ + node, + isOpen, + onClose, + onUpdate, + onDelete, +}) => { + const [localNode, setLocalNode] = useState | null>(node); + const { getNodes } = useReactFlow(); + const allNodes = getNodes(); + + useEffect(() => { + setLocalNode(node); + }, [node]); + + 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"); + + // Helper for dropdowns + const renderNodeSelect = (value: string, onChange: (id: string) => void) => ( + onChange(e.target.value)}> + Select node... + {nodeOptions.map(n => ( + + {n.data.label || n.id} + + ))} + + ); + + // Completion Needs + const handleCompletionNeedsChange = (idx: number, id: string) => { + if (!localNode) return; + const needs = [...(localNode.data.completion?.needs || [])]; + needs[idx] = { id }; + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + const addCompletionNeed = () => { + if (!localNode) return; + const needs = [...(localNode.data.completion?.needs || []), { id: "" }]; + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + const removeCompletionNeed = (idx: number) => { + if (!localNode) return; + const needs = (localNode.data.completion?.needs || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + + // Completion Optional + const handleCompletionOptionalChange = (idx: number, id: string) => { + if (!localNode) return; + const optional = [...(localNode.data.completion?.optional || [])]; + optional[idx] = { id }; + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + const addCompletionOptional = () => { + if (!localNode) return; + const optional = [...(localNode.data.completion?.optional || []), { id: "" }]; + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + const removeCompletionOptional = (idx: number) => { + if (!localNode) return; + const optional = (localNode.data.completion?.optional || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + + // Unlock After + const handleUnlockAfterChange = (idx: number, id: string) => { + if (!localNode) return; + const after = [...(localNode.data.unlock?.after || [])]; + after[idx] = id; + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + const addUnlockAfter = () => { + if (!localNode) return; + const after = [...(localNode.data.unlock?.after || []), ""]; + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + const removeUnlockAfter = (idx: number) => { + if (!localNode) return; + const after = (localNode.data.unlock?.after || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + + const handleSave = () => { + if (!localNode) return; + onUpdate(localNode); + onClose(); + }; + + const handleFieldChange = (field: string, value: any) => { + setLocalNode((prev: Node | null) => ({ + ...prev!, + data: { ...prev!.data, [field]: value }, + })); + }; + + const handleResourceChange = (index: number, field: string, value: string) => { + if (!localNode) return; + const resources = [...(localNode.data.resources || [])]; + resources[index] = { ...resources[index], [field]: value }; + handleFieldChange("resources", resources); + }; + + const addResource = () => { + if (!localNode) return; + const resources = [...(localNode.data.resources || []), { label: "", url: "" }]; + handleFieldChange("resources", resources); + }; + + const removeResource = (index: number) => { + if (!localNode) return; + const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index); + handleFieldChange("resources", resources); + }; + + let content; + if (localNode.type === "task") { + content = ( + <> + + Edit Task + + + + + + > + ); + } else if (localNode.type === "topic") { + content = ( + <> + + Edit Topic + + + + + + > + ); + } else if (localNode.type === "image") { + content = ( + <> + + Edit Image + + + + + + > + ); + } else if (localNode.type === "text") { + content = ( + <> + + Edit Text + + + + + + > + ); + } + + return ( + <> + + + {content} + + + Delete Node + + + Save Changes + + + + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx new file mode 100644 index 00000000..938cbb34 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx @@ -0,0 +1,48 @@ +import { ColorSelector } from "./ColorSelector"; + +import { Edge } from "@xyflow/react"; + +interface Props { + localEdge: Edge; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerEdgeContent({ + localEdge, + handleFieldChange +}: Props) { + return ( + + + handleFieldChange("color", color)} + /> + + + + handleFieldChange("animated", e.target.checked)} + /> + Animated + + + + Type + handleFieldChange("type", e.target.value)} + > + Default + Straight + Step + Smoothstep + Simple Bezier + + + + ); +} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx new file mode 100644 index 00000000..bac69992 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx @@ -0,0 +1,43 @@ +import { Node } from "@xyflow/react"; +import { ImageNodeData } from "./types"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerImageContent({ localNode, handleFieldChange }: Props) { + // Convert file to base64 and update node data + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + handleFieldChange("data", reader.result); + } + }; + reader.readAsDataURL(file); + }; + + return ( + + + Upload Image (JPG, PNG, SVG) + + + {localNode.data.data && ( + + Preview: + + + + + )} + + ); +} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx new file mode 100644 index 00000000..d6d940dd --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx @@ -0,0 +1,214 @@ +import { Node } from "@xyflow/react"; +import { Plus, Trash2 } from "lucide-react"; +import { NodeData } from "./types"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; + handleResourceChange: (index: number, field: string, value: string) => void; + addResource: () => void; + removeResource: (index: number) => void; + handleUnlockAfterChange: (idx: number, id: string) => void; + addUnlockAfter: () => void; + removeUnlockAfter: (idx: number) => void; + renderNodeSelect: (value: string, onChange: (id: string) => void) => React.ReactNode; + handleCompletionNeedsChange: (idx: number, id: string) => void; + addCompletionNeed: () => void; + removeCompletionNeed: (idx: number) => void; + handleCompletionOptionalChange: (idx: number, id: string) => void; + addCompletionOptional: () => void; + removeCompletionOptional: (idx: number) => void; +} + +export function EditorDrawerTaskContent({ + localNode, + handleFieldChange, + handleResourceChange, + addResource, + removeResource, + handleUnlockAfterChange, + addUnlockAfter, + removeUnlockAfter, + renderNodeSelect, + handleCompletionNeedsChange, + addCompletionNeed, + removeCompletionNeed, + handleCompletionOptionalChange, + addCompletionOptional, + removeCompletionOptional, +}: Props) { + // Color options for the dropdown + const colorOptions = [ + { value: "blue", label: "Blue", className: "react-flow__node-topic blue" }, + { value: "yellow", label: "Yellow", className: "react-flow__node-topic yellow" }, + { value: "lila", label: "Lila", className: "react-flow__node-topic lila" }, + { value: "green", label: "Green", className: "react-flow__node-topic green" }, + { value: "red", label: "Red", className: "react-flow__node-topic red" }, + { value: "black", label: "Black", className: "react-flow__node-topic black" }, + { value: "white", label: "White", className: "react-flow__node-topic white", border: "1px solid #d1d5db" }, + ]; + + // Determine default color based on node type + let defaultColor = "blue"; + if (localNode.type === "topic") defaultColor = "yellow"; + const selectedColor = localNode.data?.color || defaultColor; + return ( + + + Node Color + + {colorOptions.map(opt => ( + handleFieldChange("color", opt.value)} + className={opt.className} + style={{ + width: 28, + height: 28, + borderRadius: 6, + cursor: "pointer", + fontWeight: "bold", + boxSizing: "border-box", + display: "inline-block", + padding: 0, + }} + >{selectedColor === opt.value ? "X" : ""} + ))} + + + + Label * + handleFieldChange("label", e.target.value)} + placeholder="Node label" + /> + + + Summary + handleFieldChange("summary", e.target.value)} + placeholder="Short summary" + /> + + + Description + handleFieldChange("description", e.target.value)} + placeholder="Detailed description" + rows={4} + /> + + + Duration + handleFieldChange("duration", e.target.value)} + placeholder="e.g., 30 min" + /> + + + Video URL + handleFieldChange("video", e.target.value)} + placeholder="YouTube or video URL" + /> + + + Resources + {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( + + handleResourceChange(idx, "label", e.target.value)} + placeholder="Label" + style={{ flex: 1 }} + /> + handleResourceChange(idx, "url", e.target.value)} + placeholder="URL" + style={{ flex: 2 }} + /> + removeResource(idx)} className="icon-button"> + + + + ))} + + Add Resource + + + + Unlock Password + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })} + placeholder="Optional password" + /> + + + Unlock Date + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })} + /> + + + Unlock After + {(localNode.data.unlock?.after || []).map((id: string, idx: number) => ( + + {renderNodeSelect(id, newId => handleUnlockAfterChange(idx, newId))} + removeUnlockAfter(idx)} className="icon-button"> + + + + ))} + + Add Unlock After + + + {localNode.type === "topic" && + Completion Needs + {(localNode.data.completion?.needs || []).map((need: string, idx: number) => ( + + {renderNodeSelect(need, newId => handleCompletionNeedsChange(idx, newId))} + removeCompletionNeed(idx)} className="icon-button"> + + + + ))} + + Add Need + + } + {localNode.type === "topic" && + Completion Optional + {(localNode.data.completion?.optional || []).map((opt: string, idx: number) => ( + + {renderNodeSelect(opt, newId => handleCompletionOptionalChange(idx, newId))} + removeCompletionOptional(idx)} className="icon-button"> + + + + ))} + + Add Optional + + } + + ); +} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx new file mode 100644 index 00000000..d136691d --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx @@ -0,0 +1,44 @@ +import { Node } from "@xyflow/react"; +import { TextNodeData } from "./types"; +import { ColorSelector } from "./ColorSelector"; +import { RotationInput } from "./RotationInput"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props) { + return ( + + + Text + handleFieldChange("text", e.target.value)} + placeholder="Background Text" + /> + + + Font Size + handleFieldChange("fontSize", Number(e.target.value))} + /> + + + handleFieldChange("color", color)} + /> + + handleFieldChange("rotation", v)} + /> + + ); +} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx new file mode 100644 index 00000000..ee7ed8e3 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx @@ -0,0 +1,6 @@ +import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; + +// For now, topic content is the same as task content. You can customize later if needed. +export function EditorDrawerTopicContent(props: any) { + return ; +} diff --git a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx new file mode 100644 index 00000000..120e68da --- /dev/null +++ b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Menu, MenuButton, MenuItem } from "@szhsin/react-menu"; +import "@szhsin/react-menu/dist/index.css"; +import '@szhsin/react-menu/dist/transitions/zoom.css'; +import { Save, Plus, Bug, Settings, Eye } from "lucide-react"; + +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; + onOpenBackgroundDrawer: () => void; + onSave: () => void; +} + +export const EditorToolbar: React.FC = ({ + debugMode, + previewMode, + showCompletionNeeds, + showCompletionOptional, + showUnlockAfter, + onTogglePreviewMode, + onToggleDebugMode, + onSetShowCompletionNeeds, + onSetShowCompletionOptional, + onSetShowUnlockAfter, + onAddNewNode, + onOpenBackgroundDrawer, + onSave, +}) => ( + + + Nodes}> + onAddNewNode("task")}>Add Task + onAddNewNode("topic")}>Add Topic + onAddNewNode("image")}>Add Image + onAddNewNode("text")}>Add Text + + + Background + + + + Debug}> + + Enable Debug Mode + + onSetShowCompletionNeeds(e.checked ?? false)} disabled={!debugMode}> + Show Completion Needs Edges + + onSetShowCompletionOptional(e.checked ?? false)} disabled={!debugMode}> + Show Completion Optional Edges + + onSetShowUnlockAfter(e.checked ?? false)} disabled={!debugMode}> + Show Unlock After Edges + + + + Preview + + + Save + + + +); diff --git a/packages/web-component-learningmap-editor/src/FloatingEdge.tsx b/packages/web-component-learningmap-editor/src/FloatingEdge.tsx new file mode 100644 index 00000000..29b7528b --- /dev/null +++ b/packages/web-component-learningmap-editor/src/FloatingEdge.tsx @@ -0,0 +1,39 @@ +import { Edge, getBezierPath, useInternalNode } from '@xyflow/react'; + +import { getEdgeParams } from './helper'; + +const FloatingEdge = ({ id, source, target, markerEnd, style }: Edge) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ( + + ); +} + +export default FloatingEdge; + diff --git a/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx b/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx index f14c3cb1..2c4bd301 100644 --- a/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx +++ b/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx @@ -1,793 +1,19 @@ -import { useState, useCallback, useEffect } from "react"; -import { getAutoLayoutedNodesElk } from "./autoLayoutElk"; -import * as yaml from "js-yaml"; import { - ReactFlow, - Controls, - useNodesState, - useEdgesState, ReactFlowProvider, - Handle, - Position, - ColorMode, - useReactFlow, - Node, - addEdge, - Connection, - Edge, } from "@xyflow/react"; -import { - Save, - Plus, - Settings, - Trash2, - X, -} from "lucide-react"; - -// ============================================================================ -// TYPES & INTERFACES -// ============================================================================ - -interface UnlockCondition { - after?: string[]; - date?: string; - password?: string; -} - -interface CompletionNeed { - id: string; - source?: string; - target?: string; -} - -interface Completion { - needs?: CompletionNeed[]; - optional?: CompletionNeed[]; -} - -interface NodeData { - label: string; - description?: string; - duration?: string; - unlock?: UnlockCondition; - completion?: Completion; - video?: string; - resources?: { label: string; url: string }[]; - summary?: string; - [key: string]: any; -} - -interface BackgroundConfig { - color?: string; - image?: { - src: string; - x?: number; - y?: number; - }; -} - -interface EdgeConfig { - animated?: boolean; - color?: string; - width?: number; - type?: string; -} - -interface RoadmapData { - nodes?: Node[]; - edges?: Edge[]; - background?: BackgroundConfig; - edgeConfig?: EdgeConfig; -} - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -const parseRoadmapData = (roadmapData: string | RoadmapData): RoadmapData => { - if (typeof roadmapData !== "string") { - return roadmapData || {}; - } - - try { - return JSON.parse(roadmapData); - } catch { - try { - return yaml.load(roadmapData) as RoadmapData; - } catch (err) { - console.error("Failed to parse roadmap data:", err); - return {}; - } - } -}; - -const generateEdgesFromCompletionNeeds = (nodes: Node[], edgeConfig: EdgeConfig) => { - const edges: Edge[] = []; - nodes.forEach((node) => { - const needs = node.data?.completion?.needs || []; - needs.forEach((need: CompletionNeed) => { - if (need.id) { - edges.push({ - id: `${need.id}->${node.id}`, - source: need.id, - target: node.id, - sourceHandle: need.source || "bottom", - targetHandle: need.target || "top", - }); - } - }); - const optional = node.data?.completion?.optional || []; - optional.forEach((opt: CompletionNeed) => { - if (opt.id) { - edges.push({ - id: `${opt.id}->${node.id}-optional`, - source: opt.id, - target: node.id, - sourceHandle: opt.source || "bottom", - targetHandle: opt.target || "top", - style: { - strokeDasharray: "5,5", - strokeWidth: edgeConfig.width ?? 2, - stroke: edgeConfig.color ?? "#94a3b8" - }, - }); - } - }); - }); - return edges; -}; - -// ============================================================================ -// NODE COMPONENTS -// ============================================================================ - -const EditorNode = ({ data, type }: { data: NodeData; type: string }) => { - return ( - - - - {data.label || "Untitled"} - - - {data.summary && ( - - {data.summary} - - )} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - ); -}; - -const BackgroundNode = ({ data }: { data: { image?: { src: string } } }) => { - if (!data.image?.src) return null; - return ; -}; - -// ============================================================================ -// EDITOR DRAWER -// ============================================================================ - -const EditorDrawer = ({ - node, - isOpen, - onClose, - onUpdate, - onDelete -}: { - node: Node | null; - isOpen: boolean; - onClose: () => void; - onUpdate: (node: Node) => void; - onDelete: () => void; -}) => { - const [localNode, setLocalNode] = useState | null>(node); - - useEffect(() => { - setLocalNode(node); - }, [node]); - - if (!isOpen || !node) return null; - - const handleSave = () => { - if (!localNode) return; - onUpdate(localNode); - onClose(); - }; - - const handleFieldChange = (field: string, value: any) => { - setLocalNode((prev: Node | null) => ({ - ...prev!, - data: { ...prev!.data, [field]: value }, - })); - }; - - const handleResourceChange = (index: number, field: string, value: string) => { - if (!localNode) return; - const resources = [...(localNode.data.resources || [])]; - resources[index] = { ...resources[index], [field]: value }; - handleFieldChange("resources", resources); - }; - - const addResource = () => { - if (!localNode) return; - const resources = [...(localNode.data.resources || []), { label: "", url: "" }]; - handleFieldChange("resources", resources); - }; - - const removeResource = (index: number) => { - if (!localNode) return; - const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index); - handleFieldChange("resources", resources); - }; - - const handleUnlockAfterChange = (value: string) => { - if (!localNode) return; - const after = value.split(",").map((s) => s.trim()).filter(Boolean); - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); - }; - - const handleCompletionNeedsChange = (value: string) => { - if (!localNode) return; - const needs = value.split(",").map((s) => s.trim()).filter(Boolean).map((id) => ({ id })); - handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); - }; - - const handleCompletionOptionalChange = (value: string) => { - if (!localNode) return; - const optional = value.split(",").map((s) => s.trim()).filter(Boolean).map((id) => ({ id })); - handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); - }; - - return ( - <> - - - - Edit Node - - - - - - - - Node Type - setLocalNode({ ...localNode, type: e.target.value })} - > - Task - Topic - - - - - Label * - handleFieldChange("label", e.target.value)} - placeholder="Node label" - /> - - - - Summary - handleFieldChange("summary", e.target.value)} - placeholder="Short summary" - /> - - - - Description - handleFieldChange("description", e.target.value)} - placeholder="Detailed description" - rows={4} - /> - - - - Duration - handleFieldChange("duration", e.target.value)} - placeholder="e.g., 30 min" - /> - - - - Video URL - handleFieldChange("video", e.target.value)} - placeholder="YouTube or video URL" - /> - - - - Resources - {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( - - handleResourceChange(idx, "label", e.target.value)} - placeholder="Label" - style={{ flex: 1 }} - /> - handleResourceChange(idx, "url", e.target.value)} - placeholder="URL" - style={{ flex: 2 }} - /> - removeResource(idx)} className="icon-button"> - - - - ))} - - Add Resource - - - - - Unlock Password - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })} - placeholder="Optional password" - /> - - - - Unlock Date - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })} - /> - - - - Unlock After (comma-separated node IDs) - handleUnlockAfterChange(e.target.value)} - placeholder="e.g., node1, node2" - /> - - - - Completion Needs (comma-separated node IDs) - n.id).join(", ")} - onChange={(e) => handleCompletionNeedsChange(e.target.value)} - placeholder="e.g., node1, node2" - /> - - - - Completion Optional (comma-separated node IDs) - n.id).join(", ")} - onChange={(e) => handleCompletionOptionalChange(e.target.value)} - placeholder="e.g., node3, node4" - /> - - - - - - Delete Node - - - Save Changes - - - - > - ); -}; - -// ============================================================================ -// BACKGROUND SETTINGS DRAWER -// ============================================================================ - -const BackgroundDrawer = ({ - isOpen, - onClose, - background, - onUpdate -}: { - isOpen: boolean; - onClose: () => void; - background: BackgroundConfig; - onUpdate: (bg: BackgroundConfig) => void; -}) => { - const [localBg, setLocalBg] = useState(background); - - useEffect(() => { - setLocalBg(background); - }, [background]); - - if (!isOpen) return null; - - const handleSave = () => { - onUpdate(localBg); - onClose(); - }; - - return ( - <> - - - - Background Settings - - - - - - - - Background Color - setLocalBg({ ...localBg, color: e.target.value })} - /> - - - - Background Image URL - - setLocalBg({ - ...localBg, - image: { ...(localBg?.image || {}), src: e.target.value }, - }) - } - placeholder="Image URL" - /> - - - - Image X Position - - setLocalBg({ - ...localBg, - image: { src: localBg?.image?.src || "", ...(localBg?.image || {}), x: Number(e.target.value) }, - }) - } - /> - - - - Image Y Position - - setLocalBg({ - ...localBg, - image: { src: localBg?.image?.src || "", ...(localBg?.image || {}), y: Number(e.target.value) }, - }) - } - /> - - - - - - Save Changes - - - - > - ); -}; - -// ============================================================================ -// MAIN EDITOR COMPONENT -// ============================================================================ - -function HyperbookLearningmapEditorInner({ - roadmapData, - language = "en" -}: { - roadmapData: string | RoadmapData; - language?: string; -}) { - const { screenToFlowPosition } = useReactFlow(); - - const [nodes, setNodes] = useNodesState([]); - const [edges, setEdges] = useEdgesState([]); - const [colorMode] = useState("light"); - const [selectedNode, setSelectedNode] = useState | null>(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [backgroundDrawerOpen, setBackgroundDrawerOpen] = useState(false); - const [background, setBackground] = useState({ color: "#ffffff" }); - const [edgeConfig, setEdgeConfig] = useState({}); - const [nextNodeId, setNextNodeId] = useState(1); - - const parsedRoadmap = parseRoadmapData(roadmapData); - - // Initialize from roadmap data - useEffect(() => { - async function loadRoadmap() { - const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; - const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; - - setBackground(parsedRoadmap?.background || { color: "#ffffff" }); - setEdgeConfig(parsedRoadmap?.edgeConfig || {}); - - const rawNodes = nodesArr.map((n) => ({ - ...n, - draggable: true, - data: { ...n.data }, - })); - - const rawEdges: Edge[] = edgesArr.length > 0 - ? edgesArr as Edge[] - : generateEdgesFromCompletionNeeds(rawNodes, edgeConfig); - - setEdges(rawEdges); - - const needsLayout = rawNodes.some((n: Node) => !n.position); - if (needsLayout) { - const autoNodes = await getAutoLayoutedNodesElk(rawNodes, rawEdges); - setNodes(autoNodes); - } else { - 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); - } - } - loadRoadmap(); - }, [roadmapData]); - - // Event handlers - const onNodeClick = useCallback((_: any, node: Node) => { - if (node.type === "background") return; - setSelectedNode(node); - setDrawerOpen(true); - }, []); - - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - }, - [setEdges] - ); - - const closeDrawer = useCallback(() => { - setDrawerOpen(false); - setSelectedNode(null); - }, []); - - const updateNode = useCallback( - (updatedNode: Node) => { - setNodes((nds) => - nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) - ); - setSelectedNode(updatedNode); - }, - [setNodes] - ); - - const deleteNode = useCallback(() => { - if (!selectedNode) return; - setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); - setEdges((eds) => - eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id) - ); - closeDrawer(); - }, [selectedNode, setNodes, setEdges, closeDrawer]); - - const addNewNode = useCallback( - (type: "task" | "topic") => { - const newNode: Node = { - id: `node${nextNodeId}`, - type, - position: screenToFlowPosition({ x: 100, y: 100 }), - data: { - label: `New ${type}`, - summary: "", - description: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - }, - [nextNodeId, screenToFlowPosition, setNodes] - ); - - const handleSave = useCallback(() => { - const roadmapData: RoadmapData = { - nodes: nodes.map((n) => ({ - id: n.id, - type: n.type, - position: n.position, - data: n.data, - })), - edges: edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle, - targetHandle: e.targetHandle, - style: e.style, - })), - background, - edgeConfig, - }; - - const root = document.querySelector("hyperbook-learningmap-editor"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); - } - }, [nodes, edges, background, edgeConfig]); - - // Create background node if configured - const backgroundNode = background?.image?.src - ? { - id: "background-image", - type: "background", - position: { x: background.image.x ?? 0, y: background.image.y ?? 0 }, - data: { image: { src: background.image.src } }, - draggable: false, - selectable: false, - focusable: false, - zIndex: -1, - } - : null; - - const displayNodes = [...(backgroundNode ? [backgroundNode] : []), ...nodes]; - - const nodeTypes = { - topic: (props: any) => , - task: (props: any) => , - background: BackgroundNode, - }; - - const defaultEdgeOptions = { - animated: edgeConfig.animated ?? false, - style: { - stroke: edgeConfig.color ?? "#94a3b8", - strokeWidth: edgeConfig.width ?? 2, - }, - type: edgeConfig.type ?? "default", - }; - - return ( - - {/* Toolbar */} - - - addNewNode("task")} className="toolbar-button"> - Add Task - - addNewNode("topic")} className="toolbar-button"> - Add Topic - - setBackgroundDrawerOpen(true)} className="toolbar-button"> - Background - - - - Save - - - - {/* Editor Canvas */} - - - - - - - {/* Drawers */} - - setBackgroundDrawerOpen(false)} - background={background} - onUpdate={setBackground} - /> - - ); -} - -export function HyperbookLearningmapEditor({ - roadmapData, - language -}: { - roadmapData: string | RoadmapData; +import { LearningMapEditor } from "./LearningMapEditor"; +import { RoadmapData } from "./types"; + +export function HyperbookLearningmapEditor({ + roadmapData, + language +}: { + roadmapData: string | RoadmapData; language?: string; }) { return ( - diff --git a/packages/web-component-learningmap-editor/src/LearningMap.tsx b/packages/web-component-learningmap-editor/src/LearningMap.tsx new file mode 100644 index 00000000..c5415bde --- /dev/null +++ b/packages/web-component-learningmap-editor/src/LearningMap.tsx @@ -0,0 +1,126 @@ +import { Controls, Edge, Node, ReactFlow, useEdgesState, useNodesState } from "@xyflow/react"; +import { ImageNode } from "./nodes/ImageNode"; +import { TaskNode } from "./nodes/TaskNode"; +import { TextNode } from "./nodes/TextNode"; +import { TopicNode } from "./nodes/TopicNode"; +import { BackgroundConfig, EdgeConfig, NodeData, RoadmapData } from "./types"; +import { useCallback, useEffect, useState } from "react"; +import { parseRoadmapData } from "./helper"; + +const nodeTypes = { + topic: TopicNode, + task: TaskNode, + image: ImageNode, + text: TextNode, +}; + +export function LearningMap({ + roadmapData, + language = "en" +}: { + roadmapData: string | RoadmapData; + language?: string; +}) { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedNode, setSelectedNode] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [background, setBackground] = useState({ color: "#ffffff" }); + const [edgeConfig, setEdgeConfig] = useState({}); + + const parsedRoadmap = parseRoadmapData(roadmapData); + + useEffect(() => { + async function loadRoadmap() { + const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; + const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; + + setBackground(parsedRoadmap?.background || { color: "#ffffff" }); + setEdgeConfig(parsedRoadmap?.edgeConfig || {}); + + const rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: false, + data: { ...n.data }, + })); + + setEdges(edgesArr); + setNodes(rawNodes); + } + loadRoadmap(); + }, [roadmapData]); + + const onNodeClick = useCallback((_: any, node: Node) => { + setSelectedNode(node); + setDrawerOpen(true); + }, []); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + setSelectedNode(null); + }, []); + + const updateNode = useCallback( + (updatedNode: Node) => { + setNodes((nds) => + nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + ); + setSelectedNode(updatedNode); + }, + [setNodes] + ); + + const handleSave = useCallback(() => { + const root = document.querySelector("hyperbook-learningmap-editor"); + if (root) { + root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); + } + }, [nodes]); + + const defaultEdgeOptions = { + animated: edgeConfig.animated ?? false, + style: { + stroke: edgeConfig.color ?? "#94a3b8", + strokeWidth: edgeConfig.width ?? 2, + }, + type: edgeConfig.type ?? "default", + }; + + return ( + + { + const className = []; + if (n.data?.color) { + className.push(n.data.color); + } + return { + ...n, + className: className.join(" "), + data: { + ...n.data, + } + }; + })} + edges={edges} + onEdgesChange={onEdgesChange} + onNodeClick={onNodeClick} + onNodesChange={onNodesChange} + nodeTypes={nodeTypes} + fitView + nodeOrigin={[0.5, 0.5]} + proOptions={{ hideAttribution: true }} + defaultEdgeOptions={defaultEdgeOptions} + nodesDraggable={false} + nodesConnectable={false} + > + + + + ) +} diff --git a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx new file mode 100644 index 00000000..fd9cdf00 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx @@ -0,0 +1,425 @@ +import { useState, useCallback, useEffect } from "react"; +import { + ReactFlow, + Controls, + useNodesState, + useEdgesState, + ColorMode, + useReactFlow, + Node, + addEdge, + Connection, + Edge, + Background, +} 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, BackgroundConfig, EdgeConfig } from "./types"; +import { BackgroundDrawer } from "./BackgroundDrawer"; +import FloatingEdge from "./FloatingEdge"; +import { EditorToolbar } from "./EditorToolbar"; +import { parseRoadmapData } from "./helper"; +import { LearningMap } from "./LearningMap"; + +const nodeTypes = { + topic: TopicNode, + task: TaskNode, + image: ImageNode, + text: TextNode, +}; + +const edgeTypes = { + floating: FloatingEdge +}; + + +export function LearningMapEditor({ + roadmapData, + language = "en" +}: { + roadmapData: string | RoadmapData; + language?: string; +}) { + const { screenToFlowPosition } = useReactFlow(); + + const [previewMode, setPreviewMode] = useState(false); + const [debugMode, setDebugMode] = useState(false); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [colorMode] = useState("light"); + const [selectedNode, setSelectedNode] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [backgroundDrawerOpen, setBackgroundDrawerOpen] = useState(false); + const [background, setBackground] = useState({ color: "#ffffff" }); + const [edgeConfig, setEdgeConfig] = useState({}); + const [nextNodeId, setNextNodeId] = useState(1); + + // Debug settings state + const [showCompletionNeeds, setShowCompletionNeeds] = useState(true); + const [showCompletionOptional, setShowCompletionOptional] = useState(true); + const [showUnlockAfter, setShowUnlockAfter] = useState(true); + + // Edge drawer state + const [selectedEdge, setSelectedEdge] = useState(null); + const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false); + + // Track Shift key state + const [shiftPressed, setShiftPressed] = useState(false); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(true); + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + const parsedRoadmap = parseRoadmapData(roadmapData); + + // Initialize from roadmap data + useEffect(() => { + async function loadRoadmap() { + const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; + const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; + + setBackground(parsedRoadmap?.background || { color: "#ffffff" }); + setEdgeConfig(parsedRoadmap?.edgeConfig || {}); + + const rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: true, + 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); + } + } + loadRoadmap(); + }, [roadmapData]); + + 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) => { + setSelectedNode(node); + setDrawerOpen(true); + }, []); + + const onEdgeClick = useCallback((_: any, edge: Edge) => { + setSelectedEdge(edge); + setEdgeDrawerOpen(true); + }, []); + + const onConnect = useCallback( + (connection: Connection) => { + setEdges((eds) => addEdge(connection, eds)); + }, + [setEdges] + ); + + const toggleDebugMode = useCallback(() => { + setDebugMode((mode) => !mode); + }, [setDebugMode]); + + const togglePreviewMode = useCallback(() => { + setPreviewMode((mode) => !mode); + }, [setPreviewMode]); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + setSelectedNode(null); + setEdgeDrawerOpen(false); + setSelectedEdge(null); + setBackgroundDrawerOpen(false) + }, []); + + const updateNode = useCallback( + (updatedNode: Node) => { + setNodes((nds) => + nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + ); + setSelectedNode(updatedNode); + }, + [setNodes] + ); + + const updateEdge = useCallback( + (updatedEdge: Edge) => { + setEdges((eds) => + eds.map((e) => (e.id === updatedEdge.id ? { ...e, ...updatedEdge } : e)) + ); + setSelectedEdge(updatedEdge); + }, + [setEdges] + ); + + // Delete selected edge + const deleteEdge = useCallback(() => { + if (!selectedEdge) return; + setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id)); + closeDrawer(); + }, [selectedEdge, setEdges, closeDrawer]); + + const deleteNode = useCallback(() => { + if (!selectedNode) return; + setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); + setEdges((eds) => + eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id) + ); + closeDrawer(); + }, [selectedNode, setNodes, setEdges, closeDrawer]); + + const addNewNode = useCallback( + (type: "task" | "topic" | "image" | "text") => { + if (type === "task") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position: screenToFlowPosition({ x: 100, y: 100 }), + data: { + label: `New ${type}`, + summary: "", + description: "", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } else if (type === "topic") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position: screenToFlowPosition({ x: 100, y: 100 }), + data: { + label: `New ${type}`, + 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: screenToFlowPosition({ x: 100, y: 100 }), + data: { + src: "", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } else if (type === "text") { + const newNode: Node = { + id: `background-node${nextNodeId}`, + type, + position: screenToFlowPosition({ x: 100, y: 100 }), + zIndex: -1, + data: { + text: "Background Text", + fontSize: 32, + color: "#e5e7eb", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } + }, + [nextNodeId, screenToFlowPosition, setNodes] + ); + + const handleSave = useCallback(() => { + const roadmapData: RoadmapData = { + nodes: nodes.map((n) => ({ + id: n.id, + type: n.type, + position: n.position, + 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, + })), + background, + edgeConfig, + }; + + const root = document.querySelector("hyperbook-learningmap-editor"); + if (root) { + root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); + } + }, [nodes, edges, background, edgeConfig]); + + const defaultEdgeOptions = { + animated: edgeConfig.animated ?? false, + style: { + stroke: edgeConfig.color ?? "#94a3b8", + strokeWidth: edgeConfig.width ?? 2, + }, + type: edgeConfig.type ?? "default", + }; + + // Toolbar handler wrappers for EditorToolbar props + const handleOpenBackgroundDrawer = useCallback(() => setBackgroundDrawerOpen(true), []); + const handleSetShowCompletionNeeds = useCallback((checked: boolean) => setShowCompletionNeeds(checked), []); + const handleSetShowCompletionOptional = useCallback((checked: boolean) => setShowCompletionOptional(checked), []); + const handleSetShowUnlockAfter = useCallback((checked: boolean) => setShowUnlockAfter(checked), []); + + return ( + + + {previewMode && } + {!previewMode && <> + + { + const className = []; + if (n.data?.color) { + className.push(n.data.color); + } + return { + ...n, + className: className.join(" ") + }; + })} + edges={edges} + onEdgesChange={onEdgesChange} + onNodeClick={onNodeClick} + onEdgeClick={onEdgeClick} + onNodesChange={onNodesChange} + onConnect={onConnect} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + fitView + snapToGrid={!shiftPressed} + nodeOrigin={[0.5, 0.5]} + proOptions={{ hideAttribution: true }} + defaultEdgeOptions={defaultEdgeOptions} + nodesDraggable={true} + nodesConnectable={true} + colorMode={colorMode} + > + + + + + + + + > + } + + ); +} diff --git a/packages/web-component-learningmap-editor/src/RotationInput.tsx b/packages/web-component-learningmap-editor/src/RotationInput.tsx new file mode 100644 index 00000000..04da8b3d --- /dev/null +++ b/packages/web-component-learningmap-editor/src/RotationInput.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface RotationInputProps { + value: number; + onChange: (value: number) => void; +} + +export function RotationInput({ value, onChange }: RotationInputProps) { + return ( + + Rotation (degrees): {value}° + + onChange(Number(e.target.value))} + style={{ flex: 1 }} + /> + { + let v = Number(e.target.value); + if (isNaN(v)) v = 0; + if (v < 0) v = 0; + if (v > 360) v = 360; + onChange(v); + }} + style={{ width: 100 }} + /> + + + ); +} diff --git a/packages/web-component-learningmap-editor/src/helper.ts b/packages/web-component-learningmap-editor/src/helper.ts new file mode 100644 index 00000000..e0ad019e --- /dev/null +++ b/packages/web-component-learningmap-editor/src/helper.ts @@ -0,0 +1,86 @@ +import { Position, Node } from "@xyflow/react"; +import { RoadmapData } from "./types"; + +// this helper function returns the intersection point +// of the line between the center of the intersectionNode and the target node +function getNodeIntersection(intersectionNode: Node, targetNode: Node) { + // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = + intersectionNode.measured; + const intersectionNodePosition = intersectionNode.internals.positionAbsolute; + const targetPosition = targetNode.internals.positionAbsolute; + + const w = intersectionNodeWidth / 2; + const h = intersectionNodeHeight / 2; + + const x2 = intersectionNodePosition.x + w; + const y2 = intersectionNodePosition.y + h; + const x1 = targetPosition.x + targetNode.measured.width / 2; + const y1 = targetPosition.y + targetNode.measured.height / 2; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +} + +// returns the position (top,right,bottom or right) passed node compared to the intersection point +function getEdgePosition(node: Node, intersectionPoint: any) { + const n = { ...node.internals.positionAbsolute, ...node }; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= nx + 1) { + return Position.Left; + } + if (px >= nx + n.measured.width - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y + n.measured.height - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge +export function getEdgeParams(source: Node, target: Node) { + const sourceIntersectionPoint = getNodeIntersection(source, target); + const targetIntersectionPoint = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + return { + sx: sourceIntersectionPoint.x, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +} + +export const parseRoadmapData = ( + roadmapData: string | RoadmapData, +): RoadmapData => { + if (typeof roadmapData !== "string") { + return roadmapData || {}; + } + try { + return JSON.parse(roadmapData); + } catch (err) { + console.error("Failed to parse roadmap data:", err); + return {}; + } +}; diff --git a/packages/web-component-learningmap-editor/src/index.css b/packages/web-component-learningmap-editor/src/index.css index 347e56f8..9145fed9 100644 --- a/packages/web-component-learningmap-editor/src/index.css +++ b/packages/web-component-learningmap-editor/src/index.css @@ -56,6 +56,24 @@ border-color: #2563eb; } +.toolbar-button.active { + background: var(--color-brand, #3b82f6); + color: white; + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.active:hover { + background: #2563eb; + border-color: #2563eb; +} + +.toolbar-button:disabled { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); + border-color: var(--color-nav-border, #e5e7eb); + cursor: not-allowed; +} + /* Editor Canvas */ .editor-canvas { flex: 1; @@ -64,17 +82,6 @@ width: 100%; } -/* Editor Nodes */ -.editor-node { - cursor: pointer; - transition: all 0.2s; -} - -.editor-node:hover { - transform: translateY(-2px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1) !important; -} - .react-flow__node-background img { max-width: none; } @@ -95,6 +102,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -104,6 +112,7 @@ from { transform: translateX(100%); } + to { transform: translateX(0); } @@ -196,6 +205,10 @@ transition: border-color 0.2s; } +.form-group input[type="color"] { + height: 64px; +} + .form-group input:focus, .form-group textarea:focus, .form-group select:focus { @@ -294,3 +307,109 @@ background: var(--color-brand, #3b82f6); border: 2px solid white; } + +.react-flow__edge.selected { + stroke: var(--color-brand, #3b82f6); + stroke-width: 4px; +} + +.react-flow__node-image img { + width: 100%; +} + +.react-flow__node-task { + padding: 16px 24px; + border-radius: 16px; + border: 2px solid; + border-color: #3b82f6; + background: #f0f7ff; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + + .check-icon { + position: absolute; + top: -10px; + right: -10px; + fill: #fff; + stroke: #3b82f6; + } + +} + +.react-flow__node-topic { + padding: 16px 24px; + border-radius: 16px; + border: 2px solid; + border-color: #f59e42; + background: #fffbe6; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.react-flow__node-topic.black, +.react-flow__node-task.black { + border-color: #374151; + background: #f3f4f6; + + .check-icon { + stroke: #374151; + } +} + +.react-flow__node-topic.white, +.react-flow__node-task.white { + border-color: #9ca3af; + background: #f9fafb; + + .check-icon { + stroke: #9ca3af; + } +} + +.react-flow__node-topic.lila, +.react-flow__node-task.lila { + border-color: #9e86ed; + background: #f3e8ff; + + .check-icon { + stroke: #9e86ed; + } +} + +.react-flow__node-topic.green, +.react-flow__node-task.green { + border-color: #10b981; + background: #ecfdf5; + + .check-icon { + stroke: #10b981; + } +} + +.react-flow__node-topic.yellow, +.react-flow__node-task.yellow { + border-color: #f59e42; + background: #fffbeb; + + .check-icon { + stroke: #f59e42; + } +} + +.react-flow__node-topic.red, +.react-flow__node-task.red { + border-color: #ef4444; + background: #fef2f2; + + .check-icon { + stroke: #ef4444; + } +} + +.react-flow__node-topic.blue, +.react-flow__node-task.blue { + border-color: #3b82f6; + background: #eff6ff; + + .check-icon { + stroke: #3b82f6; + } +} diff --git a/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx b/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx new file mode 100644 index 00000000..c7bbf411 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx @@ -0,0 +1,25 @@ +import { Node, NodeResizer } from "@xyflow/react"; +import { ImageNodeData } from "../types"; + +export const ImageNode = ({ data, selected }: Node) => { + return ( + <> + {data.data ? ( + <> + + + + + > + ) : ( + No Image + )} + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx new file mode 100644 index 00000000..d963b276 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx @@ -0,0 +1,45 @@ +import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; +import { NodeData } from "../types"; +import { CircleCheck } from "lucide-react"; + +export const TaskNode = ({ data, selected, isConnectable, ...props }: Node) => { + console.log(props); + return ( + <> + {isConnectable && } + + + + {data.label || "Untitled"} + + + {data.summary && ( + + {data.summary} + + )} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx new file mode 100644 index 00000000..0eb899c8 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx @@ -0,0 +1,19 @@ +import { Node } from "@xyflow/react"; +import { TextNodeData } from "../types"; + +export const TextNode = ({ data }: Node) => { + return ( + <> + + {data.text || "No Text"} + + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx new file mode 100644 index 00000000..22f0a565 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx @@ -0,0 +1,42 @@ +import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; +import { NodeData } from "../types"; + +export const TopicNode = ({ data, selected, isConnectable }: Node) => { + return ( + <> + {isConnectable && } + + + {data.label || "Untitled"} + + + {data.summary && ( + + {data.summary} + + )} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + > + ); +}; diff --git a/packages/web-component-learningmap-editor/src/types.ts b/packages/web-component-learningmap-editor/src/types.ts new file mode 100644 index 00000000..926a26bb --- /dev/null +++ b/packages/web-component-learningmap-editor/src/types.ts @@ -0,0 +1,85 @@ +import { Node, Edge, Box } from "@xyflow/react"; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export interface UnlockCondition { + after?: string[]; + date?: string; + password?: string; +} + +export interface CompletionNeed { + id: string; + source?: string; + target?: string; +} + +export interface Completion { + needs?: CompletionNeed[]; + optional?: CompletionNeed[]; +} + +export interface NodeData { + state: "locked" | "unlocked" | "started" | "completed" | "mastered"; + label: string; + description?: string; + duration?: string; + unlock?: UnlockCondition; + completion?: Completion; + video?: string; + resources?: { label: string; url: string }[]; + summary?: string; + [key: string]: any; +} + +export interface ImageNodeData { + data?: string; // base64 encoded image +} + +export interface TextNodeData { + text?: string; + fontSize?: number; + color?: string; + rotation?: number; +} + +export type BackgroundNodeData = ImageNodeData | TextNodeData; + +export interface BackgroundConfig { + color?: string; + nodes?: Node[]; +} + +export interface EdgeConfig { + animated?: boolean; + color?: string; + width?: number; + type?: string; +} + +export interface RoadmapData { + nodes?: Node[]; + edges?: Edge[]; + background?: BackgroundConfig; + edgeConfig?: EdgeConfig; +} + +export type Orientation = "horizontal" | "vertical"; + +export type HelperLine = { + // Used to filter out helper lines corresponding to the node being dragged + node: Node; + // We use it to check that the helper line is within the viewport. + nodeBox: Box; + // 0 for horizontal, 1 for vertical + orientation: Orientation; + // If orientation is 'horizontal', `position` holds the Y coordinate of the helper line. + // (Might correspond to the top or bottom position of a node, or other anchors). + // If orientation is 'vertical', `position` holds the X coordinate of the helper line. + position: number; + // Optional color for the helper line + color?: string; + anchorName: string; +}; From f2209a6e8f686ca7c10a82170210de7f9bc58edd Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Thu, 9 Oct 2025 11:24:41 +0200 Subject: [PATCH 06/19] improve editor --- .../index.html | 3 + .../src/Drawer.tsx | 84 +++++ .../src/DrawerTaskContent.tsx | 0 .../src/DrawerTopicContent.tsx | 0 .../src/EditorDrawerTaskContent.tsx | 5 +- .../src/EditorToolbar.tsx | 42 +-- .../src/LearningMap.tsx | 114 ++++++- .../src/LearningMapEditor.tsx | 308 ++++++++++++++---- .../src/Video.tsx | 54 +++ .../src/icons/StarCircle.tsx | 23 ++ .../src/index.css | 194 ++++++++++- .../src/nodes/TaskNode.tsx | 3 +- .../src/nodes/TopicNode.tsx | 2 + .../src/useUndoable/errors.ts | 9 + .../src/useUndoable/index.ts | 126 +++++++ .../src/useUndoable/mutate.ts | 117 +++++++ .../src/useUndoable/reducer.ts | 88 +++++ .../src/useUndoable/types.ts | 63 ++++ 18 files changed, 1121 insertions(+), 114 deletions(-) delete mode 100644 packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx create mode 100644 packages/web-component-learningmap-editor/src/Video.tsx create mode 100644 packages/web-component-learningmap-editor/src/icons/StarCircle.tsx create mode 100644 packages/web-component-learningmap-editor/src/useUndoable/errors.ts create mode 100644 packages/web-component-learningmap-editor/src/useUndoable/index.ts create mode 100644 packages/web-component-learningmap-editor/src/useUndoable/mutate.ts create mode 100644 packages/web-component-learningmap-editor/src/useUndoable/reducer.ts create mode 100644 packages/web-component-learningmap-editor/src/useUndoable/types.ts diff --git a/packages/web-component-learningmap-editor/index.html b/packages/web-component-learningmap-editor/index.html index e3c3b581..2a9b2a3d 100644 --- a/packages/web-component-learningmap-editor/index.html +++ b/packages/web-component-learningmap-editor/index.html @@ -53,6 +53,9 @@ data: { label: "Advanced Topics", summary: "Deep dive into advanced concepts", + unlock: { + after: ["node1"] + }, completion: { needs: ["node1"] } diff --git a/packages/web-component-learningmap-editor/src/Drawer.tsx b/packages/web-component-learningmap-editor/src/Drawer.tsx index e69de29b..19adb2cf 100644 --- a/packages/web-component-learningmap-editor/src/Drawer.tsx +++ b/packages/web-component-learningmap-editor/src/Drawer.tsx @@ -0,0 +1,84 @@ +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; +import { X, Lock, CheckCircle } from "lucide-react"; +import { Video } from "./Video"; +import StarCircle from "./icons/StarCircle"; + +interface DrawerProps { + open: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + node: Node +} + +export function Drawer({ open, onClose, onUpdate, node }: DrawerProps) { + if (!open) return null; + + const locked = node.data?.state === 'locked' || false; + const unlocked = node.data?.state === 'unlocked' || false; + const completed = node.data?.state === 'completed' || false; + const started = node.data?.state === 'started' || false; + const mastered = node.data?.state === 'mastered' || false; + + const handleStateChange = (newState: 'locked' | 'unlocked' | 'started' | 'completed') => () => { + if (node.type === "topic" || locked) return; + + onUpdate({ + ...node, + data: { + ...node.data, + state: newState + } + }); + }; + + return ( + <> + + + > + ); +} diff --git a/packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx b/packages/web-component-learningmap-editor/src/DrawerTaskContent.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx b/packages/web-component-learningmap-editor/src/DrawerTopicContent.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx index d6d940dd..d23816e3 100644 --- a/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx +++ b/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx @@ -42,10 +42,11 @@ export function EditorDrawerTaskContent({ { value: "blue", label: "Blue", className: "react-flow__node-topic blue" }, { value: "yellow", label: "Yellow", className: "react-flow__node-topic yellow" }, { value: "lila", label: "Lila", className: "react-flow__node-topic lila" }, - { value: "green", label: "Green", className: "react-flow__node-topic green" }, + { value: "pink", label: "Pink", className: "react-flow__node-topic pink" }, + { value: "teal", label: "Teal", className: "react-flow__node-topic teal" }, { value: "red", label: "Red", className: "react-flow__node-topic red" }, { value: "black", label: "Black", className: "react-flow__node-topic black" }, - { value: "white", label: "White", className: "react-flow__node-topic white", border: "1px solid #d1d5db" }, + { value: "white", label: "White", className: "react-flow__node-topic white" }, ]; // Determine default color based on node type diff --git a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx index 120e68da..9a8873d3 100644 --- a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx +++ b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx @@ -1,10 +1,11 @@ import React from "react"; -import { Menu, MenuButton, MenuItem } from "@szhsin/react-menu"; +import { Menu, MenuButton, MenuItem, SubMenu } from "@szhsin/react-menu"; import "@szhsin/react-menu/dist/index.css"; import '@szhsin/react-menu/dist/transitions/zoom.css'; -import { Save, Plus, Bug, Settings, Eye } from "lucide-react"; +import { Save, Plus, Bug, Settings, Eye, Menu as MenuI } from "lucide-react"; interface EditorToolbarProps { + saved: boolean; debugMode: boolean; previewMode: boolean; showCompletionNeeds: boolean; @@ -21,6 +22,7 @@ interface EditorToolbarProps { } export const EditorToolbar: React.FC = ({ + saved, debugMode, previewMode, showCompletionNeeds, @@ -48,26 +50,28 @@ export const EditorToolbar: React.FC = ({ - Debug}> - - Enable Debug Mode + }> + Debug>}> + + Enable Debug Mode + + onSetShowCompletionNeeds(e.checked ?? false)} disabled={!debugMode}> + Show Completion Needs Edges + + onSetShowCompletionOptional(e.checked ?? false)} disabled={!debugMode}> + Show Completion Optional Edges + + onSetShowUnlockAfter(e.checked ?? false)} disabled={!debugMode}> + Show Unlock After Edges + + + + Preview - onSetShowCompletionNeeds(e.checked ?? false)} disabled={!debugMode}> - Show Completion Needs Edges - - onSetShowCompletionOptional(e.checked ?? false)} disabled={!debugMode}> - Show Completion Optional Edges - - onSetShowUnlockAfter(e.checked ?? false)} disabled={!debugMode}> - Show Unlock After Edges + + Save{!saved ? "*" : ""} - - Preview - - - Save - ); diff --git a/packages/web-component-learningmap-editor/src/LearningMap.tsx b/packages/web-component-learningmap-editor/src/LearningMap.tsx index c5415bde..5ea3cf17 100644 --- a/packages/web-component-learningmap-editor/src/LearningMap.tsx +++ b/packages/web-component-learningmap-editor/src/LearningMap.tsx @@ -6,6 +6,7 @@ import { TopicNode } from "./nodes/TopicNode"; import { BackgroundConfig, EdgeConfig, NodeData, RoadmapData } from "./types"; import { useCallback, useEffect, useState } from "react"; import { parseRoadmapData } from "./helper"; +import { Drawer } from "./Drawer"; const nodeTypes = { topic: TopicNode, @@ -14,12 +15,86 @@ 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"; +} + + export function LearningMap({ roadmapData, + onChange, language = "en" }: { roadmapData: string | RoadmapData; language?: string; + onChange?: (state: Record) => void; }) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -38,12 +113,16 @@ export function LearningMap({ setBackground(parsedRoadmap?.background || { color: "#ffffff" }); setEdgeConfig(parsedRoadmap?.edgeConfig || {}); - const rawNodes = nodesArr.map((n) => ({ + let rawNodes = nodesArr.map((n) => ({ ...n, draggable: false, - data: { ...n.data }, + connectable: false, + selectable: isInteractableNode(n), + focusable: isInteractableNode(n), })); + rawNodes = updateNodesStates(rawNodes); + setEdges(edgesArr); setNodes(rawNodes); } @@ -51,6 +130,7 @@ export function LearningMap({ }, [roadmapData]); const onNodeClick = useCallback((_: any, node: Node) => { + if (!isInteractableNode(node)) return; setSelectedNode(node); setDrawerOpen(true); }, []); @@ -62,18 +142,31 @@ export function LearningMap({ const updateNode = useCallback( (updatedNode: Node) => { - setNodes((nds) => - nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + setNodes((nds) => { + let newNodes = nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + newNodes = updateNodesStates(newNodes); + return newNodes; + } ); setSelectedNode(updatedNode); }, [setNodes] ); - const handleSave = useCallback(() => { - const root = document.querySelector("hyperbook-learningmap-editor"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); + useEffect(() => { + const minimalState: Record = {}; + nodes.forEach((n) => { + if (n.data.state) { + minimalState[n.id] = { state: n.data.state }; + } + }); + if (onChange) { + onChange(minimalState); + } else { + const root = document.querySelector("hyperbook-learningmap"); + if (root) { + root.dispatchEvent(new CustomEvent("change", { detail: minimalState })); + } } }, [nodes]); @@ -99,12 +192,10 @@ export function LearningMap({ if (n.data?.color) { className.push(n.data.color); } + className.push(n.data?.state); return { ...n, className: className.join(" "), - data: { - ...n.data, - } }; })} edges={edges} @@ -121,6 +212,7 @@ export function LearningMap({ > + ) } diff --git a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx index fd9cdf00..f8608554 100644 --- a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx +++ b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx @@ -11,6 +11,9 @@ import { Connection, Edge, Background, + ControlButton, + OnNodesChange, + OnEdgesChange, } from "@xyflow/react"; import { EditorDrawer } from "./EditorDrawer"; import { EdgeDrawer } from "./EdgeDrawer"; @@ -24,6 +27,8 @@ import FloatingEdge from "./FloatingEdge"; import { EditorToolbar } from "./EditorToolbar"; import { parseRoadmapData } from "./helper"; import { LearningMap } from "./LearningMap"; +import { Info, Redo, Undo, RotateCw } from "lucide-react"; +import useUndoable from "./useUndoable"; const nodeTypes = { topic: TopicNode, @@ -39,23 +44,45 @@ const edgeTypes = { export function LearningMapEditor({ roadmapData, - language = "en" + language = "en", + onChange, }: { roadmapData: string | RoadmapData; language?: string; + onChange?: (data: RoadmapData) => void; }) { + const keyboardShortcuts = [ + { action: "Save", shortcut: "Ctrl+S" }, + { action: "Undo", shortcut: "Ctrl+Z" }, + { action: "Redo", shortcut: "Ctrl+Y or Ctrl+Shift+Z" }, + { action: "Add Task Node", shortcut: "Ctrl+A" }, + { action: "Add Topic Node", shortcut: "Ctrl+O" }, + { action: "Add Image Node", shortcut: "Ctrl+I" }, + { action: "Add Text Node", shortcut: "Ctrl+X" }, + { action: "Delete Node/Edge", shortcut: "Delete" }, + { action: "Toggle Preview Mode", shortcut: "Ctrl+P" }, + { action: "Toggle Debug Mode", shortcut: "Ctrl+D" }, + { action: "Show Help", shortcut: "Ctrl+? or Help Button" }, + ]; + const { screenToFlowPosition } = useReactFlow(); + const parsedRoadmap = parseRoadmapData(roadmapData); + const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset }] = 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([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [background, setBackground] = useState({ color: "#ffffff" }); + const [edgeConfig, setEdgeConfig] = useState({}); + + const [helpOpen, setHelpOpen] = useState(false); const [colorMode] = useState("light"); const [selectedNode, setSelectedNode] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [backgroundDrawerOpen, setBackgroundDrawerOpen] = useState(false); - const [background, setBackground] = useState({ color: "#ffffff" }); - const [edgeConfig, setEdgeConfig] = useState({}); const [nextNodeId, setNextNodeId] = useState(1); // Debug settings state @@ -69,53 +96,44 @@ export function LearningMapEditor({ // Track Shift key state const [shiftPressed, setShiftPressed] = useState(false); + + const loadRoadmapStateIntoReactFlowState = useCallback(() => { + const nodesArr = Array.isArray(roadmapState?.nodes) ? roadmapState.nodes : []; + const edgesArr = Array.isArray(roadmapState?.edges) ? roadmapState.edges : []; + + setBackground(roadmapState?.background || { color: "#ffffff" }); + setEdgeConfig(roadmapState?.edgeConfig || {}); + + const rawNodes = nodesArr.map((n) => ({ + ...n, + draggable: true, + 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); + } + }, [roadmapState, setNodes, setEdges, setBackground, setEdgeConfig]); + useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift") setShiftPressed(true); - }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === "Shift") setShiftPressed(false); - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; + loadRoadmapStateIntoReactFlowState(); }, []); - const parsedRoadmap = parseRoadmapData(roadmapData); - - // Initialize from roadmap data useEffect(() => { - async function loadRoadmap() { - const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; - const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; - - setBackground(parsedRoadmap?.background || { color: "#ffffff" }); - setEdgeConfig(parsedRoadmap?.edgeConfig || {}); - - const rawNodes = nodesArr.map((n) => ({ - ...n, - draggable: true, - 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); - } + if (didUndoRedo) { + setDidUndoRedo(false); + loadRoadmapStateIntoReactFlowState(); } - loadRoadmap(); - }, [roadmapData]); + }, [roadmapState, didUndoRedo, loadRoadmapStateIntoReactFlowState]); useEffect(() => { const newEdges: Edge[] = edges.filter((e) => !e.id.startsWith("debug-")); @@ -181,8 +199,9 @@ export function LearningMapEditor({ const onConnect = useCallback( (connection: Connection) => { setEdges((eds) => addEdge(connection, eds)); + setSaved(false); }, - [setEdges] + [setEdges, setSaved] ); const toggleDebugMode = useCallback(() => { @@ -190,7 +209,14 @@ export function LearningMapEditor({ }, [setDebugMode]); const togglePreviewMode = useCallback(() => { - setPreviewMode((mode) => !mode); + setPreviewMode((mode) => { + const newMode = !mode; + if (newMode) { + setDebugMode(false); + closeDrawer(); + } + return newMode; + }); }, [setPreviewMode]); const closeDrawer = useCallback(() => { @@ -207,8 +233,9 @@ export function LearningMapEditor({ nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) ); setSelectedNode(updatedNode); + setSaved(false); }, - [setNodes] + [setNodes, setSelectedNode, setSaved] ); const updateEdge = useCallback( @@ -217,14 +244,16 @@ export function LearningMapEditor({ eds.map((e) => (e.id === updatedEdge.id ? { ...e, ...updatedEdge } : e)) ); setSelectedEdge(updatedEdge); + setSaved(false); }, - [setEdges] + [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]); @@ -234,16 +263,18 @@ export function LearningMapEditor({ setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id) ); + setSaved(false); closeDrawer(); - }, [selectedNode, setNodes, setEdges, closeDrawer]); + }, [selectedNode, setNodes, setEdges, closeDrawer, setSaved]); const addNewNode = useCallback( (type: "task" | "topic" | "image" | "text") => { + const centerPos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); if (type === "task") { const newNode: Node = { id: `node${nextNodeId}`, type, - position: screenToFlowPosition({ x: 100, y: 100 }), + position: centerPos, data: { label: `New ${type}`, summary: "", @@ -256,7 +287,7 @@ export function LearningMapEditor({ const newNode: Node = { id: `node${nextNodeId}`, type, - position: screenToFlowPosition({ x: 100, y: 100 }), + position: centerPos, data: { label: `New ${type}`, summary: "", @@ -271,7 +302,7 @@ export function LearningMapEditor({ id: `background-node${nextNodeId}`, type, zIndex: -2, - position: screenToFlowPosition({ x: 100, y: 100 }), + position: centerPos, data: { src: "", }, @@ -282,7 +313,7 @@ export function LearningMapEditor({ const newNode: Node = { id: `background-node${nextNodeId}`, type, - position: screenToFlowPosition({ x: 100, y: 100 }), + position: centerPos, zIndex: -1, data: { text: "Background Text", @@ -293,8 +324,9 @@ export function LearningMapEditor({ setNodes((nds) => [...nds, newNode]); setNextNodeId((id) => id + 1); } + setSaved(false); }, - [nextNodeId, screenToFlowPosition, setNodes] + [nextNodeId, screenToFlowPosition, setNodes, setSaved] ); const handleSave = useCallback(() => { @@ -320,9 +352,17 @@ export function LearningMapEditor({ edgeConfig, }; - const root = document.querySelector("hyperbook-learningmap-editor"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); + 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, background, edgeConfig]); @@ -341,9 +381,114 @@ export function LearningMapEditor({ 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]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(true); + //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.toLowerCase() === 'a' && !e.shiftKey) { + e.preventDefault(); + addNewNode("task"); + } + // add topic node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o' && !e.shiftKey) { + e.preventDefault(); + addNewNode("topic"); + } + // add image node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i' && !e.shiftKey) { + e.preventDefault(); + addNewNode("image"); + } + // add text node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { + e.preventDefault(); + addNewNode("text"); + } + + 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(); + } + // Dismiss with Escape + if (helpOpen && e.key === 'Escape') { + setHelpOpen(false); + } + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]); + return ( - + + + + + + + + + + + setHelpOpen(true)}> + + + + setHelpOpen(false)} + > + Keyboard Shortcuts + + + + Action + Shortcut + + + + {keyboardShortcuts.map((item) => ( + + {item.action} + {item.shortcut} + + ))} + + + setHelpOpen(false)}>Close + > } diff --git a/packages/web-component-learningmap-editor/src/Video.tsx b/packages/web-component-learningmap-editor/src/Video.tsx new file mode 100644 index 00000000..81b2ab8b --- /dev/null +++ b/packages/web-component-learningmap-editor/src/Video.tsx @@ -0,0 +1,54 @@ +function isYoutubeUrl(url: string) { + return ( + typeof url === "string" && + (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/")) + ); +} + +function getYoutubeEmbedUrl(url: string) { + if (url.includes("youtube.com/watch?v=")) { + const videoId = url.split("v=")[1].split("&")[0]; + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + if (url.includes("youtu.be/")) { + const videoId = url.split("youtu.be/")[1].split("?")[0]; + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + return url; +} + +function getVideoMimeType(url: string) { + if (url.endsWith(".webm")) return "video/webm"; + if (url.endsWith(".mp4")) return "video/mp4"; + return "video/mp4"; +} + +export const Video: React.FC<{ url: string; title?: string }> = ({ url, title }) => { + if (isYoutubeUrl(url)) { + const embedUrl = getYoutubeEmbedUrl(url); + return ( + + + + ); + } else { + const mimeType = getVideoMimeType(url); + return ( + + + Your browser does not support the video tag. + + ); + } +}; diff --git a/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx b/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx new file mode 100644 index 00000000..109f5aa7 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx @@ -0,0 +1,23 @@ +import { FC, SVGProps } from "react"; + +const StarCircle: FC> = (props) => ( + + + + +); + +export default StarCircle; diff --git a/packages/web-component-learningmap-editor/src/index.css b/packages/web-component-learningmap-editor/src/index.css index 9145fed9..9a61c083 100644 --- a/packages/web-component-learningmap-editor/src/index.css +++ b/packages/web-component-learningmap-editor/src/index.css @@ -148,6 +148,10 @@ border: none; } +.drawer-footer button { + width: 100%; +} + .close-button { background: none; border: none; @@ -280,6 +284,52 @@ background: #dc2626; } +.drawer-button { + padding: 14px 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + font-size: 14px; + cursor: pointer; +} + +.drawer-button:hover { + background: var(--color-spacer, #f3f4f6); + border-color: var(--color-brand, #3b82f6); +} + +.drawer-button.locked { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); + border-color: var(--color-nav-border, #e5e7eb); + cursor: not-allowed; +} + +.drawer-button.started { + background: #fef3c7; + border-color: #f59e42; + color: #b45309; +} + +.drawer-button.completed { + background: #d1fae5; + border-color: #10b981; + color: #065f46; +} + +.drawer-button.mastered { + background: #d1fae5; + border-color: #10b981; + color: #065f46; +} + .icon-button { padding: 6px; background: none; @@ -295,9 +345,21 @@ color: #ef4444; } -/* React Flow Controls */ +.react-flow__controls-button svg.lucide { + fill: none; +} + .react-flow__controls-button { - color: #000 !important; + transition: all 0.2s; +} + +.react-flow__controls-button:hover { + background: var(--color-spacer, #f3f4f6); +} + +.react-flow__controls-button:disabled { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); } /* React Flow Handles */ @@ -309,8 +371,7 @@ } .react-flow__edge.selected { - stroke: var(--color-brand, #3b82f6); - stroke-width: 4px; + outline: 1px solid var(--color-brand, #3b82f6); } .react-flow__node-image img { @@ -325,7 +386,7 @@ background: #f0f7ff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - .check-icon { + .icon { position: absolute; top: -10px; right: -10px; @@ -342,6 +403,14 @@ border-color: #f59e42; background: #fffbe6; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + + .icon { + position: absolute; + top: -10px; + left: -10px; + fill: #fff; + stroke: #f59e42; + } } .react-flow__node-topic.black, @@ -349,7 +418,7 @@ border-color: #374151; background: #f3f4f6; - .check-icon { + .icon { stroke: #374151; } } @@ -359,7 +428,7 @@ border-color: #9ca3af; background: #f9fafb; - .check-icon { + .icon { stroke: #9ca3af; } } @@ -369,18 +438,28 @@ border-color: #9e86ed; background: #f3e8ff; - .check-icon { + .icon { stroke: #9e86ed; } } -.react-flow__node-topic.green, -.react-flow__node-task.green { - border-color: #10b981; - background: #ecfdf5; +.react-flow__node-topic.pink, +.react-flow__node-task.pink { + border-color: #ec4899; + background: #fdf2f8; - .check-icon { - stroke: #10b981; + .icon { + stroke: #ec4899; + } +} + +.react-flow__node-topic.teal, +.react-flow__node-task.teal { + border-color: #14b8a6; + background: #e0f2fe; + + .icon { + stroke: #14b8a6; } } @@ -389,7 +468,7 @@ border-color: #f59e42; background: #fffbeb; - .check-icon { + .icon { stroke: #f59e42; } } @@ -399,7 +478,7 @@ border-color: #ef4444; background: #fef2f2; - .check-icon { + .icon { stroke: #ef4444; } } @@ -409,7 +488,88 @@ border-color: #3b82f6; background: #eff6ff; - .check-icon { + .icon { stroke: #3b82f6; } } + +.react-flow__node-task.completed, +.react-flow__node-topic.completed { + text-decoration: line-through; + border-color: #10b981; + background: #ecfdf5; + + .icon { + stroke: #10b981; + } +} + +.react-flow__node-task.locked, +.react-flow__node-topic.locked { + border-color: #6b7280; + background: #e5e7eb; + + .icon { + stroke: #6b7280; + } +} + +.react-flow__node-task.mastered, +.react-flow__node-topic.mastered { + text-decoration: line-through; + border-color: #10b981; + background: #ecfdf5; + + .icon { + stroke: #10b981; + } +} + +dialog.help[open] { + width: 600px; + max-width: 90vw; + border: none; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + padding: 24px; + background: var(--color-nav, white); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + gap: 16px; + display: flex; + flex-direction: column; + + table { + width: 100%; + border-collapse: collapse; + } + + th, + td { + border: 1px solid var(--color-nav-border, #e5e7eb); + padding: 8px; + text-align: left; + } + + th { + background: var(--color-spacer, #f3f4f6); + } + + button { + width: 100%; + } +} + +.szh-menu__item { + svg { + margin-right: 8px; + } +} + +.szh-menu__submenu.active, +.szh-menu__item.active { + color: var(--color-brand, #3b82f6); + +} diff --git a/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx index d963b276..1960629a 100644 --- a/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx +++ b/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx @@ -3,11 +3,10 @@ import { NodeData } from "../types"; import { CircleCheck } from "lucide-react"; export const TaskNode = ({ data, selected, isConnectable, ...props }: Node) => { - console.log(props); return ( <> {isConnectable && } - + {data.label || "Untitled"} diff --git a/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx index 22f0a565..0590043a 100644 --- a/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx +++ b/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx @@ -1,10 +1,12 @@ import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; import { NodeData } from "../types"; +import StarCircle from "../icons/StarCircle"; export const TopicNode = ({ data, selected, isConnectable }: Node) => { return ( <> {isConnectable && } + {data.state === "mastered" && } {data.label || "Untitled"} diff --git a/packages/web-component-learningmap-editor/src/useUndoable/errors.ts b/packages/web-component-learningmap-editor/src/useUndoable/errors.ts new file mode 100644 index 00000000..6cb01026 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/useUndoable/errors.ts @@ -0,0 +1,9 @@ +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/web-component-learningmap-editor/src/useUndoable/index.ts b/packages/web-component-learningmap-editor/src/useUndoable/index.ts new file mode 100644 index 00000000..228a67b1 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/useUndoable/index.ts @@ -0,0 +1,126 @@ +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/web-component-learningmap-editor/src/useUndoable/mutate.ts b/packages/web-component-learningmap-editor/src/useUndoable/mutate.ts new file mode 100644 index 00000000..b5b9b857 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/useUndoable/mutate.ts @@ -0,0 +1,117 @@ +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/web-component-learningmap-editor/src/useUndoable/reducer.ts b/packages/web-component-learningmap-editor/src/useUndoable/reducer.ts new file mode 100644 index 00000000..8bfeed0c --- /dev/null +++ b/packages/web-component-learningmap-editor/src/useUndoable/reducer.ts @@ -0,0 +1,88 @@ +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/web-component-learningmap-editor/src/useUndoable/types.ts b/packages/web-component-learningmap-editor/src/useUndoable/types.ts new file mode 100644 index 00000000..5fd95b05 --- /dev/null +++ b/packages/web-component-learningmap-editor/src/useUndoable/types.ts @@ -0,0 +1,63 @@ +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; + }, +]; From c222226b98d643f3b7da428eeeab86cb84792790 Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Thu, 9 Oct 2025 14:01:28 +0200 Subject: [PATCH 07/19] improvements --- .../TEST_GUIDE.md | 151 ---------------- .../index.html | 9 +- .../package.json | 1 + .../src/Drawer.tsx | 63 ++++++- .../src/EditorToolbar.tsx | 25 ++- .../src/LearningMap.tsx | 63 +++++-- .../src/LearningMapEditor.tsx | 162 +++++++++++++----- .../src/ProgressTracker.tsx | 25 +++ ...ackgroundDrawer.tsx => SettingsDrawer.tsx} | 24 +-- .../src/helper.ts | 2 +- .../src/index.css | 76 ++++++++ .../src/types.ts | 9 +- 12 files changed, 380 insertions(+), 230 deletions(-) delete mode 100644 packages/web-component-learningmap-editor/TEST_GUIDE.md create mode 100644 packages/web-component-learningmap-editor/src/ProgressTracker.tsx rename packages/web-component-learningmap-editor/src/{BackgroundDrawer.tsx => SettingsDrawer.tsx} (66%) diff --git a/packages/web-component-learningmap-editor/TEST_GUIDE.md b/packages/web-component-learningmap-editor/TEST_GUIDE.md deleted file mode 100644 index ad73151d..00000000 --- a/packages/web-component-learningmap-editor/TEST_GUIDE.md +++ /dev/null @@ -1,151 +0,0 @@ -# Hyperbook Learningmap Editor - Complete Test Guide - -This test file demonstrates all the features of the learningmap-editor web component. - -## Setup - -1. Build the component: - ```bash - pnpm build - ``` - -2. Open `index.html` in a web browser - -## Features to Test - -### 1. Node Management -- ✅ Click "Add Task" button to create a new task node -- ✅ Click "Add Topic" button to create a new topic node -- ✅ Click on any node to open the editor drawer -- ✅ Drag nodes to reposition them - -### 2. Node Editing (in the drawer) -- ✅ Change node type between Task and Topic -- ✅ Edit label (required field) -- ✅ Edit summary (shown on the node) -- ✅ Edit description (detailed text) -- ✅ Set duration (e.g., "30 min") -- ✅ Set video URL (YouTube or direct video link) - -### 3. Resources -- ✅ Click "Add Resource" to add a new resource -- ✅ Fill in label and URL for each resource -- ✅ Click trash icon to remove a resource - -### 4. Unlock Rules -- ✅ Set a password to unlock the node -- ✅ Set a date when the node unlocks -- ✅ List comma-separated node IDs that must be completed first - -### 5. Completion Rules -- ✅ Set "Completion Needs" - nodes that must be completed -- ✅ Set "Completion Optional" - optional nodes for full completion - -### 6. Background Settings -- ✅ Click "Background" button in toolbar -- ✅ Change background color using color picker -- ✅ Add background image URL -- ✅ Adjust image X and Y positions - -### 7. Edge Management -- ✅ Drag from a node's handle to another node to create an edge -- ✅ Edges are automatically created from completion needs if not specified - -### 8. Save Functionality -- ✅ Click "Save" button in toolbar -- ✅ Check the output box for the saved roadmap data -- ✅ Verify the change event is fired with complete data - -### 9. Delete Nodes -- ✅ Click "Delete Node" button in the editor drawer -- ✅ Verify node and connected edges are removed - -### 10. Initial Data Loading -- ✅ Component initializes with the sample data -- ✅ Two nodes are visible: "Getting Started" (task) and "Advanced Topics" (topic) -- ✅ Background has a light blue color (#f0f9ff) - -## Data Structure - -The component expects and outputs data in this format: - -```json -{ - "nodes": [ - { - "id": "node1", - "type": "task", - "position": { "x": 100, "y": 100 }, - "data": { - "label": "Node Label", - "summary": "Short summary", - "description": "Detailed description", - "duration": "30 min", - "video": "https://youtube.com/...", - "resources": [ - { "label": "Resource Name", "url": "https://..." } - ], - "unlock": { - "password": "secret", - "date": "2024-01-01", - "after": ["node0"] - }, - "completion": { - "needs": [{ "id": "node0" }], - "optional": [{ "id": "node2" }] - } - } - } - ], - "edges": [ - { - "id": "node1->node2", - "source": "node1", - "target": "node2" - } - ], - "background": { - "color": "#ffffff", - "image": { - "src": "bg.png", - "x": 0, - "y": 0 - } - }, - "edgeConfig": { - "animated": false, - "color": "#94a3b8", - "width": 2, - "type": "default" - } -} -``` - -## Known Limitations - -1. Node IDs are auto-generated as "node1", "node2", etc. -2. The component doesn't validate unlock/completion references -3. Background image must be publicly accessible -4. Video URLs work best with YouTube or direct video files - -## Browser Console Testing - -Open browser console and try: - -```javascript -// Get the editor element -const editor = document.getElementById('editor'); - -// Listen for changes -editor.addEventListener('change', (e) => { - console.log('Roadmap saved:', e.detail); -}); - -// Programmatically set data -const newData = { - nodes: [], - edges: [], - background: { color: "#fff" } -}; -editor.setAttribute('roadmap-data', JSON.stringify(newData)); -``` diff --git a/packages/web-component-learningmap-editor/index.html b/packages/web-component-learningmap-editor/index.html index 2a9b2a3d..3001c499 100644 --- a/packages/web-component-learningmap-editor/index.html +++ b/packages/web-component-learningmap-editor/index.html @@ -30,6 +30,12 @@ // Initialize with sample data const initialData = { + settings: { + title: "Sample Learning Roadmap", + background: { + color: "#f0f9ff" + } + }, nodes: [ { id: "node1", @@ -63,9 +69,6 @@ } ], edges: [], - background: { - color: "#f0f9ff" - } }; editor.setAttribute('roadmap-data', JSON.stringify(initialData)); diff --git a/packages/web-component-learningmap-editor/package.json b/packages/web-component-learningmap-editor/package.json index db93ca0e..f526a56c 100644 --- a/packages/web-component-learningmap-editor/package.json +++ b/packages/web-component-learningmap-editor/package.json @@ -36,6 +36,7 @@ "@szhsin/react-menu": "^4.5.0", "@xyflow/react": "^12.8.6", "elkjs": "^0.11.0", + "html-to-image": "1.11.11", "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/web-component-learningmap-editor/src/Drawer.tsx b/packages/web-component-learningmap-editor/src/Drawer.tsx index 19adb2cf..77847dc0 100644 --- a/packages/web-component-learningmap-editor/src/Drawer.tsx +++ b/packages/web-component-learningmap-editor/src/Drawer.tsx @@ -8,10 +8,38 @@ interface DrawerProps { open: boolean; onClose: () => void; onUpdate: (node: Node) => void; - node: Node + node: Node; + nodes: Node[]; + onNodeClick: (_: any, node: Node, focus: boolean) => void; } -export function Drawer({ open, onClose, onUpdate, node }: DrawerProps) { +function getUnlockConditions(node: Node, nodes: Node[]): Node[] { + const unmetNeeds: Node[] = []; + if (node.data?.unlock?.after) { + node.data.unlock.after.forEach((depId: string) => { + const depNode = nodes.find(n => n.id === depId); + if (depNode && depNode.data?.state !== 'completed' && depNode.data?.state !== 'mastered') { + unmetNeeds.push(depNode); + } + }); + } + return unmetNeeds; +} + +function getCompletionNeeds(node: Node, nodes: Node[]): Node[] { + const unmetNeeds: Node[] = []; + if (node.data?.completion?.needs) { + node.data.completion.needs.forEach((needId: string) => { + const needNode = nodes.find(n => n.id === needId); + if (needNode && needNode.data?.state !== 'completed' && needNode.data?.state !== 'mastered') { + unmetNeeds.push(needNode); + } + }); + } + return unmetNeeds; +} + +export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick }: DrawerProps) { if (!open) return null; const locked = node.data?.state === 'locked' || false; @@ -20,6 +48,9 @@ export function Drawer({ open, onClose, onUpdate, node }: DrawerProps) { const started = node.data?.state === 'started' || false; const mastered = node.data?.state === 'mastered' || false; + const unlockConditions = getUnlockConditions(node, nodes); + const completionNeeds = getCompletionNeeds(node, nodes); + const handleStateChange = (newState: 'locked' | 'unlocked' | 'started' | 'completed') => () => { if (node.type === "topic" || locked) return; @@ -60,6 +91,34 @@ export function Drawer({ open, onClose, onUpdate, node }: DrawerProps) { )} + {unlockConditions.length > 0 && ( + + Complete the following nodes first to unlock this one: + + {unlockConditions.map(n => ( + + { onNodeClick(null, n, true); }}> + {n.data?.label || n.id} - {n.data?.state} + + + ))} + + + )} + {!locked && completionNeeds.length > 0 && ( + + The following nodes need to be completed or mastered before this one is completed: + + {completionNeeds.map(n => ( + + { onNodeClick(null, n, true); }}> + {n.data?.label || n.id} - {n.data?.state} + + + ))} + + + )} {locked && diff --git a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx index 9a8873d3..7ed88b7d 100644 --- a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx +++ b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Menu, MenuButton, MenuItem, SubMenu } from "@szhsin/react-menu"; import "@szhsin/react-menu/dist/index.css"; import '@szhsin/react-menu/dist/transitions/zoom.css'; -import { Save, Plus, Bug, Settings, Eye, Menu as MenuI } from "lucide-react"; +import { Save, Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown } from "lucide-react"; interface EditorToolbarProps { saved: boolean; @@ -17,8 +17,11 @@ interface EditorToolbarProps { onSetShowCompletionOptional: (checked: boolean) => void; onSetShowUnlockAfter: (checked: boolean) => void; onAddNewNode: (type: "task" | "topic" | "image" | "text") => void; - onOpenBackgroundDrawer: () => void; + onOpenSettingsDrawer: () => void; onSave: () => void; + onDownlad: () => void; + onOpen: () => void; + onExportSVG: () => void; } export const EditorToolbar: React.FC = ({ @@ -34,8 +37,11 @@ export const EditorToolbar: React.FC = ({ onSetShowCompletionOptional, onSetShowUnlockAfter, onAddNewNode, - onOpenBackgroundDrawer, + onOpenSettingsDrawer, onSave, + onDownlad, + onOpen, + onExportSVG }) => ( @@ -45,8 +51,8 @@ export const EditorToolbar: React.FC = ({ onAddNewNode("image")}>Add Image onAddNewNode("text")}>Add Text - - Background + + Settings @@ -71,6 +77,15 @@ export const EditorToolbar: React.FC = ({ Save{!saved ? "*" : ""} + + Download + + + Open + + {false && + Export as SVG + } diff --git a/packages/web-component-learningmap-editor/src/LearningMap.tsx b/packages/web-component-learningmap-editor/src/LearningMap.tsx index 5ea3cf17..1394764a 100644 --- a/packages/web-component-learningmap-editor/src/LearningMap.tsx +++ b/packages/web-component-learningmap-editor/src/LearningMap.tsx @@ -1,12 +1,13 @@ -import { Controls, Edge, Node, ReactFlow, useEdgesState, useNodesState } from "@xyflow/react"; +import { Controls, Edge, Node, Panel, ReactFlow, useEdgesState, useNodesState, 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 { BackgroundConfig, EdgeConfig, NodeData, RoadmapData } from "./types"; +import { NodeData, RoadmapData, Settings } from "./types"; import { useCallback, useEffect, useState } from "react"; import { parseRoadmapData } from "./helper"; import { Drawer } from "./Drawer"; +import { ProgressTracker } from "./ProgressTracker"; const nodeTypes = { topic: TopicNode, @@ -86,6 +87,24 @@ const isInteractableNode = (node: Node) => { return node.type === "task" || node.type === "topic"; } +const countCompletedNodes = (nodes: Node[]) => { + let completed = 0; + let mastered = 0; + let total = 0; + nodes.forEach(n => { + if (n.type === "task" || n.type === "topic") { + total++; + if (n.data?.state === 'completed') { + completed++; + } + else if (n.data?.state === 'mastered') { + completed++; + mastered++; + } + } + }); + return { completed, mastered, total }; +} export function LearningMap({ roadmapData, @@ -100,8 +119,10 @@ export function LearningMap({ const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNode, setSelectedNode] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); - const [background, setBackground] = useState({ color: "#ffffff" }); - const [edgeConfig, setEdgeConfig] = useState({}); + const [settings, setSettings] = useState(); + const { fitView } = useReactFlow(); + + const { completed, mastered, total } = countCompletedNodes(nodes); const parsedRoadmap = parseRoadmapData(roadmapData); @@ -110,8 +131,7 @@ export function LearningMap({ const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; - setBackground(parsedRoadmap?.background || { color: "#ffffff" }); - setEdgeConfig(parsedRoadmap?.edgeConfig || {}); + setSettings(parsedRoadmap?.settings || {}); let rawNodes = nodesArr.map((n) => ({ ...n, @@ -129,11 +149,15 @@ export function LearningMap({ loadRoadmap(); }, [roadmapData]); - const onNodeClick = useCallback((_: any, node: Node) => { + const onNodeClick = useCallback((_: any, node: Node, focus: boolean = false) => { if (!isInteractableNode(node)) return; setSelectedNode(node); setDrawerOpen(true); - }, []); + + if (focus) { + fitView({ nodes: [node], duration: 150 }); + } + }, [fitView]); const closeDrawer = useCallback(() => { setDrawerOpen(false); @@ -171,19 +195,19 @@ export function LearningMap({ }, [nodes]); const defaultEdgeOptions = { - animated: edgeConfig.animated ?? false, + animated: false, style: { - stroke: edgeConfig.color ?? "#94a3b8", - strokeWidth: edgeConfig.width ?? 2, + stroke: "#94a3b8", + strokeWidth: 2, }, - type: edgeConfig.type ?? "default", + type: "default", }; return ( + {settings?.title && ( + + + {settings.title} + + + )} + + + - + ) } diff --git a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx index f8608554..6216b208 100644 --- a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx +++ b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx @@ -14,20 +14,24 @@ import { ControlButton, OnNodesChange, OnEdgesChange, + getNodesBounds, + getViewportForBounds, + Panel, } from "@xyflow/react"; +import { toSvg } from "html-to-image"; 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, BackgroundConfig, EdgeConfig } from "./types"; -import { BackgroundDrawer } from "./BackgroundDrawer"; +import { RoadmapData, NodeData, ImageNodeData, TextNodeData, Settings } 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 } from "lucide-react"; +import { Info, Redo, Undo, RotateCw, ShieldAlert } from "lucide-react"; import useUndoable from "./useUndoable"; const nodeTypes = { @@ -65,7 +69,7 @@ export function LearningMapEditor({ { action: "Show Help", shortcut: "Ctrl+? or Help Button" }, ]; - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, getViewport, setViewport } = useReactFlow(); const parsedRoadmap = parseRoadmapData(roadmapData); const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset }] = useUndoable(parsedRoadmap); @@ -75,14 +79,13 @@ export function LearningMapEditor({ const [debugMode, setDebugMode] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [background, setBackground] = useState({ color: "#ffffff" }); - const [edgeConfig, setEdgeConfig] = useState({}); + const [settings, setSettings] = useState({ background: { color: "#ffffff" } }); const [helpOpen, setHelpOpen] = useState(false); const [colorMode] = useState("light"); const [selectedNode, setSelectedNode] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); - const [backgroundDrawerOpen, setBackgroundDrawerOpen] = useState(false); + const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false); const [nextNodeId, setNextNodeId] = useState(1); // Debug settings state @@ -97,12 +100,11 @@ export function LearningMapEditor({ // Track Shift key state const [shiftPressed, setShiftPressed] = useState(false); - const loadRoadmapStateIntoReactFlowState = useCallback(() => { + const loadRoadmapStateIntoReactFlowState = useCallback((roadmapState: RoadmapData) => { const nodesArr = Array.isArray(roadmapState?.nodes) ? roadmapState.nodes : []; const edgesArr = Array.isArray(roadmapState?.edges) ? roadmapState.edges : []; - setBackground(roadmapState?.background || { color: "#ffffff" }); - setEdgeConfig(roadmapState?.edgeConfig || {}); + setSettings(roadmapState?.settings || { background: { color: "#ffffff" } }); const rawNodes = nodesArr.map((n) => ({ ...n, @@ -122,16 +124,16 @@ export function LearningMapEditor({ ); setNextNodeId(maxId + 1); } - }, [roadmapState, setNodes, setEdges, setBackground, setEdgeConfig]); + }, [setNodes, setEdges, setSettings]); useEffect(() => { - loadRoadmapStateIntoReactFlowState(); + loadRoadmapStateIntoReactFlowState(parsedRoadmap); }, []); useEffect(() => { if (didUndoRedo) { setDidUndoRedo(false); - loadRoadmapStateIntoReactFlowState(); + loadRoadmapStateIntoReactFlowState(roadmapState); } }, [roadmapState, didUndoRedo, loadRoadmapStateIntoReactFlowState]); @@ -208,23 +210,12 @@ export function LearningMapEditor({ setDebugMode((mode) => !mode); }, [setDebugMode]); - const togglePreviewMode = useCallback(() => { - setPreviewMode((mode) => { - const newMode = !mode; - if (newMode) { - setDebugMode(false); - closeDrawer(); - } - return newMode; - }); - }, [setPreviewMode]); - const closeDrawer = useCallback(() => { setDrawerOpen(false); setSelectedNode(null); setEdgeDrawerOpen(false); setSelectedEdge(null); - setBackgroundDrawerOpen(false) + setSettingsDrawerOpen(false) }, []); const updateNode = useCallback( @@ -348,8 +339,8 @@ export function LearningMapEditor({ type: e.type, style: e.style, })), - background, - edgeConfig, + settings, + version: 1 }; setRoadmapState(roadmapData); @@ -364,19 +355,103 @@ export function LearningMapEditor({ root.dispatchEvent(new CustomEvent("change", { detail: roadmapData })); } } - }, [nodes, edges, background, edgeConfig]); + }, [nodes, edges, settings]); + + 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", "roadmap.json"); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }, [roadmapState]); const defaultEdgeOptions = { - animated: edgeConfig.animated ?? false, + animated: false, style: { - stroke: edgeConfig.color ?? "#94a3b8", - strokeWidth: edgeConfig.width ?? 2, + stroke: "#94a3b8", + strokeWidth: 2, }, - type: edgeConfig.type ?? "default", + type: "default", }; + const handleExportSVG = useCallback(async () => { + const nodesBounds = getNodesBounds(nodes); + const imageWidth = nodesBounds.width; + const imageHeight = nodesBounds.height; + let viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.1, 5); + + const dom = document.querySelector(".react-flow__viewport") as HTMLElement; + if (!dom) return; + + toSvg(dom, { + backgroundColor: settings?.background?.color || "#ffffff", + width: imageWidth, + height: imageHeight, + style: { + transform: `translate(${viewport.x / 2.0}px, ${viewport.y / 2.0}px) scale(${viewport.zoom})`, + width: `${imageWidth}px`, + height: `${imageHeight}px`, + } + }).then((dataUrl) => { + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataUrl); + downloadAnchorNode.setAttribute("download", "roadmap.svg"); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + + // Restore old viewport + }).catch((err) => { + alert("Failed to export SVG: " + err.message); + }); + }, [nodes, roadmapState]); + + const handleOpen = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.onchange = (e: any) => { + const file = e.target.files[0]; + if (!file) return; + + if (!window.confirm("Opening a file will replace your current map. Continue?")) { + 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('Failed to load the file. Please make sure it is a valid roadmap JSON file.'); + } + }; + reader.readAsText(file); + }; + input.click(); + }, [setRoadmapState, setDidUndoRedo]); + // Toolbar handler wrappers for EditorToolbar props - const handleOpenBackgroundDrawer = useCallback(() => setBackgroundDrawerOpen(true), []); + 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), []); @@ -500,15 +575,18 @@ export function LearningMapEditor({ onSetShowCompletionOptional={handleSetShowCompletionOptional} onSetShowUnlockAfter={handleSetShowUnlockAfter} onAddNewNode={addNewNode} - onOpenBackgroundDrawer={handleOpenBackgroundDrawer} + onOpenSettingsDrawer={handleOpenSettingsDrawer} onSave={handleSave} + onDownlad={handleDownload} + onOpen={handleOpen} + onExportSVG={handleExportSVG} /> - {previewMode && } + {previewMode && } {!previewMode && <> @@ -554,6 +633,9 @@ export function LearningMapEditor({ + {!saved && { handleSave(); }}> + + } - { + const progress = total > 0 ? (completed / total) * 100 : 0; + + return ( + <> + + + {completed} / {total} + + + {progress}% + + + + + + {mastered} + + > + ); +} + diff --git a/packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx b/packages/web-component-learningmap-editor/src/SettingsDrawer.tsx similarity index 66% rename from packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx rename to packages/web-component-learningmap-editor/src/SettingsDrawer.tsx index 98d61a58..06f6d9d6 100644 --- a/packages/web-component-learningmap-editor/src/BackgroundDrawer.tsx +++ b/packages/web-component-learningmap-editor/src/SettingsDrawer.tsx @@ -1,31 +1,31 @@ import React, { useState, useEffect } from "react"; import { X, Save } from "lucide-react"; -import { BackgroundConfig } from "./types"; +import { Settings } from "./types"; import { ColorSelector } from "./ColorSelector"; -interface BackgroundDrawerProps { +interface SettingsDrawerProps { isOpen: boolean; onClose: () => void; - background: BackgroundConfig; - onUpdate: (bg: BackgroundConfig) => void; + settings: Settings; + onUpdate: (s: Settings) => void; } -export const BackgroundDrawer: React.FC = ({ +export const SettingsDrawer: React.FC = ({ isOpen, onClose, - background, + settings, onUpdate, }) => { - const [localBg, setLocalBg] = useState(background); + const [localSettings, setLocalSettings] = useState(settings); useEffect(() => { - setLocalBg(background); - }, [background]); + setLocalSettings(settings); + }, [settings]); if (!isOpen) return null; const handleSave = () => { - onUpdate(localBg); + onUpdate(localSettings); onClose(); }; @@ -44,8 +44,8 @@ export const BackgroundDrawer: React.FC = ({ setLocalBg({ ...localBg, color })} + value={localSettings?.background?.color || "#ffffff"} + onChange={color => setLocalSettings(settings => ({ ...settings, background: { ...settings.background, color } }))} /> diff --git a/packages/web-component-learningmap-editor/src/helper.ts b/packages/web-component-learningmap-editor/src/helper.ts index e0ad019e..50d7345e 100644 --- a/packages/web-component-learningmap-editor/src/helper.ts +++ b/packages/web-component-learningmap-editor/src/helper.ts @@ -81,6 +81,6 @@ export const parseRoadmapData = ( return JSON.parse(roadmapData); } catch (err) { console.error("Failed to parse roadmap data:", err); - return {}; + return { settings: { title: "New Roadmap" }, version: 1 }; } }; diff --git a/packages/web-component-learningmap-editor/src/index.css b/packages/web-component-learningmap-editor/src/index.css index 9a61c083..c27d34a4 100644 --- a/packages/web-component-learningmap-editor/src/index.css +++ b/packages/web-component-learningmap-editor/src/index.css @@ -374,6 +374,11 @@ outline: 1px solid var(--color-brand, #3b82f6); } +.react-flow__node.selected { + outline: 2px solid var(--color-brand, #3b82f6); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3); +} + .react-flow__node-image img { width: 100%; } @@ -573,3 +578,74 @@ dialog.help[open] { color: var(--color-brand, #3b82f6); } + +.link-button { + color: var(--color-brand, #3b82f6); + text-decoration: underline; + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; +} + +.progress-panel { + width: 80%; + padding: 8px; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + background: #fff; + border: 1px solid var(--color-nav-border, #d1d5db); + display: flex; + justify-content: space-between; + font-size: 14px; + margin-bottom: 4px; + + .mastered-counter, + .completed-counter { + color: #10b981; + width: 150px; + user-select: none; + } + + .completed-counter { + display: flex; + justify-content: flex-start; + } + + .mastered-counter { + display: flex; + justify-content: flex-end; + } + + .progress-value { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #266651; + user-select: none; + font-weight: 600; + } + + .progress-bar-container { + position: relative; + width: 100%; + border: 1px solid var(--color-nav-border, #d1d5db); + background: var(--color-nav, #f3f4f6); + border-radius: 9999px; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + background: #10b981; + border-radius: 9999px; + transition: width 0.3s ease; + } + + .progress-text { + font-size: 12px; + margin-top: 4px; + } +} diff --git a/packages/web-component-learningmap-editor/src/types.ts b/packages/web-component-learningmap-editor/src/types.ts index 926a26bb..d4d2365b 100644 --- a/packages/web-component-learningmap-editor/src/types.ts +++ b/packages/web-component-learningmap-editor/src/types.ts @@ -52,6 +52,11 @@ export interface BackgroundConfig { nodes?: Node[]; } +export interface Settings { + title?: string; + background?: BackgroundConfig; +} + export interface EdgeConfig { animated?: boolean; color?: string; @@ -62,8 +67,8 @@ export interface EdgeConfig { export interface RoadmapData { nodes?: Node[]; edges?: Edge[]; - background?: BackgroundConfig; - edgeConfig?: EdgeConfig; + settings: Settings; + version: number; } export type Orientation = "horizontal" | "vertical"; From ee5f5165488a5c13f674fb0f3c8387f7fd30c029 Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Thu, 9 Oct 2025 20:55:31 +0200 Subject: [PATCH 08/19] improvements --- .../web-component-learningmap/CHANGELOG.md | 245 +--- packages/web-component-learningmap/README.md | 77 +- packages/web-component-learningmap/bg.svg | 4 - packages/web-component-learningmap/index.html | 240 +--- packages/web-component-learningmap/map.html | 88 ++ .../web-component-learningmap/package.json | 10 +- .../src/ColorSelector.tsx | 28 + .../web-component-learningmap/src/Drawer.tsx | 143 ++ .../src/EdgeDrawer.tsx | 60 + .../src/EditorDrawer.tsx | 241 ++++ .../src/EditorDrawerEdgeContent.tsx | 48 + .../src/EditorDrawerImageContent.tsx | 43 + .../src/EditorDrawerTaskContent.tsx | 215 +++ .../src/EditorDrawerTextContent.tsx | 44 + .../src/EditorDrawerTopicContent.tsx | 6 + .../src/EditorToolbar.tsx | 92 ++ .../src/FloatingEdge.tsx | 39 + .../src/HyperbookLearningmap.tsx | 1160 +---------------- .../src/HyperbookLearningmapEditor.tsx | 27 + .../src/LearningMap.tsx | 266 ++++ .../src/LearningMapEditor.tsx | 721 ++++++++++ .../src/MultiNodePanel.tsx | 146 +++ .../src/ProgressTracker.tsx | 25 + .../src/RotationInput.tsx | 38 + .../src/SettingsDrawer.tsx | 70 + .../web-component-learningmap/src/Video.tsx | 54 + .../src/autoLayoutElk.ts | 15 +- .../web-component-learningmap/src/helper.ts | 86 ++ .../src/icons/StarCircle.tsx | 23 + .../web-component-learningmap/src/index.css | 785 +++++++---- .../web-component-learningmap/src/index.ts | 21 +- .../src/nodes/ImageNode.tsx | 25 + .../src/nodes/TaskNode.tsx | 44 + .../src/nodes/TextNode.tsx | 19 + .../src/nodes/TopicNode.tsx | 44 + .../web-component-learningmap/src/types.ts | 97 ++ .../src/useUndoable/errors.ts | 9 + .../src/useUndoable/index.ts | 126 ++ .../src/useUndoable/mutate.ts | 117 ++ .../src/useUndoable/reducer.ts | 88 ++ .../src/useUndoable/types.ts | 63 + .../web-component-learningmap/tsconfig.json | 7 +- 42 files changed, 3881 insertions(+), 1818 deletions(-) delete mode 100644 packages/web-component-learningmap/bg.svg create mode 100644 packages/web-component-learningmap/map.html create mode 100644 packages/web-component-learningmap/src/ColorSelector.tsx create mode 100644 packages/web-component-learningmap/src/Drawer.tsx create mode 100644 packages/web-component-learningmap/src/EdgeDrawer.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawer.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawerImageContent.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawerTextContent.tsx create mode 100644 packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx create mode 100644 packages/web-component-learningmap/src/EditorToolbar.tsx create mode 100644 packages/web-component-learningmap/src/FloatingEdge.tsx create mode 100644 packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx create mode 100644 packages/web-component-learningmap/src/LearningMap.tsx create mode 100644 packages/web-component-learningmap/src/LearningMapEditor.tsx create mode 100644 packages/web-component-learningmap/src/MultiNodePanel.tsx create mode 100644 packages/web-component-learningmap/src/ProgressTracker.tsx create mode 100644 packages/web-component-learningmap/src/RotationInput.tsx create mode 100644 packages/web-component-learningmap/src/SettingsDrawer.tsx create mode 100644 packages/web-component-learningmap/src/Video.tsx create mode 100644 packages/web-component-learningmap/src/helper.ts create mode 100644 packages/web-component-learningmap/src/icons/StarCircle.tsx create mode 100644 packages/web-component-learningmap/src/nodes/ImageNode.tsx create mode 100644 packages/web-component-learningmap/src/nodes/TaskNode.tsx create mode 100644 packages/web-component-learningmap/src/nodes/TextNode.tsx create mode 100644 packages/web-component-learningmap/src/nodes/TopicNode.tsx create mode 100644 packages/web-component-learningmap/src/types.ts create mode 100644 packages/web-component-learningmap/src/useUndoable/errors.ts create mode 100644 packages/web-component-learningmap/src/useUndoable/index.ts create mode 100644 packages/web-component-learningmap/src/useUndoable/mutate.ts create mode 100644 packages/web-component-learningmap/src/useUndoable/reducer.ts create mode 100644 packages/web-component-learningmap/src/useUndoable/types.ts diff --git a/packages/web-component-learningmap/CHANGELOG.md b/packages/web-component-learningmap/CHANGELOG.md index 76f85b48..f631eae3 100644 --- a/packages/web-component-learningmap/CHANGELOG.md +++ b/packages/web-component-learningmap/CHANGELOG.md @@ -1,242 +1,13 @@ -# @hyperbook/element-excalidraw +# @hyperbook/web-component-learningmap ## 0.1.0 ### Minor Changes -- [`25a216f`](https://github.com/openpatch/hyperbook/commit/25a216f4f3d4b63f2c1db89880e7e0ee29d84da8) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Add learningmap element - -## 0.3.2 - -### Patch Changes - -- [`b4b9593`](https://github.com/openpatch/hyperbook/commit/b4b9593aa46c33b47aa311e6aa7c8d0117bd753b) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update dependencies - -## 0.3.1 - -### Patch Changes - -- [`e1720b5`](https://github.com/openpatch/hyperbook/commit/e1720b5eec08da070bd76881e47c23b6850bb880) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Update dependencies - -## 0.3.0 - -### Minor Changes - -- [`b5a41e0`](https://github.com/openpatch/hyperbook/commit/b5a41e00a014a77dd13a5e8d13009c5c2462cb15) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - - Save every state of the hyperbook and make it available for download. To enable this feature, set `importExport` to `true` in the configuration file. The buttons for importing and exporting will be at the bottom of the page. The state of the hyperbook will be saved as a JSON file. The file can be imported again to restore the state of the hyperbook. - - The code of the editor for the elements P5, Pyide, ABC-Music can now be copied, download or resetted. - -## 0.2.1 - -### Patch Changes - -- [#897](https://github.com/openpatch/hyperbook/pull/897) [`33724b8`](https://github.com/openpatch/hyperbook/commit/33724b8c46c588d30bce661c32244fc34896209f) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Add p5 element - -## 0.2.0 - -### Minor Changes - -- [#882](https://github.com/openpatch/hyperbook/pull/882) [`26ae87e`](https://github.com/openpatch/hyperbook/commit/26ae87e19b01b6c2f45590ade4d681c5a27c932a) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - The release is a complete rewrite of the underlying process to generate the - HTML files. React was removed from the project and replaced with remark - plugins. This improves build times and give us more control over the whole - process. For example there is no need anymore for running `npx hyperbook -setup`. - - I also added a new pagelist directive, which can be used to list pages based - on user-defined criteria. This directive also replaces the included glossary - page. Therefore, you need to create on yourself. This can be easily done by - creating a page `glossary.md` with the following content: - - ```md - --- - name: Glossary - --- - - ::pagelist{format="glossary" source="href(/glossary/)"} - ``` - - Additionally, I added the ability to use custom JavaScript and CSS-files - see - the documentation under Advanced Features - in addition to using HTML in your - hyperbook, when the `allowDangerousHtml` option is enabled in your config. - - I also improved the appearance of custom links, by moving them to the - footer on mobile devices. - -## 0.6.2 - -### Patch Changes - -- [`4a3a21f`](https://github.com/openpatch/hyperbook/commit/4a3a21f40c0355c308e8dcb723234c0434aced23) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update dependencies - -- Updated dependencies [[`4a3a21f`](https://github.com/openpatch/hyperbook/commit/4a3a21f40c0355c308e8dcb723234c0434aced23)]: - - @hyperbook/provider@0.4.2 - -## 0.6.1 - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.4.1 - -## 0.6.0 - -### Minor Changes - -- [`2b94605`](https://github.com/openpatch/hyperbook/commit/2b9460574a23fcecc66c0a187a56d236891482fe) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update mermaind and excalidraw packages - -## 0.5.2 - -### Patch Changes - -- [`f5ddc0c`](https://github.com/openpatch/hyperbook/commit/f5ddc0c53564ea426d31aff69695e8fc91dfa0e9) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Fix auto zoom for excalidraw elements. - -## 0.5.1 - -### Patch Changes - -- [`e84275a`](https://github.com/openpatch/hyperbook/commit/e84275a11949e455be4a00742528541b969d52f1) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update dependencies - -## 0.5.0 - -### Minor Changes - -- [`9bb80bb`](https://github.com/openpatch/hyperbook/commit/9bb80bbd711a2ec11d84f2263c581d42e92fd7de) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Move to pure ESM packages - -### Patch Changes - -- Updated dependencies [[`9bb80bb`](https://github.com/openpatch/hyperbook/commit/9bb80bbd711a2ec11d84f2263c581d42e92fd7de)]: - - @hyperbook/provider@0.4.0 - -## 0.4.5 - -### Patch Changes - -- Updated dependencies [[`902c0b3`](https://github.com/openpatch/hyperbook/commit/902c0b30a0aa97984350cfd58ad88d38ef7b4cd6)]: - - @hyperbook/provider@0.3.0 - -## 0.4.4 - -### Patch Changes - -- [`edf8e94`](https://github.com/openpatch/hyperbook/commit/edf8e943e9b9c393121cfc1d859dc91e44af30c1) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - fix data not defined - -- Updated dependencies [[`edf8e94`](https://github.com/openpatch/hyperbook/commit/edf8e943e9b9c393121cfc1d859dc91e44af30c1)]: - - @hyperbook/provider@0.2.5 - -## 0.4.3 - -### Patch Changes - -- [`832678b`](https://github.com/openpatch/hyperbook/commit/832678b39f6a1a6e5cdd361c9c384d341762c09e) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update packages - -- Updated dependencies [[`832678b`](https://github.com/openpatch/hyperbook/commit/832678b39f6a1a6e5cdd361c9c384d341762c09e)]: - - @hyperbook/provider@0.2.4 - -## 0.4.2 - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.2.3 - -## 0.4.1 - -### Patch Changes - -- [`cf58c13`](https://github.com/openpatch/hyperbook/commit/cf58c13ca19aaba8e20e6e1cb27ab3ebbfb74d37) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - update dependencies - -- Updated dependencies [[`cf58c13`](https://github.com/openpatch/hyperbook/commit/cf58c13ca19aaba8e20e6e1cb27ab3ebbfb74d37)]: - - @hyperbook/provider@0.2.2 - -## 0.4.0 - -### Minor Changes - -- [`e087609`](https://github.com/openpatch/hyperbook/commit/e087609de23c4d2868793fec65deee8beb144a78) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - upgrade excalidraw to version 0.15.0 - -## 0.3.1 - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.2.1 - -## 0.3.0 - -### Minor Changes - -- [`b1415aa`](https://github.com/openpatch/hyperbook/commit/b1415aaf8905a0fa7d119074e3b6731167023671) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Update excalidraw to version 0.13.0 - -## 0.2.3 - -### Patch Changes - -- Updated dependencies [[`8d53899`](https://github.com/openpatch/hyperbook/commit/8d538999fc924f7b3e3115416cba4978c9589b68)]: - - @hyperbook/provider@0.2.0 - -## 0.2.2 - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.1.7 - -## 0.2.1 - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.1.6 - -## 0.2.0 - -### Minor Changes - -- [#326](https://github.com/openpatch/hyperbook/pull/326) [`9472583`](https://github.com/openpatch/hyperbook/commit/947258359e33a39362c070f6c7128f214a79c4c5) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Add configuration options to the hyperbook.json for the element bookmarks and excalidraw. - -### Patch Changes - -- Updated dependencies []: - - @hyperbook/provider@0.1.5 - -## 0.1.4 - -### Patch Changes - -- Updated dependencies [[`cedb551`](https://github.com/openpatch/hyperbook/commit/cedb55191fd025b5a214df406a53cbab5d1b1bc1)]: - - @hyperbook/provider@0.1.4 - -## 0.1.3 - -### Patch Changes - -- [`c3e747a`](https://github.com/openpatch/hyperbook/commit/c3e747ab7b95c3e526b3800b169aa8f505f9b9a2) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Do not rely on process.env.NODE_ENV and use env prodivded by the provider. - -- Updated dependencies [[`c3e747a`](https://github.com/openpatch/hyperbook/commit/c3e747ab7b95c3e526b3800b169aa8f505f9b9a2)]: - - @hyperbook/provider@0.1.3 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [[`2c34554`](https://github.com/openpatch/hyperbook/commit/2c34554f64359fb995190b1465daddfa3e0101c0)]: - - @hyperbook/provider@0.1.2 - -## 0.1.1 - -### Patch Changes - -- [`9ea5483`](https://github.com/openpatch/hyperbook/commit/9ea5483512fd5134f6823104a68fecea2c50cb00) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Force package update due to failed pipeline - -- Updated dependencies [[`9ea5483`](https://github.com/openpatch/hyperbook/commit/9ea5483512fd5134f6823104a68fecea2c50cb00)]: - - @hyperbook/provider@0.1.1 - -## 0.1.0 - -### Minor Changes - -- [`6c1bd51`](https://github.com/openpatch/hyperbook/commit/6c1bd51e7ded1b2094ba590e5d8ddc5c0f6254b8) Thanks [@mikebarkmin](https://github.com/mikebarkmin)! - Extract feature in separate packages. This allows for easier development of new templates and allows enables to make changes more transparent. - -### Patch Changes - -- Updated dependencies [[`6c1bd51`](https://github.com/openpatch/hyperbook/commit/6c1bd51e7ded1b2094ba590e5d8ddc5c0f6254b8)]: - - @hyperbook/provider@0.1.0 +- Initial release of the learningmap editor web component +- Visual editor for creating and editing learning maps +- Support for drag-and-drop node positioning +- Configurable node settings (label, description, resources, unlock rules, completion rules) +- Background customization (color and image) +- Auto-layout support using ELK algorithm +- Change event fires when saving diff --git a/packages/web-component-learningmap/README.md b/packages/web-component-learningmap/README.md index 8ddd4b7d..a6e32328 100644 --- a/packages/web-component-learningmap/README.md +++ b/packages/web-component-learningmap/README.md @@ -1,9 +1,74 @@ -# @hyperbook/element-excalidraw +# @hyperbook/web-component-learningmap-editor -## Installation +A web component for editing learning maps/roadmaps with drag-and-drop nodes, customizable settings, and visual editing capabilities. -```sh -yarn add @hyperbook/element-excalidraw -# or -npm i @hyperbook/element-excalidraw +## Features + +- **Visual Editor**: Drag-and-drop interface for creating and positioning nodes +- **Node Types**: Support for Task and Topic nodes +- **Node Settings**: Edit labels, descriptions, resources, durations, and more +- **Unlock Rules**: Configure password, date, and dependency-based unlocking +- **Completion Rules**: Set completion needs and optional dependencies +- **Background Customization**: Configure background color and images +- **Auto-Layout**: Automatic node positioning using ELK algorithm +- **Edge Management**: Connect nodes with customizable edges + +## Usage + +```html + +``` + +## Events + +The component fires a `change` event when the Save button is pressed: + +```javascript +const editor = document.querySelector('hyperbook-learningmap-editor'); +editor.addEventListener('change', (event) => { + const roadmapData = event.detail; + console.log('Roadmap data:', roadmapData); +}); +``` + +## Roadmap Data Format + +```json +{ + "nodes": [ + { + "id": "node1", + "type": "task", + "position": { "x": 0, "y": 0 }, + "data": { + "label": "Introduction", + "description": "Learn the basics", + "resources": [ + { "label": "Documentation", "url": "https://example.com" } + ], + "unlock": { + "password": "secret", + "date": "2024-01-01", + "after": ["node0"] + }, + "completion": { + "needs": [{ "id": "node0" }], + "optional": [{ "id": "node2" }] + } + } + } + ], + "edges": [], + "background": { + "color": "#ffffff", + "image": { + "src": "bg.png", + "x": 0, + "y": 0 + } + } +} ``` diff --git a/packages/web-component-learningmap/bg.svg b/packages/web-component-learningmap/bg.svg deleted file mode 100644 index 976193d1..00000000 --- a/packages/web-component-learningmap/bg.svg +++ /dev/null @@ -1,4 +0,0 @@ - - -eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXF1TXHUwMDFiuVx1MDAxMn3Pr0ixr7FW3freNyB8XHUwMDA1SEggXHUwMDAxcmsrZfCAXHLGNrbBwFb++z0yhFx1MDAxOTxcdTAwMWVsXHUwMDAypGxcbqViQFx1MDAxYWs0063Tp1st/ffm7duZ7mUrmfnn7UxysV+u1yrtcm/mXaw/T9qdWrOBJu7/3Wmetff7V1a73Vbnn7//Tr8h9psn199K6slJ0uh2cN3/8Pfbt//1P7P3qddrrU7Sv7zfkLlcdTAwMTFLNVj9sdno35WUYlxurN3tXHUwMDA1tc573K2bVNB6UK53krQlVs183F3e2DeLvLpPLXl1sldZ+K576W1cdTAwMGZq9fpm97LeXHUwMDFmVKeJXHUwMDA3Sds63XbzONmuVbrVX8+fqS/6Vrt5dlhtJJ348HRb22yV92vdy1gn5W1tuXHY7yOtuYh3kkp4bVUweFhLLqSvI3ZQXCLHwjulgrfGKWOkXHUwMDFlXHUwMDE42nyz3mzHof0l+yVcdTAwMWTcXnn/+Fx1MDAxMCNsVG6v6bbLjU6r3Ia80ut6N1x1MDAwZm2MsFx1MDAxY/DKnWZPVqZcdTAwMGZUTWqH1W68hFx1MDAwNEtS3lx1MDAwNIfBkPWZ0SR9sXhvVGDDqdDiXHUwMDEwWiuVVD/6tWeb9fn9ZLPyvfq19mW5tLLTU9SauWn/N32GOPyFjIalTWetSvlaXHUwMDE1yJmgpXFEXHUwMDE07G17vdY4RmPjrF5P65r7x8O0p1FJojBm9ugwoy7t8kmyUlx1MDAxOeyj/0ZvZFx1MDAxZVx1MDAxYvr1P99ccld9XGaiQO+tK9R760haJ/34in+hPy/rzlrzw4/qcXl5ZbXJW5/MhCs+e6GDZa0leVwiR35A8ZlEMMb5XHUwMDAwbVxmXHUwMDAx/1x1MDAxZqP4f+0nXHUwMDE1XSnnlZ6F41x1MDAxMNXeWylVoMz8utV6y0I6JaH5zlgyXHUwMDFjXHUwMDA2lZ5YeWtcdTAwMDPKNGl9vdzpzjdPTmpddLjRrDW6g1/sdMvt7my73exVk3JuXCIkjUphWyt2d/fh09/epqrQ/+P293/fXHK9ulQspFhy4kn7y73ZzEyvPmim/5dcbvFmbvNNzc+7r2tcdTAwMGW3qDVcdTAwMGWHvKyCllazfnnYXHUwMDA3hb54flx1MDAxM05YXHUwMDBlVv+CXHUwMDEzvFx1MDAxMGl10KlhXHUwMDE5hSYlXk5aXHUwMDE1OrqaSzbOkqR36XfVxKOJXHUwMDE1XHUwMDEyl3nAiaa0jz6UXHUwMDE4LzhAQVx1MDAxNHvnnWV+lFxyLYZcdTAwMTItMFx1MDAxYr1jXHUwMDE3JHNwQ+yncsJcdTAwMWKjnJbAi2hcdTAwMDNMXHUwMDBlSoCJ2pqMtF6R5OmQpFBEseSFM1x1MDAxZZDUXlx1MDAxNJCoMFh9y0tcdTAwMThcdTAwMWHi2PL4SPIp7OxcdTAwMWU210/LbnV//njnvLUzu5RMOpJo4Yy24LegXHUwMDFmYN1cdTAwMDNYXHUwMDEyhPHOgKtLZ02GxD0pkijBXHUwMDE294ij0I4zhCNFXHUwMDEyXHK8I1x1MDAwMnfSxGBIMockTNJ5pWRcdTAwMDaHXqHkKUlJkZBiyYtnPCw5elFYYvxgderjWGPYe5eq9igs0Wtfa8tr9d2l+pcv69XN9tmnmv462VhCXHUwMDAx9sbCxbfQXHUwMDE0I2VcdTAwMTjwcbxcdTAwMTXWg5VohVdlsjPmKdFEsdBcdTAwMWUzMlx1MDAwNO1h89RcdTAwMTBewlL4iFx1MDAxNnD+XHJcdTAwMThcYpR6XHUwMDEwTZzGSFx1MDAxNdmp8uunXHUwMDA2TFxuRVx1MDAxNEspL53xwOT4JYGJUnqw+lx1MDAxNkzA+j2ReoCLs/L9ov5prbGv55bqXHUwMDBiteWTXHUwMDAzVVpqTDaYxICJ91x1MDAxMH6QjqU1Lp2K11x1MDAwMVx1MDAxM2nR7tCR8fhwTlxyXGbtibiJXHUwMDE0VllcdFx1MDAwNm2cXHUwMDA0R/bpzEzhxFxiXHUwMDFmNJTVe1x1MDAwM1x1MDAxZe0zduCXm1x1MDAxMyxcdTAwMDRcdTAwMWFJ1iue/JokT+nnXHUwMDE0XG6p35pcdTAwMTPPeHhSn0I8uWftgWxGd3NrXHUwMDBmZFxmOTd+XGJWr5S/nVx1MDAxZn0+qfPsUYvd8dW6WmhNNqKQNCxYSasjoPhM9PJcdTAwMWFQSFx1MDAwYtZkVdBcdTAwMTQvcM+39KC1IFx1MDAxYuL0XHUwMDA0XHUwMDExkspcdTAwMGaLwmrotNFcdTAwMTKgYcGXdC5cYlx1MDAxYqRRcN7UqMhJ0lxcONu52j0/qah29fPC6fvKwVnjsYiirU3f7e+tPJz8mZVcdTAwMDdcZrkwVEhGe+WC1eOz8tbibnN7s37mqLSzs0WbXHUwMDFma1x1MDAxZjfnJ13tXHUwMDFk6FZwXHUwMDE0SDtcdTAwMThMe1ftnVx1MDAxMp6Ug1BccjBcdTAwMTTw+TxmXHUwMDE0XG7vXHUwMDE0bKBh7T15M0Thg2BvgUNcbuNcdKRynNxcdTAwMTKzXHLBj7KhXHUwMDEzpfFTY0NLhVwiiiUnnPFMaGNcbk1oMZLIQkrOMliScPDTKTpcbkk2djY/bm3u+IPTsFx1MDAxMT4tguDWW81JR1x1MDAxMlx1MDAxYlx1MDAwNIQvXHUwMDE51stcdTAwMDbOWMg+lDBcdTAwMGKjQ5CKXHUwMDE1rFP88SxYQkFcdTAwMTiSwCyWOihY8/Q2abxQXG7MWnYgfUHBwbT5lVx1MDAwN4J/KZVcdTAwMWHl4b+iyXV5XHUwMDE4mlx1MDAxNMsolrx0xoOT5suCk+JwYVxc4PSBzPhcdTAwMWU+cWOzxydL7ZLtleWHlfUvXHUwMDFkklNcdTAwMDAnSlx1MDAxYa018MJcdTAwMDc3kFx1MDAwYsRKaFx1MDAwMlx1MDAxZlx1MDAwN8cl8Fx1MDAxNv08y5jkRVxcN9BWXo+Bh1BcdTAwMTN4kLCMcKOdXGa40OW5XHSolVx1MDAwNpnnUVx1MDAxOVx1MDAxMa9ocl1cdTAwMWXITYqFXHUwMDE0S14848FJ6yXBXHRcYkQhO/FOXHUwMDE5vJVUs0d6935vt/Jtb2Frduk4nOpccrtXXd+fdDSBd1x1MDAxZpNcdGB1XGZcdTAwMWLnXHUwMDA2/Fx1MDAxY1x1MDAwYp+bTVxmKFx1MDAxYqkpM8ufXHUwMDE4TIKJ81x1MDAxMtpoNVxmXFwqk1swwTUgXHUwMDFljmRg5bTN+GM3WGJ8kDCM+tXPuZ1cdTAwMWZPyUxcbkV003pXOOMhyemLQlx1MDAxMlecq6lNYE/8XHUwMDAwP2f2cnlVdrRbOF5obG8murr+ZWth0qHEgXpYw1x1MDAxMS+MsVx1MDAwM7xEXHUwMDFi4eNcdTAwMDR1MYZcdTAwMTjk41KUiyMmTiiS3oP7SO+s4yFcdTAwMGJcdTAwMGZkhJZcdTAwMWOMXHUwMDAzZHiWXHUwMDE47CCWxGaYTD8qKeJcdTAwMTVLrstcdTAwMDPXXHUwMDFkXG5lXHUwMDE0SykvnvHQpP2i0CRcdTAwMTSjXHQrY7xWXHUwMDBmcHO2z3tcdTAwMWJ7R9+uVlvdk0+7h9/nXHUwMDE3T7Z2Jlx1MDAxZE1cdTAwMDBcdTAwMTigXHUwMDFkTnlcdTAwMGbg1LllXHUwMDA3KZQ3sEfOx7xqflSO1b3LXHUwMDBlNqJcdTAwMWGMnrNBe+v8kMxcYlx1MDAwModcIrifgbyRkpzPIYpXnlxcMK9R2GdBlEJcdTAwMTHFklx1MDAxN854eNKZQjxpJ/vd6/k0XHUwMDA0VJi4MG1cdTAwMTN+uCUjx/d15Lleblx1MDAwNl+ZX96qXm7ufvxgXHUwMDBm1drEQYog71xmZO/xbDE4lH7xJpRihNLO4enJWzuYK2FIWMVcdTAwMWGzVsf1LjM41IdRloODg6FpV1x1MDAwZWOTXHUwMDE4ncUsVUOCssDB4GWMXHUwMDBmW4q5tTpcdTAwMWaUlWBVcVx1MDAwNWKq4iiZqdZ90FT7/VVNVbyWz/B/NT0kdnh42rNz7fm589mrT+uf5jbW6ejrb2VcdTAwMDfRc86AQYquXHUwMDA1XGI6RFx1MDAwNq0nLVx1MDAwN1Ncco2QXHUwMDA21N1cdTAwMDRcdTAwMDJcdTAwMDNcdTAwMDNiPkrh71xccLt/UFx0jtxPSXZSOz0ka1x1MDAxOYCtldchsCan7mxK+aXwhq2SWuupUvipMadk40JDiFx1MDAwZlxmwGHFnP06XHUwMDFhY3aZdXGnXHUwMDE1saRR3XHMW2TPIGnGSmDZnVVcclx1MDAxM31GXG5cdTAwMWE22jt4XXpkd3BcdTAwMWZiXG6IlMFcdTAwMDSvrc/2xkKSVfBcdTAwMWNcdTAwMTRalcsukVx1MDAxNPSmRJ9cdTAwMTao6HNcdTAwMTA7d+dZwTJjXHUwMDEysoy7o4JcdTAwMDKDXHUwMDFi+bDwXmBOpFx1MDAwMpQrh4FoPdCfVejJgsNcdTAwMDdgOo982lJcdTAwMWNcdTAwMDFcdTAwMDXHXHUwMDFhrlJQOmOFYrHCKI0uTTCYM8GO7Fx1MDAwZbKNsTxcdTAwMWJAqcGInL8zPi/6RFx1MDAwMF15mDxPfmR/MU2MYFwiXHUwMDE5r0davku9Slx1MDAxNH1+g7kqcVx1MDAwNVDFj37eOEBIldBcdTAwMWZLqeju7lx1MDAxYtzMalx1MDAxOTd6alxudrSyXHUwMDE0Kf59xO9sXG6J3z1cdTAwMTlsTlx1MDAxN4e4rbOM+TK+0Wuuz6+crl7Mqlx1MDAxZnulTvlDe+mMNy8n3ehcdTAwMTknwK9cdTAwMTRcdTAwMTic11lcdTAwMGV4k1x1MDAxMUvCgVx1MDAxYlx1MDAwNmPili6YnGfzJElcdTAwMGLtwON0zOa3alhSLFG0z945YFx1MDAwZalcYrM526c57iFmO1WuZGZ6nT8t2btH842/Z/u8gjFcdTAwMDJcdTAwMDdcdTAwMWFf9b+a3tGXw9rR5iwv8PtcdTAwMGZcdTAwMWaOT/fDxaSrPjxj4z0sXHUwMDE4nGLjXHUwMDA3+V5wgmCtgKNcdTAwMWN3Oj5ueede1Zdxu1xcXHUwMDAwm9Mx/TJcdTAwMWVcdTAwMGaR130pYJFcdTAwMTRohfXeS7Y6n1x1MDAxME7GYLxuWrM3e39Q9Vx1MDAwYtMkYDZhkVVcdTAwMThf81x1MDAxN+vvZW1pe2mn+uPD0sV868PFwZqadM33SmhcdTAwMDNvRpFcdTAwMDKbsYOa36edXHUwMDFhnlCMLlr/rJpvQX3gxThcdTAwMGI3X6phi5uCXHUwMDAzLFx1MDAwZsivXG64QltcdTAwMWWi+X3KKKfK48mo/sVcdTAwMWZTfTDRQthX8ELozsEzo3T/c3nlIOz0dlx1MDAxNre+VbfNXFxz7+hcdTAwMGL/sdC5/C3dXHUwMDA3QVx1MDAxNnA+4E1RiCddXGYsxGFCXGJv4VHYuFxmXHUwMDA3n+b5XGKPMcLENT/jQTSz1jh7VpD0kcrHqVx1MDAxYThzKMPtqSmsJYHyj6I7W+X35+HbxWzpaC/o7fZe7dTtlVx1MDAxZav4PmRiar+n+JdPq/hFsS24WFSk9Vx1MDAxMVxmPV7/+MvPvrfa/fJ5dtFsfdtL2LS2k6vV2Vx013rrXHUwMDAw6Vx1MDAwMHqvXHLER4NpcVaKmMJcdTAwMDIkXHUwMDA2XHUwMDBls3+uhH3HKi71gOO7XHUwMDEwlM9rvCVcdTAwMTGDXHUwMDEz2sEseOlM/nSsyPA5hqSnSuWnJrpVKlx1MDAxNFIsefGk/eXebGaaX01h0KBwXHUwMDFibSjOiqN+4EiNv1DUOqyczF+cbFx1MDAxZibV4+rR+bfjlcXSb5HHP1x0JUbAPjrlXGLOiFxy6Z2vI1x1MDAwNnCbQNKCizm4lI1APvlJQTF2XHUwMDE2M4ect9pcZkvYd2CX3inpXHUwMDAz/tl8pNxK1Fx1MDAwNmNGZbK8Qsl1efhJQcNFXHUwMDE0S044YyFcdMtcdTAwMTeFJPdtyI+rXHUwMDAx7iFYUls7XFy6aLYv2V86173c2Obu1Vx1MDAxZjtz7HexhITSbIhJXHUwMDA2SYN+KEv4fjouZ2h4hppcdTAwMWW3xHzPdnywXHUwMDFljsRcdTAwMDc/TVx1MDAxOFx1MDAxMnhUXHUwMDA2RDzuTjJMRqvsjv1bWmJjjie/0pJnoiVFQoolL57xwIReXHUwMDEwmCineLD6XHUwMDE3mNjgPcW94WNjyfba2u5qpbW+0eDawrY++TG3uFqadCyxXCJcdTAwMDAzXHUwMDE1XGaNJEmDXHUwMDFiXHS1XHUwMDE1XHUwMDE2Xr2Gz1xylzuY59lIqKXQMVx1MDAwNmuUMlx1MDAxNNN582BcdTAwMTLPooG/XHUwMDFmWOlcYmo0JGVFWa2DtGpUPOtcdTAwMTVMrsuD0/WtjEeOQUAxL+5utr6KXHUwMDA3SsV97Vx1MDAwMVx1MDAxZl6akavGSlx0mC+r4Vx1MDAxMrGyXHUwMDE07nhMpXiijIrpSVx1MDAxMr43Q+yjuivUoH53TlCcypFdXHUwMDFiuGLejYd0/IKQXHUwMDBlrn/xSa3WXHUwMDEzsX/Agef7V1x1MDAwYkmj9/Wssd4zXHUwMDE34WD+6/FcdTAwMGZZnnCkY1x1MDAxNzVOypjw5kxuy7TRQsNSQset8ijPs5kgXHUwMDEyeMcwu9ZZks5ccuNNTHH3dlxcXXOsTNTYQahTXHUwMDFlXHUwMDA0z1o/asv0K9Jdl4chXVx1MDAxMDAwcNTxKVx0vtbddFx1MDAxYlx1MDAwMJ0ltvG8X1x0uCM1qrt4nng8ZI295LhcdTAwMTjz7m7bgKhH4mahXHUwMDAyXVx1MDAwZlx1MDAxZI6jhS5bKLn1crxIXHUwMDEzq0lcdTAwMDa6NzeDnim3WptdaOPtXHUwMDEwZs5rSW9ueF5sTI19czOiiEdJf+Q/3/z8P/91XFxXIn0= \ No newline at end of file diff --git a/packages/web-component-learningmap/index.html b/packages/web-component-learningmap/index.html index 4b03d5c3..4c0053f8 100644 --- a/packages/web-component-learningmap/index.html +++ b/packages/web-component-learningmap/index.html @@ -1,187 +1,81 @@ - + - - - Learningmap - - + + + Learningmap Editor Test + + - + + diff --git a/packages/web-component-learningmap/map.html b/packages/web-component-learningmap/map.html new file mode 100644 index 00000000..1cc6bda7 --- /dev/null +++ b/packages/web-component-learningmap/map.html @@ -0,0 +1,88 @@ + + + + + + + Learningmap Editor Test + + + + + + + + + + + + diff --git a/packages/web-component-learningmap/package.json b/packages/web-component-learningmap/package.json index 7c7518bf..fa7d65ec 100644 --- a/packages/web-component-learningmap/package.json +++ b/packages/web-component-learningmap/package.json @@ -21,7 +21,7 @@ "repository": { "type": "git", "url": "git+https://github.com/openpatch/hyperbook.git", - "directory": "packages/web-component-learningmap" + "directory": "packages/web-component-learningmap-editor" }, "bugs": { "url": "https://github.com/openpatch/hyperbook/issues" @@ -32,18 +32,18 @@ "build": "vite build" }, "dependencies": { - "@excalidraw/excalidraw": "0.18.0", "@r2wc/react-to-web-component": "^2.0.4", + "@szhsin/react-menu": "^4.5.0", "@xyflow/react": "^12.8.6", "elkjs": "^0.11.0", - "js-yaml": "^4.1.0", + "html-to-image": "1.11.11", "lucide-react": "^0.544.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tslib": "^2.8.1" }, "devDependencies": { "@rollup/plugin-typescript": "^12.1.2", - "@types/js-yaml": "^4.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/packages/web-component-learningmap/src/ColorSelector.tsx b/packages/web-component-learningmap/src/ColorSelector.tsx new file mode 100644 index 00000000..911ee2f4 --- /dev/null +++ b/packages/web-component-learningmap/src/ColorSelector.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface ColorSelectorProps { + value: string; + onChange: (color: string) => void; + label?: string; +} + +export const ColorSelector: React.FC = ({ value, onChange, label }) => { + return ( + + {label && {label}} + onChange(e.target.value)} + style={{ width: 32, height: 32, border: "none", background: "none", padding: 0 }} + /> + onChange(e.target.value)} + placeholder="#e5e7eb" + style={{ width: 100 }} + /> + + ); +}; diff --git a/packages/web-component-learningmap/src/Drawer.tsx b/packages/web-component-learningmap/src/Drawer.tsx new file mode 100644 index 00000000..77847dc0 --- /dev/null +++ b/packages/web-component-learningmap/src/Drawer.tsx @@ -0,0 +1,143 @@ +import { Node } from "@xyflow/react"; +import { NodeData } from "./types"; +import { X, Lock, CheckCircle } from "lucide-react"; +import { Video } from "./Video"; +import StarCircle from "./icons/StarCircle"; + +interface DrawerProps { + open: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + node: Node; + nodes: Node[]; + onNodeClick: (_: any, node: Node, focus: boolean) => void; +} + +function getUnlockConditions(node: Node, nodes: Node[]): Node[] { + const unmetNeeds: Node[] = []; + if (node.data?.unlock?.after) { + node.data.unlock.after.forEach((depId: string) => { + const depNode = nodes.find(n => n.id === depId); + if (depNode && depNode.data?.state !== 'completed' && depNode.data?.state !== 'mastered') { + unmetNeeds.push(depNode); + } + }); + } + return unmetNeeds; +} + +function getCompletionNeeds(node: Node, nodes: Node[]): Node[] { + const unmetNeeds: Node[] = []; + if (node.data?.completion?.needs) { + node.data.completion.needs.forEach((needId: string) => { + const needNode = nodes.find(n => n.id === needId); + if (needNode && needNode.data?.state !== 'completed' && needNode.data?.state !== 'mastered') { + unmetNeeds.push(needNode); + } + }); + } + return unmetNeeds; +} + +export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick }: DrawerProps) { + if (!open) return null; + + const locked = node.data?.state === 'locked' || false; + const unlocked = node.data?.state === 'unlocked' || false; + const completed = node.data?.state === 'completed' || false; + const started = node.data?.state === 'started' || false; + const mastered = node.data?.state === 'mastered' || false; + + const unlockConditions = getUnlockConditions(node, nodes); + const completionNeeds = getCompletionNeeds(node, nodes); + + const handleStateChange = (newState: 'locked' | 'unlocked' | 'started' | 'completed') => () => { + if (node.type === "topic" || locked) return; + + onUpdate({ + ...node, + data: { + ...node.data, + state: newState + } + }); + }; + + return ( + <> + + + > + ); +} diff --git a/packages/web-component-learningmap/src/EdgeDrawer.tsx b/packages/web-component-learningmap/src/EdgeDrawer.tsx new file mode 100644 index 00000000..b8357d0a --- /dev/null +++ b/packages/web-component-learningmap/src/EdgeDrawer.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { X, Trash2, Save } from "lucide-react"; +import { Edge } from "@xyflow/react"; +import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent"; + +interface EdgeDrawerProps { + edge: Edge | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (edge: Edge) => void; + onDelete: () => void; +} + +export const EdgeDrawer: React.FC = ({ + edge: selectedEdge, + isOpen: edgeDrawerOpen, + onClose: closeDrawer, + onUpdate: updateEdge, + onDelete: deleteEdge, +}) => { + if (!selectedEdge || !edgeDrawerOpen) return null; + return ( + + + + + Edit Edge + + + + + { + let updated = { ...selectedEdge }; + if (field === "color") { + updated = { + ...updated, + style: { ...updated.style, stroke: value }, + }; + } else if (field === "animated") { + updated = { ...updated, animated: value }; + } else if (field === "type") { + updated = { ...updated, type: value }; + } + updateEdge(updated); + }} + /> + + + Delete Edge + + + Save Changes + + + + + ); +}; diff --git a/packages/web-component-learningmap/src/EditorDrawer.tsx b/packages/web-component-learningmap/src/EditorDrawer.tsx new file mode 100644 index 00000000..0bdec2bd --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawer.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from "react"; +import { X, Trash2, Save } from "lucide-react"; +import { Node, useReactFlow } from "@xyflow/react"; +import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; +import { EditorDrawerTopicContent } from "./EditorDrawerTopicContent"; +import { EditorDrawerImageContent } from "./EditorDrawerImageContent"; +import { EditorDrawerTextContent } from "./EditorDrawerTextContent"; +import { NodeData } from "./types"; + +interface EditorDrawerProps { + node: Node | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + onDelete: () => void; +} + +export const EditorDrawer: React.FC = ({ + node, + isOpen, + onClose, + onUpdate, + onDelete, +}) => { + const [localNode, setLocalNode] = useState | null>(node); + const { getNodes } = useReactFlow(); + const allNodes = getNodes(); + + useEffect(() => { + setLocalNode(node); + }, [node]); + + 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"); + + // Helper for dropdowns + const renderNodeSelect = (value: string, onChange: (id: string) => void) => ( + onChange(e.target.value)}> + Select node... + {nodeOptions.map(n => ( + + {n.data.label || n.id} + + ))} + + ); + + // Completion Needs + const handleCompletionNeedsChange = (idx: number, id: string) => { + if (!localNode) return; + const needs = [...(localNode.data.completion?.needs || [])]; + needs[idx] = { id }; + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + const addCompletionNeed = () => { + if (!localNode) return; + const needs = [...(localNode.data.completion?.needs || []), { id: "" }]; + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + const removeCompletionNeed = (idx: number) => { + if (!localNode) return; + const needs = (localNode.data.completion?.needs || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); + }; + + // Completion Optional + const handleCompletionOptionalChange = (idx: number, id: string) => { + if (!localNode) return; + const optional = [...(localNode.data.completion?.optional || [])]; + optional[idx] = { id }; + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + const addCompletionOptional = () => { + if (!localNode) return; + const optional = [...(localNode.data.completion?.optional || []), { id: "" }]; + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + const removeCompletionOptional = (idx: number) => { + if (!localNode) return; + const optional = (localNode.data.completion?.optional || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); + }; + + // Unlock After + const handleUnlockAfterChange = (idx: number, id: string) => { + if (!localNode) return; + const after = [...(localNode.data.unlock?.after || [])]; + after[idx] = id; + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + const addUnlockAfter = () => { + if (!localNode) return; + const after = [...(localNode.data.unlock?.after || []), ""]; + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + const removeUnlockAfter = (idx: number) => { + if (!localNode) return; + const after = (localNode.data.unlock?.after || []).filter((_: any, i: number) => i !== idx); + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); + }; + + const handleSave = () => { + if (!localNode) return; + onUpdate(localNode); + onClose(); + }; + + const handleFieldChange = (field: string, value: any) => { + setLocalNode((prev: Node | null) => ({ + ...prev!, + data: { ...prev!.data, [field]: value }, + })); + }; + + const handleResourceChange = (index: number, field: string, value: string) => { + if (!localNode) return; + const resources = [...(localNode.data.resources || [])]; + resources[index] = { ...resources[index], [field]: value }; + handleFieldChange("resources", resources); + }; + + const addResource = () => { + if (!localNode) return; + const resources = [...(localNode.data.resources || []), { label: "", url: "" }]; + handleFieldChange("resources", resources); + }; + + const removeResource = (index: number) => { + if (!localNode) return; + const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index); + handleFieldChange("resources", resources); + }; + + let content; + if (localNode.type === "task") { + content = ( + <> + + Edit Task + + + + + + > + ); + } else if (localNode.type === "topic") { + content = ( + <> + + Edit Topic + + + + + + > + ); + } else if (localNode.type === "image") { + content = ( + <> + + Edit Image + + + + + + > + ); + } else if (localNode.type === "text") { + content = ( + <> + + Edit Text + + + + + + > + ); + } + + return ( + <> + + + {content} + + + Delete Node + + + Save Changes + + + + > + ); +}; diff --git a/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx new file mode 100644 index 00000000..938cbb34 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx @@ -0,0 +1,48 @@ +import { ColorSelector } from "./ColorSelector"; + +import { Edge } from "@xyflow/react"; + +interface Props { + localEdge: Edge; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerEdgeContent({ + localEdge, + handleFieldChange +}: Props) { + return ( + + + handleFieldChange("color", color)} + /> + + + + handleFieldChange("animated", e.target.checked)} + /> + Animated + + + + Type + handleFieldChange("type", e.target.value)} + > + Default + Straight + Step + Smoothstep + Simple Bezier + + + + ); +} diff --git a/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx new file mode 100644 index 00000000..bac69992 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx @@ -0,0 +1,43 @@ +import { Node } from "@xyflow/react"; +import { ImageNodeData } from "./types"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerImageContent({ localNode, handleFieldChange }: Props) { + // Convert file to base64 and update node data + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + handleFieldChange("data", reader.result); + } + }; + reader.readAsDataURL(file); + }; + + return ( + + + Upload Image (JPG, PNG, SVG) + + + {localNode.data.data && ( + + Preview: + + + + + )} + + ); +} diff --git a/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx new file mode 100644 index 00000000..d23816e3 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx @@ -0,0 +1,215 @@ +import { Node } from "@xyflow/react"; +import { Plus, Trash2 } from "lucide-react"; +import { NodeData } from "./types"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; + handleResourceChange: (index: number, field: string, value: string) => void; + addResource: () => void; + removeResource: (index: number) => void; + handleUnlockAfterChange: (idx: number, id: string) => void; + addUnlockAfter: () => void; + removeUnlockAfter: (idx: number) => void; + renderNodeSelect: (value: string, onChange: (id: string) => void) => React.ReactNode; + handleCompletionNeedsChange: (idx: number, id: string) => void; + addCompletionNeed: () => void; + removeCompletionNeed: (idx: number) => void; + handleCompletionOptionalChange: (idx: number, id: string) => void; + addCompletionOptional: () => void; + removeCompletionOptional: (idx: number) => void; +} + +export function EditorDrawerTaskContent({ + localNode, + handleFieldChange, + handleResourceChange, + addResource, + removeResource, + handleUnlockAfterChange, + addUnlockAfter, + removeUnlockAfter, + renderNodeSelect, + handleCompletionNeedsChange, + addCompletionNeed, + removeCompletionNeed, + handleCompletionOptionalChange, + addCompletionOptional, + removeCompletionOptional, +}: Props) { + // Color options for the dropdown + const colorOptions = [ + { value: "blue", label: "Blue", className: "react-flow__node-topic blue" }, + { value: "yellow", label: "Yellow", className: "react-flow__node-topic yellow" }, + { value: "lila", label: "Lila", className: "react-flow__node-topic lila" }, + { value: "pink", label: "Pink", className: "react-flow__node-topic pink" }, + { value: "teal", label: "Teal", className: "react-flow__node-topic teal" }, + { value: "red", label: "Red", className: "react-flow__node-topic red" }, + { value: "black", label: "Black", className: "react-flow__node-topic black" }, + { value: "white", label: "White", className: "react-flow__node-topic white" }, + ]; + + // Determine default color based on node type + let defaultColor = "blue"; + if (localNode.type === "topic") defaultColor = "yellow"; + const selectedColor = localNode.data?.color || defaultColor; + return ( + + + Node Color + + {colorOptions.map(opt => ( + handleFieldChange("color", opt.value)} + className={opt.className} + style={{ + width: 28, + height: 28, + borderRadius: 6, + cursor: "pointer", + fontWeight: "bold", + boxSizing: "border-box", + display: "inline-block", + padding: 0, + }} + >{selectedColor === opt.value ? "X" : ""} + ))} + + + + Label * + handleFieldChange("label", e.target.value)} + placeholder="Node label" + /> + + + Summary + handleFieldChange("summary", e.target.value)} + placeholder="Short summary" + /> + + + Description + handleFieldChange("description", e.target.value)} + placeholder="Detailed description" + rows={4} + /> + + + Duration + handleFieldChange("duration", e.target.value)} + placeholder="e.g., 30 min" + /> + + + Video URL + handleFieldChange("video", e.target.value)} + placeholder="YouTube or video URL" + /> + + + Resources + {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( + + handleResourceChange(idx, "label", e.target.value)} + placeholder="Label" + style={{ flex: 1 }} + /> + handleResourceChange(idx, "url", e.target.value)} + placeholder="URL" + style={{ flex: 2 }} + /> + removeResource(idx)} className="icon-button"> + + + + ))} + + Add Resource + + + + Unlock Password + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })} + placeholder="Optional password" + /> + + + Unlock Date + handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })} + /> + + + Unlock After + {(localNode.data.unlock?.after || []).map((id: string, idx: number) => ( + + {renderNodeSelect(id, newId => handleUnlockAfterChange(idx, newId))} + removeUnlockAfter(idx)} className="icon-button"> + + + + ))} + + Add Unlock After + + + {localNode.type === "topic" && + Completion Needs + {(localNode.data.completion?.needs || []).map((need: string, idx: number) => ( + + {renderNodeSelect(need, newId => handleCompletionNeedsChange(idx, newId))} + removeCompletionNeed(idx)} className="icon-button"> + + + + ))} + + Add Need + + } + {localNode.type === "topic" && + Completion Optional + {(localNode.data.completion?.optional || []).map((opt: string, idx: number) => ( + + {renderNodeSelect(opt, newId => handleCompletionOptionalChange(idx, newId))} + removeCompletionOptional(idx)} className="icon-button"> + + + + ))} + + Add Optional + + } + + ); +} diff --git a/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx new file mode 100644 index 00000000..d136691d --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx @@ -0,0 +1,44 @@ +import { Node } from "@xyflow/react"; +import { TextNodeData } from "./types"; +import { ColorSelector } from "./ColorSelector"; +import { RotationInput } from "./RotationInput"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; +} + +export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props) { + return ( + + + Text + handleFieldChange("text", e.target.value)} + placeholder="Background Text" + /> + + + Font Size + handleFieldChange("fontSize", Number(e.target.value))} + /> + + + handleFieldChange("color", color)} + /> + + handleFieldChange("rotation", v)} + /> + + ); +} diff --git a/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx new file mode 100644 index 00000000..ee7ed8e3 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx @@ -0,0 +1,6 @@ +import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; + +// For now, topic content is the same as task content. You can customize later if needed. +export function EditorDrawerTopicContent(props: any) { + return ; +} diff --git a/packages/web-component-learningmap/src/EditorToolbar.tsx b/packages/web-component-learningmap/src/EditorToolbar.tsx new file mode 100644 index 00000000..7ed88b7d --- /dev/null +++ b/packages/web-component-learningmap/src/EditorToolbar.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Menu, MenuButton, MenuItem, SubMenu } from "@szhsin/react-menu"; +import "@szhsin/react-menu/dist/index.css"; +import '@szhsin/react-menu/dist/transitions/zoom.css'; +import { Save, Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown } from "lucide-react"; + +interface EditorToolbarProps { + saved: boolean; + 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; + onSave: () => void; + onDownlad: () => void; + onOpen: () => void; + onExportSVG: () => void; +} + +export const EditorToolbar: React.FC = ({ + saved, + debugMode, + previewMode, + showCompletionNeeds, + showCompletionOptional, + showUnlockAfter, + onTogglePreviewMode, + onToggleDebugMode, + onSetShowCompletionNeeds, + onSetShowCompletionOptional, + onSetShowUnlockAfter, + onAddNewNode, + onOpenSettingsDrawer, + onSave, + onDownlad, + onOpen, + onExportSVG +}) => ( + + + Nodes}> + onAddNewNode("task")}>Add Task + onAddNewNode("topic")}>Add Topic + onAddNewNode("image")}>Add Image + onAddNewNode("text")}>Add Text + + + Settings + + + + }> + Debug>}> + + Enable Debug Mode + + onSetShowCompletionNeeds(e.checked ?? false)} disabled={!debugMode}> + Show Completion Needs Edges + + onSetShowCompletionOptional(e.checked ?? false)} disabled={!debugMode}> + Show Completion Optional Edges + + onSetShowUnlockAfter(e.checked ?? false)} disabled={!debugMode}> + Show Unlock After Edges + + + + Preview + + + Save{!saved ? "*" : ""} + + + Download + + + Open + + {false && + Export as SVG + } + + + +); diff --git a/packages/web-component-learningmap/src/FloatingEdge.tsx b/packages/web-component-learningmap/src/FloatingEdge.tsx new file mode 100644 index 00000000..29b7528b --- /dev/null +++ b/packages/web-component-learningmap/src/FloatingEdge.tsx @@ -0,0 +1,39 @@ +import { Edge, getBezierPath, useInternalNode } from '@xyflow/react'; + +import { getEdgeParams } from './helper'; + +const FloatingEdge = ({ id, source, target, markerEnd, style }: Edge) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ( + + ); +} + +export default FloatingEdge; + diff --git a/packages/web-component-learningmap/src/HyperbookLearningmap.tsx b/packages/web-component-learningmap/src/HyperbookLearningmap.tsx index 5454e3d9..b9708471 100644 --- a/packages/web-component-learningmap/src/HyperbookLearningmap.tsx +++ b/packages/web-component-learningmap/src/HyperbookLearningmap.tsx @@ -1,1146 +1,32 @@ -import { useState, useCallback, useEffect } from "react"; -import { getAutoLayoutedNodesElk } from "./autoLayoutElk"; -import * as yaml from "js-yaml"; import { - ReactFlow, - Controls, - useNodesState, - useEdgesState, ReactFlowProvider, - Handle, - Position, - ColorMode, - useReactFlow, - Node, - useOnViewportChange, } from "@xyflow/react"; -import { - CheckCircle2, - Circle, - Lock, - Clock, - Compass, - Star -} from "lucide-react"; - -interface UnlockCondition { - after?: string[]; // Node IDs that must be completed - date?: string; // ISO date string for unlock - password?: string; // Password required to unlock -} - -interface CompletionNeed { - id: string; - source?: string; - target?: string; -} - -interface Completion { - needs?: CompletionNeed[]; - optional?: CompletionNeed[]; -} - -interface TopicNodeData { - label: string; - description?: string; - duration?: string; - position?: { x: number; y: number }; - unlock?: UnlockCondition; - completion?: Completion; - video?: string; - resources?: { label: string; url: string }[]; - summary?: string; - [key: string]: any; -} - -interface TaskNodeData { - label: string; - description?: string; - duration?: string; - position?: { x: number; y: number }; - unlock?: UnlockCondition; - video?: string; - resources?: { label: string; url: string }[]; - summary?: string; - [key: string]: any; -} - -type LearningNodeData = TaskNodeData | TopicNodeData; - -// ============================================================================ -// VIDEO UTILS -// ============================================================================ - -function isYoutubeUrl(url: string) { - return ( - typeof url === "string" && - (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/")) - ); -} - -function getYoutubeEmbedUrl(url: string) { - if (url.includes("youtube.com/watch?v=")) { - const videoId = url.split("v=")[1].split("&")[0]; - return `https://www.youtube-nocookie.com/embed/${videoId}`; - } - if (url.includes("youtu.be/")) { - const videoId = url.split("youtu.be/")[1].split("?")[0]; - return `https://www.youtube-nocookie.com/embed/${videoId}`; - } - return url; -} - -function getVideoMimeType(url: string) { - if (url.endsWith(".webm")) return "video/webm"; - if (url.endsWith(".mp4")) return "video/mp4"; - return "video/mp4"; -} - -// ============================================================================ -// CONSTANTS & TYPES -// ============================================================================ - -const TEXTS = { - en: { - trackJourney: "Track your learning journey", - progress: "Progress", - completed: "Completed", - available: "Available", - locked: "Locked", - topicsCompleted: "{completed} of {total} completed", - markAsComplete: "Mark as Complete", - markAsIncomplete: "Mark as Incomplete", - unlock: "Unlock", - unlocked: "Unlocked", - lockedBtn: "Locked", - resources: "Resources", - optional: "Optional", - passwordPlaceholder: "Password", - incorrectPassword: "Incorrect password", - startTask: "Start the Task", - inProgressLegend: "In Progress", - unlockPasswordHint: "Enter password to unlock this node.", - unlockDateHint: "This node unlocks after {date}.", - unlockAfterHint: "Complete these first:", - unlockClose: "Close", - unlockButton: "Unlock", - task: "Task" - }, - // ...other languages unchanged... - de: { - trackJourney: "Verfolge deinen Lernfortschritt", - progress: "Fortschritt", - completed: "Abgeschlossen", - available: "Verfügbar", - locked: "Gesperrt", - topicsCompleted: "{completed} von {total} abgeschlossen", - markAsComplete: "Als abgeschlossen markieren", - markAsIncomplete: "Als nicht abgeschlossen markieren", - unlock: "Entsperren", - unlocked: "Entsperrt", - lockedBtn: "Gesperrt", - resources: "Ressourcen", - optional: "Optional", - passwordPlaceholder: "Passwort", - incorrectPassword: "Falsches Passwort", - startTask: "Starte die Aufgabe", - inProgressLegend: "In Bearbeitung", - unlockPasswordHint: "Passwort eingeben, um dieses Thema zu entsperren.", - unlockDateHint: "Dieses Thema wird nach dem {date} freigeschaltet.", - unlockAfterHint: "Folgende zuerst abschließen:", - unlockClose: "Schließen", - unlockButton: "Entsperren", - task: "Aufgabe" - }, - // ...other languages unchanged... - es: { - trackJourney: "Sigue tu trayectoria de aprendizaje", - progress: "Progreso", - completed: "Completado", - available: "Disponible", - locked: "Bloqueado", - topicsCompleted: "{completed} de {total} completados", - markAsComplete: "Marcar como completado", - markAsIncomplete: "Marcar como incompleto", - unlock: "Desbloquear", - unlocked: "Desbloqueado", - lockedBtn: "Bloqueado", - resources: "Recursos", - optional: "Opcional", - passwordPlaceholder: "Ingresa la contraseña", - incorrectPassword: "Contraseña incorrecta", - startTask: "Iniciar la tarea", - inProgressLegend: "En progreso", - unlockPasswordHint: "Ingresa la contraseña para desbloquear este nodo.", - unlockDateHint: "Este nodo se desbloquea después de {date}.", - unlockAfterHint: "Completa estos primero:", - unlockClose: "Cerrar", - unlockButton: "Desbloquear", - task: "Tarea" - }, - fr: { - trackJourney: "Suivez votre parcours d'apprentissage", - progress: "Progrès", - completed: "Terminé", - available: "Disponible", - locked: "Verrouillé", - topicsCompleted: "{completed} sur {total} terminés", - markAsComplete: "Marquer comme terminé", - markAsIncomplete: "Marquer comme incomplet", - unlock: "Déverrouiller", - unlocked: "Déverrouillé", - lockedBtn: "Verrouillé", - resources: "Ressources", - optional: "Optionnel", - passwordPlaceholder: "Entrez le mot de passe", - incorrectPassword: "Mot de passe incorrect", - startTask: "Commencer la tâche", - inProgressLegend: "En cours", - unlockPasswordHint: "Entrez le mot de passe pour déverrouiller ce nœud.", - unlockDateHint: "Ce nœud se déverrouille après le {date}.", - unlockAfterHint: "Terminez ceux-ci d'abord :", - unlockClose: "Fermer", - unlockButton: "Déverrouiller", - task: "Tâche" - }, -}; - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -const parseRoadmapData = (roadmapData: string) => { - if (typeof roadmapData !== "string") { - return roadmapData; - } - - try { - return JSON.parse(roadmapData); - } catch { - try { - return yaml.load(roadmapData); - } catch (err) { - console.error("Failed to parse roadmap data:", err); - return {}; - } - } -}; - -const generateEdgesFromCompletionNeeds = (nodes: Node[], edgeConfig) => { - const edges = []; - nodes.forEach((node) => { - const needs = node.data?.completion?.needs || []; - needs.forEach((need: CompletionNeed) => { - if (need.id) { - edges.push({ - id: `${need.id}->${node.id}`, - source: need.id, - target: node.id, - sourceHandle: need.source || "bottom", - targetHandle: need.target || "top", - }); - } - }); - const optional = node.data?.completion?.optional || []; - optional.forEach((opt: CompletionNeed) => { - if (opt.id) { - edges.push({ - id: `${opt.id}->${node.id}-optional`, - source: opt.id, - target: node.id, - sourceHandle: opt.source || "bottom", - targetHandle: opt.target || "top", - style: { strokeDasharray: "5,5", strokeWidth: edgeConfig.width ?? 2, stroke: edgeConfig.color ?? "#94a3b8" }, - }); - } - }); - }); - return edges; -}; - -const areUnlockAfterCompleted = (node: Node, stateLookup: Record) => { - const unlockAfter = node.data?.unlock?.after || []; - return unlockAfter.every((id: string) => stateLookup[id]?.state === "completed"); -}; - -const isUnlockDatePassed = (node: Node) => { - const unlockDate = node.data?.unlock?.date; - if (!unlockDate) return true; - return new Date() >= new Date(unlockDate); -}; - -const isUnlockedByPassword = (node: Node, savedPassword: boolean) => { - const password = node.data?.unlock?.password; - if (!password) return true; - return savedPassword; -}; - -const getLockedStyle = (isLocked: boolean) => { - return isLocked - ? { - background: "#f3f4f6", - color: "#a1a1aa", - borderColor: "#d1d5db", - boxShadow: "none", - } - : {}; -}; - -function areCompletionNeedsMet(node: Node, nodesArr: Node[]) { - const needs = node.data?.completion?.needs || []; - if (needs.length === 0) return true; - return needs.every((need: CompletionNeed) => { - const neededNode = nodesArr.find(n => n.id === need.id); - return neededNode?.data?.state === "completed" || neededNode?.data?.state === "fully-completed"; - }); -} - -function getTopicNodeStatus(node: Node, stateLookup: Record) { - if (node.type !== "topic") return node.data.state; - - const needs = node.data?.completion?.needs || []; - const optional = node.data?.completion?.optional || []; - - const needsStates = needs.map((n: { id: string }) => stateLookup[n.id]?.state); - const optionalStates = optional.map((n: { id: string }) => stateLookup[n.id]?.state); - - const anyStartedOrCompleted = [...needsStates, ...optionalStates].some( - s => s === "started" || s === "completed" || s === "fully-completed" - ); - const allNeedsCompleted = needs.length > 0 && needsStates.every(s => s === "completed" || s === "fully-completed"); - const allOptionalCompleted = optional.length === 0 || optionalStates.every(s => s === "completed" || s === "fully-completed"); - const allNeedsAndOptionalCompleted = allNeedsCompleted && allOptionalCompleted; - - if (allNeedsAndOptionalCompleted) return "fully-completed"; - if (allNeedsCompleted) return "completed"; - if (anyStartedOrCompleted) return "started"; - return undefined; -} - -function getAllOptionalNodeIds(nodes: Node[]) { - const ids = []; - nodes.forEach(n => { - if (n.type === "topic" && Array.isArray(n.data?.completion?.optional)) { - n.data.completion.optional.forEach((opt: { id: string }) => { - if (opt.id) ids.push(opt.id); - }); - } - }); - return ids; -} - -// ============================================================================ -// UNLOCK ICON & POPUP -// ============================================================================ - -function getLocaleFromLanguage(language: string): string { - // Map your language code to a locale string - switch (language) { - case "de": return "de-DE"; - case "es": return "es-ES"; - case "fr": return "fr-FR"; - case "en": - default: return "en-US"; - } -} - -function getUnlockInfo(unlock: UnlockCondition, t, language = "en") { - if (!unlock) return null; - if (unlock.password) { - return { icon: , hint: t.unlockPasswordHint, type: "password" }; - } - if (unlock.date) { - // Format date according to locale - const locale = getLocaleFromLanguage(language); - const dateObj = new Date(unlock.date); - const formattedDate = isNaN(dateObj.getTime()) - ? unlock.date - : dateObj.toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" }); - return { icon: , hint: t.unlockDateHint.replace("{date}", formattedDate), type: "date" }; - } - if (unlock.after && unlock.after.length > 0) { - return { icon: , hint: t.unlockAfterHint, type: "after" }; - } - return null; -} - -// ============================================================================ -// NODE COMPONENTS -// ============================================================================ - -const LearningNode = ({ data, type, language }) => { - const t = TEXTS[language] || TEXTS.en; - - const isCompleted = data.state === "completed"; - const isFullyCompleted = data.state === "fully-completed"; - const isLocked = data.locked; - const isStarted = data.state === "started"; - const isOptional = data.isOptional; - - const unlockInfo = getUnlockInfo(data.unlock, t, language); - - const lockedStyle = getLockedStyle(isLocked); - const startedStyle = isStarted ? { borderColor: "#f59e42" } : {}; - - return ( - - - {type === "task" && ( - - {t.task} - - )} - - {unlockInfo ? ( - unlockInfo.icon - ) : (isCompleted || isFullyCompleted) ? ( - - ) : isStarted ? ( - - ) : ( - - )} - - {data.label} - - - - - {isFullyCompleted && ( - - )} - - {data.summary && ( - - {data.summary} - - )} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - ); -}; - -const BackgroundNode = ({ data }) => { - if (!data.image?.src) return null; - return ; -}; - -const DebugNode = ({ data }) => { - const { screenToFlowPosition } = useReactFlow(); - const [mousePos, setMousePos] = useState(null); - - if (!data?.bounds) return null; - - const handleMouseMove = (e: any) => { - const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); - setMousePos(flowPos); - }; - - useEffect(() => { - window.addEventListener("mousemove", handleMouseMove); - return () => window.removeEventListener("mousemove", handleMouseMove); - }, []); - - return ( - - Debug Node - Position: x={data.bounds.x}, y={data.bounds.y} - - Mouse Position: - {mousePos - ? `x=${mousePos.x.toFixed(2)}, y=${mousePos.y.toFixed(2)}` - : 'Move mouse over node'} - - Size: width={data.bounds.width}, height={data.bounds.height} - - ); -}; - -// ============================================================================ -// DRAWER COMPONENTS -// ============================================================================ - -const NodeDrawer = ({ node, isOpen, onClose, onPasswordUnlock, onToggleComplete, language = "en", setNode }) => { - const t = TEXTS[language] || TEXTS.en; - if (!isOpen || !node) return null; - - const { getNodes } = useReactFlow(); - const nodesArr = getNodes(); - - const { state, locked: isLocked, completion, unlock, label, duration, description, video, resources } = node.data; - - // Check completion needs - const unmetNeeds = (completion?.needs || []) - .map((need) => { - const n = nodesArr.find((n) => n.id === need.id); - const completed = n?.data?.state === "completed" || n?.data?.state === "fully-completed"; - return !completed ? (n?.data?.label || need.id) : null; - }) - .filter(Boolean); - - const needsMet = unmetNeeds.length === 0; - - const [passwordInput, setPasswordInput] = useState(""); - const [passwordError, setPasswordError] = useState(false); - - // Password unlock handler - const handlePasswordUnlock = (e: any) => { - e.preventDefault(); - if (passwordInput === unlock.password) { - onPasswordUnlock(); - setPasswordError(false); - } else { - setPasswordError(true); - } - }; - - let buttonText = t.startTask; - if (state === "started") buttonText = t.markAsComplete; - if (state === "completed" || state === "fully-completed") buttonText = t.completed; - - const buttonStyle = state === "started" ? { backgroundColor: "#f59e42", color: "#fff" } : {}; - const unlockInfo = getUnlockInfo(unlock, t, language); - - let content = null - if (!isLocked) { - content = ( - <> - {description && ( - - {description.split("\n").map((text: string, idx: number) => ( - {text} - ))} - - )} - - {video && ( - - {isYoutubeUrl(video) ? ( - - ) : ( - - - Your browser does not support the video tag. - - )} - - )} - - {Array.isArray(resources) && resources.length > 0 && ( - - {t.resources} - - {resources.map((res, idx) => ( - - - {res.label || res.url} - - - ))} - - - )} - - {!needsMet && ( - - {t.unlockAfterHint} - - {unmetNeeds.map((label, idx) => ( - {label} - ))} - - - )} - > - ) - } else if (unlockInfo.type === "password") { - content = ( - - - {unlockInfo.hint} - - setPasswordInput(e.target.value)} - style={{ marginRight: "0.5em" }} - /> - {t.unlockButton} - {passwordError && ( - {t.incorrectPassword} - )} - - ) - } else if (unlockInfo.type === "date") { - - content = ( - - - {unlockInfo.hint} - - - ) - } else if (unlockInfo.type === "after") { - const needsList = unlock?.after - ? unlock.after.map((id) => { - const n = nodesArr.find((n) => n.id === id); - const completed = n?.data?.state === "completed" || n?.data?.state === "fully-completed"; - return { label: n?.data?.label || id, completed }; - }) - : []; - - content = needsList.length > 0 && ( - - - {unlockInfo.hint} - - - {needsList.map((need, idx) => ( - - {need.label} - - ))} - - - ) - - } - - return ( - <> - - - - {label} - {duration && ⏱️ {duration}} - - - - {content} - - - - - {isLocked ? ( - <> - {t.lockedBtn} - > - ) : !needsMet ? ( - <> - {t.lockedBtn} - > - ) : state === "completed" || state === "fully-completed" ? ( - <> - {buttonText} - > - ) : ( - <> - {buttonText} - > - )} - - - - > - ); -}; - -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ - -function HyperbookLearningmapInner({ roadmapData, nodeState, language = "en", x = 0, y = 0, zoom = 1 }) { - const t = TEXTS[language] || TEXTS.en; - const { getNodesBounds, setViewport } = useReactFlow(); - - const [nodes, setNodes] = useNodesState([]); - const [edges, setEdges] = useEdgesState([]); - const [colorMode, setColorMode] = useState("light"); - const [selectedNode, setSelectedNode] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [showDebugNode, setShowDebugNode] = useState(false); - const [unlockedPasswords, setUnlockedPasswords] = useState({}); - - const parsedRoadmap = parseRoadmapData(roadmapData); - const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : []; - const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : []; - const completedMap = nodeState || {}; - - - // Edge configuration - const edgeConfig = parsedRoadmap?.edges || {}; - const defaultEdgeOptions = { - animated: edgeConfig.animated ?? false, - style: { - stroke: edgeConfig.color ?? "#94a3b8", - strokeWidth: edgeConfig.width ?? 2, - }, - type: edgeConfig.type ?? "default", - selectable: false, - }; - - // Create background node if configured - const createBackgroundNode = () => { - if (!parsedRoadmap?.background?.image) return null; - const img = parsedRoadmap.background.image; - return { - id: "background-image", - type: "background", - position: { x: img.x ?? 0, y: img.y ?? 0 }, - data: { image: { src: img.src } }, - draggable: false, - selectable: false, - focusable: false, - zIndex: -1, - }; - }; - - // Create debug node - const createDebugNode = (currentNodes: Node[]) => { - if (!showDebugNode || !currentNodes.length) return null; - const filteredNodes = currentNodes.filter((n) => n.type !== "debug"); - const bounds = getNodesBounds(filteredNodes); - return { - id: "debug-node", - type: "debug", - position: { x: bounds.x, y: bounds.y }, - data: { bounds }, - style: { - outline: "2px solid red", - background: "rgba(255,255,255,0.8)", - fontSize: "32px", - width: bounds.width, - height: bounds.height, - }, - draggable: false, - selectable: false, - focusable: false, - zIndex: 999, - }; - }; - - const updateNodes = (nodes: Node[]): Node[] => { - let stateLookup: Record = Object.fromEntries( - nodes.map((n) => [n.id, { state: n.data.state }]) - ); - - let recalculatedNodes = nodes.map((node) => { - let locked = false; - if (node.data?.unlock) { - if (node.data.unlock.after && node.data.unlock.after.length > 0) { - if (!areUnlockAfterCompleted(node, stateLookup)) locked = true; - } - if (node.data.unlock.date) { - if (!isUnlockDatePassed(node)) locked = true; - } - if (node.data.unlock.password) { - if (!isUnlockedByPassword(node, node.data?.password)) locked = true; - } - } - - return { - ...node, - data: { - ...node.data, - locked, - state: locked ? undefined : node.data.state, - password: locked ? false : node.data.password - }, - }; - }); - - stateLookup = Object.fromEntries( - recalculatedNodes.map((n) => [n.id, { state: n.data.state }]) - ); - - recalculatedNodes = recalculatedNodes.map((node) => { - if (node.type === "topic") { - const state = getTopicNodeStatus(node, stateLookup); - return { - ...node, - data: { - ...node.data, - state, - }, - }; - } - return node; - }); - - const minimalState = {}; - recalculatedNodes.forEach((n) => { - if (n.data.state) { - minimalState[n.id] = { state: n.data.state, password: n.data.password }; - } - }); - - const root = document.querySelector("hyperbook-learningmap"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: minimalState })); - } - - - return recalculatedNodes; - - } - - useOnViewportChange({ - onChange: (viewport) => { - const root = document.querySelector("hyperbook-learningmap"); - if (root) { - root.dispatchEvent(new CustomEvent("viewport-change", { - detail: { - x: viewport.x, - y: viewport.y, - zoom: viewport.zoom, - } - })); - } - } - }); - - // Initialize layout - useEffect(() => { - async function layoutNodes() { - const rawNodes = ((nodes: Node[]) => { - const stateLookup = Object.fromEntries( - nodes.map((n: Node) => [n.id, completedMap[n.id] || {}]) - ); - - const allOptionalIds = getAllOptionalNodeIds(nodes); - - return nodes.map((node: Node) => { - const nodeStateObj = completedMap[node.id] || {}; - let state = nodeStateObj.state; - const password = nodeStateObj.password; - - // Unlock logic - let locked = false; - if (node.data?.unlock) { - if (node.data.unlock.after && node.data.unlock.after.length > 0) { - if (!areUnlockAfterCompleted(node, stateLookup)) locked = true; - } - if (node.data.unlock.date) { - if (!isUnlockDatePassed(node)) locked = true; - } - if (node.data.unlock.password) { - if (!isUnlockedByPassword(node, password)) locked = true; - } - } - // - // Calculate topic node status - if (node.type === "topic") { - state = getTopicNodeStatus(node, stateLookup); - } - - return { - ...node, - draggable: false, - data: { - ...node.data, - state: locked ? undefined : state, - password: locked ? false : password, - locked, - isOptional: allOptionalIds.includes(node.id), - }, - }; - }); - })(nodesArr); - const rawEdges = edgesArr.length > 0 - ? edgesArr - : generateEdgesFromCompletionNeeds(rawNodes, edgeConfig); - - setEdges(rawEdges); - - const needsLayout = rawNodes.some((n: Node) => !n.position); - if (needsLayout) { - const autoNodes = await getAutoLayoutedNodesElk(rawNodes, rawEdges); - setNodes(autoNodes); - } else { - setNodes(rawNodes); - } - - setViewport({ x, y, zoom }); - } - layoutNodes(); - // eslint-disable-next-line - }, [roadmapData, nodeState, x, y, zoom, unlockedPasswords, setViewport]); - - // Debug mode keyboard shortcut - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.code === "Space") { - setShowDebugNode((prev) => !prev); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, []); - - // Event handlers - const onNodeClick = useCallback((_: any, node: Node) => { - if (node.type === "debug" || node.type === "background") return; - setSelectedNode(node); - setDrawerOpen(true); - }, []); - - const closeDrawer = useCallback(() => { - setDrawerOpen(false); - setSelectedNode(null); - }, []); - - const passwordNodeUnlock = useCallback(() => { - if (!selectedNode) return; - - setNodes(nds => { - const newNds = nds.map(n => { - if (n.id == selectedNode.id) { - return { - ...n, - data: { - ...n.data, - locked: false, - password: true - } - } - } - - return n; - }) - return updateNodes(newNds); - }) - - }, [selectedNode, parsedRoadmap]) - - const toggleNodeComplete = useCallback(() => { - if (!selectedNode || selectedNode.data.locked) return; - - // Prevent completion if needs are not met - const nodesArr = nodes; - if (!areCompletionNeedsMet(selectedNode, nodesArr)) return; - - setNodes((nds) => { - const newNds = nds.map((n) => { - if (n.id === selectedNode.id) { - let newState: string; - if (!n.data.state) { - newState = "started"; - } else if (n.data.state === "started") { - newState = "completed"; - } else if (n.data.state === "completed") { - newState = undefined; - } - - const updatedNode = { - ...n, - data: { ...n.data, state: newState }, - }; - - setSelectedNode(updatedNode); - - return updatedNode; - } - return n; - }); - - return updateNodes(newNds); - }); - }, [selectedNode, parsedRoadmap]); - - // Prepare nodes for display - const backgroundNode = createBackgroundNode(); - const debugNode = createDebugNode(nodes); - const displayNodes = [ - ...(backgroundNode ? [backgroundNode] : []), - ...nodes, - ...(debugNode ? [debugNode] : []), - ]; - - // Calculate progress - // Progress calculation (ignore optional nodes) - const nonOptionalNodes = nodes.filter(n => { - // Exclude nodes that are only referenced as optional in any topic - const allOptionalIds = getAllOptionalNodeIds(nodes); - return !allOptionalIds.includes(n.id); - }); - - const completedCount = nonOptionalNodes.filter(n => n.data?.state === "completed" || n.data?.state === "fully-completed").length; - const totalNodes = nonOptionalNodes.length; - const progress = totalNodes > 0 ? Math.round((completedCount / totalNodes) * 100) : 0; - - // Star counter - const fullyCompleteCount = nodes.filter(n => n.type === "topic" && n.data?.state === "fully-completed").length; - const totalOptionalCount = getAllOptionalNodeIds(nodes).length; - - // Node types - const nodeTypes = { - topic: (props) => , - task: (props) => , - background: BackgroundNode, - debug: DebugNode, - }; - +import { LearningMap } from "./LearningMap"; +import { RoadmapData, RoadmapState } from "./types"; + +export function HyperbookLearningmap({ + roadmapData, + language, + onChange, + initialState +}: { + roadmapData: string | RoadmapData; + language?: string; + onChange?: (state: RoadmapState) => void; + initialState?: RoadmapState; + +}) { return ( - {/* Header */} - - - - {t.progress} - {progress}% - - - {fullyCompleteCount} / {totalOptionalCount} - - - - - - - {t.topicsCompleted - .replace("{completed}", completedCount) - .replace("{total}", totalNodes)} - - - - - {/* Roadmap */} - - - - - - - {/* Legend */} - - - - - {t.available} - - - - {t.inProgressLegend} - - - - {t.completed} - - - - {t.locked} - - - - - {/* Drawers */} - + + + ); } -export function HyperbookLearningmap({ roadmapData, nodeState, language, x, y, zoom }) { - return ( - - - - ); -} diff --git a/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx b/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx new file mode 100644 index 00000000..c77f6351 --- /dev/null +++ b/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx @@ -0,0 +1,27 @@ +import { + ReactFlowProvider, +} from "@xyflow/react"; +import { LearningMapEditor } from "./LearningMapEditor"; +import { RoadmapData } from "./types"; + +export function HyperbookLearningmapEditor({ + roadmapData, + language, + onChange +}: { + roadmapData: string | RoadmapData; + language?: string; + onChange?: (data: RoadmapData) => void; +}) { + return ( + + + + + + ); +} diff --git a/packages/web-component-learningmap/src/LearningMap.tsx b/packages/web-component-learningmap/src/LearningMap.tsx new file mode 100644 index 00000000..11c73b18 --- /dev/null +++ b/packages/web-component-learningmap/src/LearningMap.tsx @@ -0,0 +1,266 @@ +import { Controls, Edge, Node, Panel, ReactFlow, useEdgesState, useNodesState, 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 { parseRoadmapData } from "./helper"; +import { Drawer } from "./Drawer"; +import { ProgressTracker } from "./ProgressTracker"; + +const nodeTypes = { + topic: TopicNode, + task: TaskNode, + image: ImageNode, + 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"; +} + +const countCompletedNodes = (nodes: Node[]) => { + let completed = 0; + let mastered = 0; + let total = 0; + nodes.forEach(n => { + if (n.type === "task" || n.type === "topic") { + total++; + if (n.data?.state === 'completed') { + completed++; + } + else if (n.data?.state === 'mastered') { + completed++; + mastered++; + } + } + }); + return { completed, mastered, total }; +} + +export function LearningMap({ + roadmapData, + onChange, + language = "en", + initialState +}: { + roadmapData: string | RoadmapData; + language?: string; + onChange?: (state: RoadmapState) => void; + initialState?: RoadmapState; +}) { + 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(); + const { fitView, getViewport, setViewport } = useReactFlow(); + + const { completed, mastered, total } = countCompletedNodes(nodes); + + const parsedRoadmap = parseRoadmapData(roadmapData); + + console.log(initialState); + + 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) => ({ + ...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(); + }, [roadmapData, initialState]); + + const onNodeClick = useCallback((_: any, node: Node, focus: boolean = false) => { + if (!isInteractableNode(node)) return; + setSelectedNode(node); + setDrawerOpen(true); + + if (focus) { + fitView({ nodes: [node], duration: 150 }); + } + }, [fitView]); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + setSelectedNode(null); + }, []); + + const updateNode = useCallback( + (updatedNode: Node) => { + setNodes((nds) => { + let newNodes = nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) + newNodes = updateNodesStates(newNodes); + return newNodes; + } + ); + setSelectedNode(updatedNode); + }, + [setNodes] + ); + + useEffect(() => { + const viewport = getViewport(); + const minimalState: RoadmapState = { nodes: {}, x: viewport.x, y: viewport.y, zoom: viewport.zoom }; + nodes.forEach((n) => { + if (n.data.state) { + minimalState.nodes[n.id] = { state: n.data.state }; + } + }); + if (onChange) { + onChange(minimalState); + } else { + const root = document.querySelector("hyperbook-learningmap"); + if (root) { + root.dispatchEvent(new CustomEvent("change", { detail: minimalState })); + } + } + }, [nodes]); + + const defaultEdgeOptions = { + animated: false, + style: { + stroke: "#94a3b8", + strokeWidth: 2, + }, + type: "default", + }; + + return ( + + { + const className = []; + if (n.data?.color) { + className.push(n.data.color); + } + className.push(n.data?.state); + return { + ...n, + selected: selectedNode?.id === n.id, + className: className.join(" "), + }; + })} + edges={edges} + onEdgesChange={onEdgesChange} + onNodeClick={onNodeClick} + onNodesChange={onNodesChange} + nodeTypes={nodeTypes} + fitView + proOptions={{ hideAttribution: true }} + defaultEdgeOptions={defaultEdgeOptions} + nodesDraggable={false} + nodesConnectable={false} + > + {settings?.title && ( + + + {settings.title} + + + )} + + + + + + + + ) +} diff --git a/packages/web-component-learningmap/src/LearningMapEditor.tsx b/packages/web-component-learningmap/src/LearningMapEditor.tsx new file mode 100644 index 00000000..55adf05d --- /dev/null +++ b/packages/web-component-learningmap/src/LearningMapEditor.tsx @@ -0,0 +1,721 @@ +import { useState, useCallback, useEffect } from "react"; +import { + ReactFlow, + Controls, + useNodesState, + useEdgesState, + ColorMode, + useReactFlow, + Node, + addEdge, + Connection, + Edge, + Background, + ControlButton, + OnNodesChange, + OnEdgesChange, + getNodesBounds, + getViewportForBounds, + Panel, + OnSelectionChangeFunc, + useEdges, +} from "@xyflow/react"; +import { toSvg } from "html-to-image"; +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 { 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 } from "lucide-react"; +import useUndoable from "./useUndoable"; +import { MultiNodePanel } from "./MultiNodePanel"; + +const nodeTypes = { + topic: TopicNode, + task: TaskNode, + image: ImageNode, + text: TextNode, +}; + +const edgeTypes = { + floating: FloatingEdge +}; + + +export function LearningMapEditor({ + roadmapData, + language = "en", + onChange, +}: { + roadmapData: string | RoadmapData; + language?: string; + onChange?: (data: RoadmapData) => void; +}) { + const keyboardShortcuts = [ + { action: "Save", shortcut: "Ctrl+S" }, + { action: "Undo", shortcut: "Ctrl+Z" }, + { action: "Redo", shortcut: "Ctrl+Y or Ctrl+Shift+Z" }, + { action: "Add Task Node", shortcut: "Ctrl+A" }, + { action: "Add Topic Node", shortcut: "Ctrl+O" }, + { action: "Add Image Node", shortcut: "Ctrl+I" }, + { action: "Add Text Node", shortcut: "Ctrl+X" }, + { action: "Delete Node/Edge", shortcut: "Delete" }, + { action: "Toggle Preview Mode", shortcut: "Ctrl+P" }, + { action: "Toggle Debug Mode", shortcut: "Ctrl+D" }, + { action: "Select Multiple Nodes", shortcut: "Ctrl+Click or Shift+Drag" }, + { action: "Show Help", shortcut: "Ctrl+? or Help Button" }, + ]; + + const { screenToFlowPosition, getViewport, setViewport } = useReactFlow(); + const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable({ + settings: {}, + version: 1, + }); + + 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([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [settings, setSettings] = useState({ background: { color: "#ffffff" } }); + + 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); + + // Edge drawer state + const [selectedEdge, setSelectedEdge] = useState(null); + const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false); + + // Track Shift key state + const [shiftPressed, setShiftPressed] = useState(false); + + useEffect(() => { + const parsedRoadmap = parseRoadmapData(roadmapData); + 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, + 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") => { + const centerPos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + if (type === "task") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position: centerPos, + data: { + label: `New ${type}`, + summary: "", + description: "", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } else if (type === "topic") { + const newNode: Node = { + id: `node${nextNodeId}`, + type, + position: centerPos, + data: { + label: `New ${type}`, + 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: centerPos, + data: { + src: "", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } else if (type === "text") { + const newNode: Node = { + id: `background-node${nextNodeId}`, + type, + position: centerPos, + zIndex: -1, + data: { + text: "Background Text", + fontSize: 32, + color: "#e5e7eb", + }, + }; + setNodes((nds) => [...nds, newNode]); + setNextNodeId((id) => id + 1); + } + setSaved(false); + }, + [nextNodeId, screenToFlowPosition, setNodes, setSaved] + ); + + const handleSave = useCallback(() => { + const roadmapData: RoadmapData = { + nodes: nodes.map((n) => ({ + id: n.id, + type: n.type, + position: n.position, + 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]); + + 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", "roadmap.json"); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }, [roadmapState]); + + const defaultEdgeOptions = { + animated: false, + style: { + stroke: "#94a3b8", + strokeWidth: 2, + }, + type: "default", + }; + + const handleExportSVG = useCallback(async () => { + const nodesBounds = getNodesBounds(nodes); + const imageWidth = nodesBounds.width; + const imageHeight = nodesBounds.height; + let viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.1, 5); + + const dom = document.querySelector(".react-flow__viewport") as HTMLElement; + if (!dom) return; + + toSvg(dom, { + backgroundColor: settings?.background?.color || "#ffffff", + width: imageWidth, + height: imageHeight, + style: { + transform: `translate(${viewport.x / 2.0}px, ${viewport.y / 2.0}px) scale(${viewport.zoom})`, + width: `${imageWidth}px`, + height: `${imageHeight}px`, + } + }).then((dataUrl) => { + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataUrl); + downloadAnchorNode.setAttribute("download", "roadmap.svg"); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + + // Restore old viewport + }).catch((err) => { + alert("Failed to export SVG: " + err.message); + }); + }, [nodes, roadmapState]); + + const handleOpen = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.onchange = (e: any) => { + const file = e.target.files[0]; + if (!file) return; + + if (!window.confirm("Opening a file will replace your current map. Continue?")) { + 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('Failed to load the file. Please make sure it is a valid roadmap JSON file.'); + } + }; + reader.readAsText(file); + }; + input.click(); + }, [setRoadmapState, setDidUndoRedo]); + + // 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 handleSelectionChange: OnSelectionChangeFunc = useCallback( + ({ nodes: selectedNodes }) => { + if (selectedNodes.length > 1) { + setSelectedNodeIds(selectedNodes.map(n => n.id)); + } else { + setSelectedNodeIds([]); + } + }, + [setSelectedNodeIds] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(true); + //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.toLowerCase() === 'a' && !e.shiftKey) { + e.preventDefault(); + addNewNode("task"); + } + // add topic node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o' && !e.shiftKey) { + e.preventDefault(); + addNewNode("topic"); + } + // add image node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i' && !e.shiftKey) { + e.preventDefault(); + addNewNode("image"); + } + // add text node shortcut + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { + e.preventDefault(); + addNewNode("text"); + } + + 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(); + } + // Dismiss with Escape + if (helpOpen && e.key === 'Escape') { + setHelpOpen(false); + } + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftPressed(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]); + + return ( + <> + + {previewMode && } + {!previewMode && <> + + { + const className = []; + if (n.data?.color) { + className.push(n.data.color); + } + return { + ...n, + className: className.join(" ") + }; + })} + edges={edges} + onEdgesChange={handleEdgesChange} + onNodeDoubleClick={onNodeClick} + onEdgeDoubleClick={onEdgeClick} + onNodesChange={handleNodesChange} + onConnect={onConnect} + onSelectionChange={handleSelectionChange} + nodeTypes={nodeTypes} + selectionOnDrag={false} + edgeTypes={edgeTypes} + fitView + snapToGrid={!shiftPressed} + proOptions={{ hideAttribution: true }} + defaultEdgeOptions={defaultEdgeOptions} + nodesDraggable={true} + elevateNodesOnSelect={false} + nodesConnectable={true} + colorMode={colorMode} + > + + + + + + + + + + + + setHelpOpen(true)}> + + + + {!saved && { handleSave(); }}> + + } + {selectedNodeIds.length > 1 && selectedNodeIds.includes(n.id))} onUpdate={updateNodes} />} + + + n.id === selectedNodeId)} + isOpen={drawerOpen} + onClose={closeDrawer} + onUpdate={updateNode} + onDelete={deleteNode} + /> + + + setHelpOpen(false)} + > + Keyboard Shortcuts + + + + Action + Shortcut + + + + {keyboardShortcuts.map((item) => ( + + {item.action} + {item.shortcut} + + ))} + + + setHelpOpen(false)}>Close + + > + } + > + ); +} diff --git a/packages/web-component-learningmap/src/MultiNodePanel.tsx b/packages/web-component-learningmap/src/MultiNodePanel.tsx new file mode 100644 index 00000000..4843c232 --- /dev/null +++ b/packages/web-component-learningmap/src/MultiNodePanel.tsx @@ -0,0 +1,146 @@ +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"; + +interface Props { + nodes: Node[]; + onUpdate: (nodes: Node[]) => void; +} + +export const MultiNodePanel: FC = ({ nodes, onUpdate }) => { + + const alignLeftVertical = () => { + if (nodes.length < 2) return; + const minX = Math.min(...nodes.map(n => n.position.x)); + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, x: minX } + })); + onUpdate(updatedNodes); + }; + + const alignCenterVertical = () => { + if (nodes.length < 2) return; + const avgX = nodes.reduce((sum, n) => sum + n.position.x + (n.width || n.measured.width) / 2, 0) / nodes.length; + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, x: avgX - (n.width || n.measured.width) / 2 } + })); + onUpdate(updatedNodes); + }; + + const alignRightVertical = () => { + if (nodes.length < 2) return; + const maxX = Math.max(...nodes.map(n => n.position.x + (n.width || 0))); + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, x: maxX - (n.width || n.measured.width) } + })); + onUpdate(updatedNodes); + }; + + const alignLeft = () => { + if (nodes.length < 2) return; + const minY = Math.min(...nodes.map(n => n.position.y)); + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, y: minY } + })); + onUpdate(updatedNodes); + }; + + const alignCenter = () => { + if (nodes.length < 2) return; + const avgY = nodes.reduce((sum, n) => sum + n.position.y + (n.height || n.measured.height) / 2, 0) / nodes.length; + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, y: avgY - (n.height || n.measured.height) / 2 } + })); + onUpdate(updatedNodes); + }; + + const alignRight = () => { + if (nodes.length < 2) return; + const maxY = Math.max(...nodes.map(n => n.position.y + (n.height || 0))); + const updatedNodes = nodes.map(n => ({ + ...n, + position: { ...n.position, y: maxY - (n.height || n.measured.height) } + })); + onUpdate(updatedNodes); + }; + + const distributeVertical = () => { + if (nodes.length < 3) return; + // Improved vertical distribution: ensures equal gaps between nodes regardless of node heights + const sortedNodes = [...nodes].sort((a, b) => a.position.y - b.position.y); + const minY = sortedNodes[0].position.y; + const totalNodesHeight = sortedNodes.reduce((sum, n) => sum + (n.height || n.measured.height), 0); + const maxY = sortedNodes[sortedNodes.length - 1].position.y; + const availableSpace = (maxY - minY) + (sortedNodes[sortedNodes.length - 1].height || sortedNodes[sortedNodes.length - 1].measured.height) - totalNodesHeight; + const gap = sortedNodes.length > 1 ? availableSpace / (sortedNodes.length - 1) : 0; + let currentY = minY; + const updatedNodes = sortedNodes.map((n, i) => { + const updatedNode = { + ...n, + position: { ...n.position, y: currentY } + }; + currentY += (n.height || n.measured.height) + gap; + return updatedNode; + }); + onUpdate(updatedNodes); + }; + + const distributeHorizontal = () => { + if (nodes.length < 3) return; + const sortedNodes = [...nodes].sort((a, b) => a.position.x - b.position.x); + const minX = sortedNodes[0].position.x; + const totalNodesWidth = sortedNodes.reduce((sum, n) => sum + (n.width || n.measured.width), 0); + const maxX = sortedNodes[sortedNodes.length - 1].position.x; + const availableSpace = (maxX - minX) + (sortedNodes[sortedNodes.length - 1].width || sortedNodes[sortedNodes.length - 1].measured.width) - totalNodesWidth; + const gap = sortedNodes.length > 1 ? availableSpace / (sortedNodes.length - 1) : 0; + let currentX = minX; + const updatedNodes = sortedNodes.map((n, i) => { + const updatedNode = { + ...n, + position: { ...n.position, x: currentX } + }; + currentX += (n.width || n.measured.width) + gap; + return updatedNode; + }); + onUpdate(updatedNodes); + }; + + const sameWidth = () => { + if (nodes.length < 2) return; + const maxWidth = Math.max(...nodes.map(n => n.width || n.measured.width)); + const updatedNodes = nodes.map(n => ({ + ...n, + width: maxWidth + })); + onUpdate(updatedNodes); + }; + + const sameHeight = () => { + if (nodes.length < 2) return; + const maxHeight = Math.max(...nodes.map(n => n.height || n.measured.height)); + const updatedNodes = nodes.map(n => ({ + ...n, + height: maxHeight + })); + onUpdate(updatedNodes); + }; + + return + + + + + + + {nodes.length > 2 && } + {nodes.length > 2 && } + + + ; +} diff --git a/packages/web-component-learningmap/src/ProgressTracker.tsx b/packages/web-component-learningmap/src/ProgressTracker.tsx new file mode 100644 index 00000000..727423f3 --- /dev/null +++ b/packages/web-component-learningmap/src/ProgressTracker.tsx @@ -0,0 +1,25 @@ +import { CheckCircle } from "lucide-react"; +import StarCircle from "./icons/StarCircle"; + +export const ProgressTracker = ({ completed, mastered, total }: { completed: number; mastered: number; total: number }) => { + const progress = total > 0 ? (completed / total) * 100 : 0; + + return ( + <> + + + {completed} / {total} + + + {progress.toFixed(0)}% + + + + + + {mastered} + + > + ); +} + diff --git a/packages/web-component-learningmap/src/RotationInput.tsx b/packages/web-component-learningmap/src/RotationInput.tsx new file mode 100644 index 00000000..04da8b3d --- /dev/null +++ b/packages/web-component-learningmap/src/RotationInput.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface RotationInputProps { + value: number; + onChange: (value: number) => void; +} + +export function RotationInput({ value, onChange }: RotationInputProps) { + return ( + + Rotation (degrees): {value}° + + onChange(Number(e.target.value))} + style={{ flex: 1 }} + /> + { + let v = Number(e.target.value); + if (isNaN(v)) v = 0; + if (v < 0) v = 0; + if (v > 360) v = 360; + onChange(v); + }} + style={{ width: 100 }} + /> + + + ); +} diff --git a/packages/web-component-learningmap/src/SettingsDrawer.tsx b/packages/web-component-learningmap/src/SettingsDrawer.tsx new file mode 100644 index 00000000..f5e747dc --- /dev/null +++ b/packages/web-component-learningmap/src/SettingsDrawer.tsx @@ -0,0 +1,70 @@ +import React, { useState, useEffect } from "react"; +import { X, Save } from "lucide-react"; +import { Settings } from "./types"; +import { ColorSelector } from "./ColorSelector"; + +interface SettingsDrawerProps { + isOpen: boolean; + onClose: () => void; + settings: Settings; + onUpdate: (s: Settings) => void; +} + +export const SettingsDrawer: React.FC = ({ + isOpen, + onClose, + settings, + onUpdate, +}) => { + const [localSettings, setLocalSettings] = useState(settings); + + useEffect(() => { + setLocalSettings(settings); + }, [settings]); + + if (!isOpen) return null; + + const handleSave = () => { + onUpdate(localSettings); + onClose(); + }; + + return ( + <> + + + + Background Settings + + + + + + + + Label * + setLocalSettings(settings => ({ ...settings, title: e.target.value }))} + placeholder="Node label" + /> + + + setLocalSettings(settings => ({ ...settings, background: { ...settings.background, color } }))} + /> + + + + + + Save Changes + + + + > + ); +}; diff --git a/packages/web-component-learningmap/src/Video.tsx b/packages/web-component-learningmap/src/Video.tsx new file mode 100644 index 00000000..81b2ab8b --- /dev/null +++ b/packages/web-component-learningmap/src/Video.tsx @@ -0,0 +1,54 @@ +function isYoutubeUrl(url: string) { + return ( + typeof url === "string" && + (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/")) + ); +} + +function getYoutubeEmbedUrl(url: string) { + if (url.includes("youtube.com/watch?v=")) { + const videoId = url.split("v=")[1].split("&")[0]; + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + if (url.includes("youtu.be/")) { + const videoId = url.split("youtu.be/")[1].split("?")[0]; + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + return url; +} + +function getVideoMimeType(url: string) { + if (url.endsWith(".webm")) return "video/webm"; + if (url.endsWith(".mp4")) return "video/mp4"; + return "video/mp4"; +} + +export const Video: React.FC<{ url: string; title?: string }> = ({ url, title }) => { + if (isYoutubeUrl(url)) { + const embedUrl = getYoutubeEmbedUrl(url); + return ( + + + + ); + } else { + const mimeType = getVideoMimeType(url); + return ( + + + Your browser does not support the video tag. + + ); + } +}; diff --git a/packages/web-component-learningmap/src/autoLayoutElk.ts b/packages/web-component-learningmap/src/autoLayoutElk.ts index d8aeeeaa..73cd091e 100644 --- a/packages/web-component-learningmap/src/autoLayoutElk.ts +++ b/packages/web-component-learningmap/src/autoLayoutElk.ts @@ -1,19 +1,20 @@ import ELK from "elkjs/lib/elk.bundled.js"; +import type { Node, Edge } from "@xyflow/react"; export async function getAutoLayoutedNodesElk( - nodes, - edges, + nodes: Node[], + edges: Edge[], nodeWidth = 320, nodeHeight = 120 ) { const elk = new ELK(); - const elkNodes = nodes.map((node) => ({ + const elkNodes = nodes.map((node: Node) => ({ id: node.id, width: nodeWidth, height: nodeHeight, ...node, })); - const elkEdges = edges.map((edge) => ({ + const elkEdges = edges.map((edge: Edge) => ({ id: edge.id, sources: [edge.source], targets: [edge.target], @@ -29,10 +30,10 @@ export async function getAutoLayoutedNodesElk( children: elkNodes, edges: elkEdges, }; - const layout = await elk.layout(elkGraph); - return nodes.map((node) => { + const layout: any = await elk.layout(elkGraph); + return nodes.map((node: Node) => { if (node.position) return node; - const layoutNode = layout.children.find((n) => n.id === node.id); + const layoutNode = layout.children.find((n: any) => n.id === node.id); return { ...node, position: { x: layoutNode.x, y: layoutNode.y }, diff --git a/packages/web-component-learningmap/src/helper.ts b/packages/web-component-learningmap/src/helper.ts new file mode 100644 index 00000000..50d7345e --- /dev/null +++ b/packages/web-component-learningmap/src/helper.ts @@ -0,0 +1,86 @@ +import { Position, Node } from "@xyflow/react"; +import { RoadmapData } from "./types"; + +// this helper function returns the intersection point +// of the line between the center of the intersectionNode and the target node +function getNodeIntersection(intersectionNode: Node, targetNode: Node) { + // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = + intersectionNode.measured; + const intersectionNodePosition = intersectionNode.internals.positionAbsolute; + const targetPosition = targetNode.internals.positionAbsolute; + + const w = intersectionNodeWidth / 2; + const h = intersectionNodeHeight / 2; + + const x2 = intersectionNodePosition.x + w; + const y2 = intersectionNodePosition.y + h; + const x1 = targetPosition.x + targetNode.measured.width / 2; + const y1 = targetPosition.y + targetNode.measured.height / 2; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +} + +// returns the position (top,right,bottom or right) passed node compared to the intersection point +function getEdgePosition(node: Node, intersectionPoint: any) { + const n = { ...node.internals.positionAbsolute, ...node }; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= nx + 1) { + return Position.Left; + } + if (px >= nx + n.measured.width - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y + n.measured.height - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge +export function getEdgeParams(source: Node, target: Node) { + const sourceIntersectionPoint = getNodeIntersection(source, target); + const targetIntersectionPoint = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + return { + sx: sourceIntersectionPoint.x, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +} + +export const parseRoadmapData = ( + roadmapData: string | RoadmapData, +): RoadmapData => { + if (typeof roadmapData !== "string") { + return roadmapData || {}; + } + try { + return JSON.parse(roadmapData); + } catch (err) { + console.error("Failed to parse roadmap data:", err); + return { settings: { title: "New Roadmap" }, version: 1 }; + } +}; diff --git a/packages/web-component-learningmap/src/icons/StarCircle.tsx b/packages/web-component-learningmap/src/icons/StarCircle.tsx new file mode 100644 index 00000000..109f5aa7 --- /dev/null +++ b/packages/web-component-learningmap/src/icons/StarCircle.tsx @@ -0,0 +1,23 @@ +import { FC, SVGProps } from "react"; + +const StarCircle: FC> = (props) => ( + + + + +); + +export default StarCircle; diff --git a/packages/web-component-learningmap/src/index.css b/packages/web-component-learningmap/src/index.css index 33339f8b..2bd94282 100644 --- a/packages/web-component-learningmap/src/index.css +++ b/packages/web-component-learningmap/src/index.css @@ -1,379 +1,680 @@ -.learningmap-footer { - width: 100%; - background: var(--color-nav); - border-top: 1px solid var(--color-nav-border); - padding: 8px 32px 16px 32px; - z-index: 10; - position: relative; - box-sizing: border-box; -} - -@media (max-width: 600px) { - .learningmap-footer { - padding-left: 8px; - padding-right: 8px; - } -} - /* Container */ - .hyperbook-learningmap-container { width: 100%; height: 100%; display: flex; flex-direction: column; + background: var(--color-nav, #f9fafb); } - -.learningmap-header { +/* Toolbar */ +.editor-toolbar { width: 100%; - background: var(--color-nav); - border-bottom: 1px solid var(--color-nav-border); - padding: 24px 32px 16px 32px; + background: var(--color-nav, #ffffff); + border-bottom: 1px solid var(--color-nav-border, #e5e7eb); + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; z-index: 10; - position: relative; box-sizing: border-box; - overflow-x: auto; } -@media (max-width: 600px) { - .learningmap-header { - padding-left: 8px; - padding-right: 8px; - } +.toolbar-group { + display: flex; + gap: 8px; +} + +.toolbar-button { + padding: 8px 16px; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; +} + +.toolbar-button:hover { + background: var(--color-spacer, #f3f4f6); + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.primary { + background: var(--color-brand, #3b82f6); + color: white; + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.primary:hover { + background: #2563eb; + border-color: #2563eb; +} + +.toolbar-button.active { + background: var(--color-brand, #3b82f6); + color: white; + border-color: var(--color-brand, #3b82f6); +} + +.toolbar-button.active:hover { + background: #2563eb; + border-color: #2563eb; } -.learningmap-roadmap { +.toolbar-button:disabled { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); + border-color: var(--color-nav-border, #e5e7eb); + cursor: not-allowed; +} + +/* Editor Canvas */ +.editor-canvas { flex: 1; min-height: 0; position: relative; width: 100%; - background: transparent; -} - -/* Learning Node */ -.learning-node { - padding: 16px 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - border: 2px solid; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - transition: all 0.3s ease; - background: white; } .react-flow__node-background img { max-width: none; } -.react-flow__node-task .learning-node { - background: #f0f7ff; - border-color: #3b82f6; - border-radius: 16px; +/* Drawer Styles */ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1002; + animation: fadeIn 0.2s ease; } -.react-flow__node-topic .learning-node { - background: #fffbe6; - border-color: #f59e42; - border-radius: 8px; - font-size: 16px; - /* slightly larger for emphasis */ - font-weight: 700; - box-shadow: 0 2px 8px rgba(245, 158, 66, 0.08); - padding-left: 18px; +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } } -.learning-node.optional { - border: 2px dashed #38bdf8; +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 500px; + max-width: 90vw; + background: var(--color-nav, white); + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1); + z-index: 1003; + display: flex; + flex-direction: column; + animation: slideIn 0.3s ease; } -.react-flow__node-topic .learning-node { - border-left: 12px solid #f59e42; +.drawer-header { + padding: 24px; + border-bottom: 1px solid var(--color-nav-border, #e5e7eb); + display: flex; + justify-content: space-between; + align-items: center; } -.learning-node.locked { - background: #f3f4f6; - border-color: #d1d5db; +.drawer-title { + font-size: 24px; + font-weight: 700; + margin: 0; + border: none; } -.learning-node.completed { - background: #f0fdf4; - border-color: #22c55e; +.drawer-footer button { + width: 100%; } -.learning-node.available { - background: white; - border-color: #60a5fa; +.close-button { + background: none; + border: none; cursor: pointer; + padding: 4px; + color: var(--color-text, #6b7280); + transition: color 0.2s; } -.learning-node.available:hover { - border-color: #2563eb; - transform: translateY(-2px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +.close-button:hover { + color: var(--color-text, #1f2937); } -.node-header { +.drawer-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.drawer-footer { + padding: 24px; + border-top: 1px solid var(--color-nav-border, #e5e7eb); display: flex; - align-items: center; - gap: 8px; + gap: 12px; + justify-content: flex-end; } -.node-icon { - width: 20px; - height: 20px; - flex-shrink: 0; +/* Form Styles */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; + color: var(--color-text, #374151); } -.learning-node.completed .node-icon { - color: #16a34a; +.form-group input[type="text"], +.form-group input[type="date"], +.form-group input[type="number"], +.form-group input[type="color"], +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; + font-size: 14px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + box-sizing: border-box; + transition: border-color 0.2s; } -.learning-node.available .node-icon { - color: #3b82f6; +.form-group input[type="color"] { + height: 64px; } -.learning-node.started .node-icon { - color: #f59e42; +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-brand, #3b82f6); } -.learning-node.locked .node-icon { - color: #a1a1aa; +.form-group textarea { + resize: vertical; + font-family: inherit; } -.node-label { - font-weight: 600; +/* Buttons */ +.primary-button { + padding: 10px 20px; + background: var(--color-brand, #3b82f6); + color: white; + border: none; + border-radius: 6px; font-size: 14px; - color: #1f2937; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; } +.primary-button:hover { + background: #2563eb; +} -.star-counter { - font-weight: 600; - color: #f59e42; +.secondary-button { + padding: 8px 16px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + width: 100%; + justify-content: center; } -/* Progress Panel */ -.progress-panel { - padding: 16px; - margin: 16px; +.secondary-button:hover { + background: var(--color-spacer, #f3f4f6); +} + +.danger-button { + padding: 10px 20px; + background: #ef4444; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; } -.progress-section { - margin-bottom: 8px; +.danger-button:hover { + background: #dc2626; } -.progress-header { +.drawer-button { + padding: 14px 20px; + cursor: pointer; display: flex; - justify-content: space-between; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 6px; font-size: 14px; - margin-bottom: 4px; + cursor: pointer; } -.progress-label { - color: var(--color-text); +.drawer-button:hover { + background: var(--color-spacer, #f3f4f6); + border-color: var(--color-brand, #3b82f6); } -.progress-value { - font-weight: 600; - color: var(--color-text); +.drawer-button.locked { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); + border-color: var(--color-nav-border, #e5e7eb); + cursor: not-allowed; } -.progress-bar-container { - width: 100%; - background-color: var(--color-spacer); - border: 1px solid var(--color-nav-border); - border-radius: 9999px; - height: 8px; - overflow: hidden; +.drawer-button.started { + background: #fef3c7; + border-color: #f59e42; + color: #b45309; } -.progress-bar-fill { - background-color: var(--color-brand); - height: 100%; - border-radius: 9999px; - transition: width 0.3s ease; +.drawer-button.completed { + background: #d1fae5; + border-color: #10b981; + color: #065f46; } -.progress-text { - font-size: 12px; - margin-top: 4px; +.drawer-button.mastered { + background: #d1fae5; + border-color: #10b981; + color: #065f46; } -/* Legend Panel */ -.legend-panel { - border-radius: 0; - box-shadow: none; - padding: 12px 0 0 0; - margin: 0; - font-size: 12px; - color: var(--color-text); - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 24px; - justify-content: center; +.icon-button { + padding: 6px; + background: none; + border: 1px solid var(--color-nav-border, #d1d5db); + border-radius: 4px; + cursor: pointer; + color: var(--color-text, #6b7280); + transition: all 0.2s; } -.legend-item { - display: flex; - align-items: center; - gap: 8px; +.icon-button:hover { + background: var(--color-spacer, #f3f4f6); + color: #ef4444; } -.legend-icon-available { - color: #3b82f6; +.react-flow__controls-button svg.lucide { + fill: none; } -.legend-icon-locked { - color: #9ca3af; +.react-flow__controls-button { + transition: all 0.2s; } -/* Drawer Styles */ -.drawer-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 1002; - animation: fadeIn 0.2s ease; +.react-flow__controls-button:hover { + background: var(--color-spacer, #f3f4f6); } -/* Password Input Visibility Fix */ -.drawer-password-input { - background: none; - color: var(--color-text); - border: 1px solid var(--color-nav-border); - border-radius: 6px; - padding: 10px 36px 10px 12px; - font-size: 16px; - width: 100%; - box-sizing: border-box; +.react-flow__controls-button:disabled { + background: var(--color-nav, #f3f4f6); + color: var(--color-text, #9ca3af); } -/* Hide React Flow Handles */ +/* React Flow Handles */ .react-flow__handle { - opacity: 0 !important; + width: 10px; + height: 10px; + background: var(--color-brand, #3b82f6); + border: 2px solid white; } -@keyframes fadeIn { - from { - opacity: 0; +.react-flow__edge.selected { + outline: 1px solid var(--color-brand, #3b82f6); +} + +.react-flow__node.selected { + outline: 2px solid var(--color-brand, #3b82f6); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3); +} + +.react-flow__node-image img { + width: 100%; +} + +.react-flow__node-task { + padding: 16px 24px; + border-radius: 16px; + border: 2px solid; + border-color: #3b82f6; + background: #f0f7ff; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + + .icon { + position: absolute; + top: -10px; + right: -10px; + fill: #fff; + stroke: #3b82f6; } - to { - opacity: 1; +} + +.react-flow__node-topic { + padding: 16px 24px; + border-radius: 16px; + border: 2px solid; + border-color: #f59e42; + background: #fffbe6; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + + .icon { + position: absolute; + top: -10px; + left: -10px; + fill: #fff; + stroke: #f59e42; } } -@keyframes slideIn { - from { - transform: translateX(100%); +.react-flow__node-topic.black, +.react-flow__node-task.black { + border-color: #374151; + background: #f3f4f6; + + .icon { + stroke: #374151; } +} - to { - transform: translateX(0); +.react-flow__node-topic.white, +.react-flow__node-task.white { + border-color: #9ca3af; + background: #f9fafb; + + .icon { + stroke: #9ca3af; } } -.drawer { - position: fixed; - top: 0px; - right: 0; - bottom: 0; - width: 500px; - max-width: 90vw; - background: var(--color-nav); - box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1); - z-index: 1003; - display: flex; - flex-direction: column; - animation: slideIn 0.3s ease; +.react-flow__node-topic.lila, +.react-flow__node-task.lila { + border-color: #9e86ed; + background: #f3e8ff; + + .icon { + stroke: #9e86ed; + } } -.drawer iframe { - border: none; +.react-flow__node-topic.pink, +.react-flow__node-task.pink { + border-color: #ec4899; + background: #fdf2f8; + + .icon { + stroke: #ec4899; + } } -.drawer-header { - padding: 24px; - border-bottom: 1px solid var(--color-nav-border); +.react-flow__node-topic.teal, +.react-flow__node-task.teal { + border-color: #14b8a6; + background: #e0f2fe; + + .icon { + stroke: #14b8a6; + } } -.drawer-header h2 { - border: none; - margin: 0; +.react-flow__node-topic.yellow, +.react-flow__node-task.yellow { + border-color: #f59e42; + background: #fffbeb; + + .icon { + stroke: #f59e42; + } } -.drawer-title { - font-size: 24px; - font-weight: 700; - margin: 0 0 4px 0; +.react-flow__node-topic.red, +.react-flow__node-task.red { + border-color: #ef4444; + background: #fef2f2; + + .icon { + stroke: #ef4444; + } } -.drawer-duration { - font-size: 14px; - color: #6b7280; +.react-flow__node-topic.blue, +.react-flow__node-task.blue { + border-color: #3b82f6; + background: #eff6ff; + + .icon { + stroke: #3b82f6; + } } -.drawer-content { - flex: 1; - overflow-y: auto; - padding: 24px; +.react-flow__node-task.completed, +.react-flow__node-topic.completed { + text-decoration: line-through; + border-color: #10b981; + background: #ecfdf5; + + .icon { + stroke: #10b981; + } } -.drawer-footer { - padding: 24px; - border-top: 1px solid var(--color-nav-border); +.react-flow__node-task.locked, +.react-flow__node-topic.locked { + border-color: #6b7280; + background: #e5e7eb; + + .icon { + stroke: #6b7280; + } } -.complete-button { - width: 100%; - padding: 14px; - border: none; +.react-flow__node-task.mastered, +.react-flow__node-topic.mastered { + text-decoration: line-through; + border-color: #10b981; + background: #ecfdf5; - .legend-icon-completed { - color: #16a34a !important; + .icon { + stroke: #10b981; } +} - cursor: pointer; +dialog.help[open] { + width: 600px; + max-width: 90vw; + border: none; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + padding: 24px; + background: var(--color-nav, white); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + gap: 16px; display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.2s; -} + flex-direction: column; -.complete-button:not(.locked):not(.completed) { - background: #3b82f6; - color: white; + table { + width: 100%; + border-collapse: collapse; + } + + th, + td { + border: 1px solid var(--color-nav-border, #e5e7eb); + padding: 8px; + text-align: left; + } + + th { + background: var(--color-spacer, #f3f4f6); + } + + button { + width: 100%; + } } -.complete-button:not(.locked):not(.completed):hover { - background: #2563eb; +.szh-menu__item { + svg { + margin-right: 8px; + } } -.complete-button.completed { - background: #22c55e; - color: white; +.szh-menu__submenu.active, +.szh-menu__item.active { + color: var(--color-brand, #3b82f6); + } -.complete-button.completed:hover { - background: #16a34a; +.link-button { + color: var(--color-brand, #3b82f6); + text-decoration: underline; + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; } -.complete-button.locked { - background: #f3f4f6; - color: #9ca3af; - cursor: not-allowed; +.multi-node-panel { + padding: 8px 24px; + border-bottom: 1px solid var(--color-nav-border, #e5e7eb); + background: var(--color-nav, #ffffff); + border-radius: 6px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + box-sizing: border-box; + + button { + padding: 8px; + border: none; + border-radius: 6px; + background: var(--color-nav, white); + color: var(--color-text, #1f2937); + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + button:hover { + background: var(--color-spacer, #f3f4f6); + border-color: var(--color-brand, #3b82f6); + } } -.react-flow__controls-button { - color: #000 !important; +.progress-panel { + width: 80%; + padding: 8px; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + background: #fff; + border: 1px solid var(--color-nav-border, #d1d5db); + display: flex; + justify-content: space-between; + font-size: 14px; + margin-bottom: 4px; + + .mastered-counter, + .completed-counter { + color: #10b981; + width: 150px; + user-select: none; + } + + .completed-counter { + display: flex; + justify-content: flex-start; + } + + .mastered-counter { + display: flex; + justify-content: flex-end; + } + + .progress-value { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #266651; + user-select: none; + font-weight: 600; + } + + .progress-bar-container { + position: relative; + width: 100%; + border: 1px solid var(--color-nav-border, #d1d5db); + background: var(--color-nav, #f3f4f6); + border-radius: 9999px; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + background: #10b981; + border-radius: 9999px; + transition: width 0.3s ease; + } + + .progress-text { + font-size: 12px; + margin-top: 4px; + } } diff --git a/packages/web-component-learningmap/src/index.ts b/packages/web-component-learningmap/src/index.ts index 4d670b52..0c77cd10 100644 --- a/packages/web-component-learningmap/src/index.ts +++ b/packages/web-component-learningmap/src/index.ts @@ -2,16 +2,14 @@ import r2wc from "@r2wc/react-to-web-component"; import { HyperbookLearningmap } from "./HyperbookLearningmap"; import "@xyflow/react/dist/style.css"; import "./index.css"; +import { HyperbookLearningmapEditor } from "./HyperbookLearningmapEditor"; -// Custom wrapper to dispatch 'change' event const LearningmapWC = r2wc(HyperbookLearningmap, { props: { roadmapData: "string", - nodeState: "json", language: "string", - x: "number", - y: "number", - zoom: "number", + onChange: "function", + initialState: "json", }, events: { change: true, @@ -19,3 +17,16 @@ const LearningmapWC = r2wc(HyperbookLearningmap, { }); customElements.define("hyperbook-learningmap", LearningmapWC); + +const LearningmapEditorWC = r2wc(HyperbookLearningmapEditor, { + props: { + roadmapData: "string", + language: "string", + onChange: "function", + }, + events: { + change: true, + }, +}); + +customElements.define("hyperbook-learningmap-editor", LearningmapEditorWC); diff --git a/packages/web-component-learningmap/src/nodes/ImageNode.tsx b/packages/web-component-learningmap/src/nodes/ImageNode.tsx new file mode 100644 index 00000000..c7bbf411 --- /dev/null +++ b/packages/web-component-learningmap/src/nodes/ImageNode.tsx @@ -0,0 +1,25 @@ +import { Node, NodeResizer } from "@xyflow/react"; +import { ImageNodeData } from "../types"; + +export const ImageNode = ({ data, selected }: Node) => { + return ( + <> + {data.data ? ( + <> + + + + + > + ) : ( + No Image + )} + > + ); +}; diff --git a/packages/web-component-learningmap/src/nodes/TaskNode.tsx b/packages/web-component-learningmap/src/nodes/TaskNode.tsx new file mode 100644 index 00000000..1960629a --- /dev/null +++ b/packages/web-component-learningmap/src/nodes/TaskNode.tsx @@ -0,0 +1,44 @@ +import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; +import { NodeData } from "../types"; +import { CircleCheck } from "lucide-react"; + +export const TaskNode = ({ data, selected, isConnectable, ...props }: Node) => { + return ( + <> + {isConnectable && } + + + + {data.label || "Untitled"} + + + {data.summary && ( + + {data.summary} + + )} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + > + ); +}; diff --git a/packages/web-component-learningmap/src/nodes/TextNode.tsx b/packages/web-component-learningmap/src/nodes/TextNode.tsx new file mode 100644 index 00000000..0eb899c8 --- /dev/null +++ b/packages/web-component-learningmap/src/nodes/TextNode.tsx @@ -0,0 +1,19 @@ +import { Node } from "@xyflow/react"; +import { TextNodeData } from "../types"; + +export const TextNode = ({ data }: Node) => { + return ( + <> + + {data.text || "No Text"} + + > + ); +}; diff --git a/packages/web-component-learningmap/src/nodes/TopicNode.tsx b/packages/web-component-learningmap/src/nodes/TopicNode.tsx new file mode 100644 index 00000000..0590043a --- /dev/null +++ b/packages/web-component-learningmap/src/nodes/TopicNode.tsx @@ -0,0 +1,44 @@ +import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; +import { NodeData } from "../types"; +import StarCircle from "../icons/StarCircle"; + +export const TopicNode = ({ data, selected, isConnectable }: Node) => { + return ( + <> + {isConnectable && } + {data.state === "mastered" && } + + + {data.label || "Untitled"} + + + {data.summary && ( + + {data.summary} + + )} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + + {["Bottom", "Top", "Left", "Right"].map((pos) => ( + + ))} + > + ); +}; diff --git a/packages/web-component-learningmap/src/types.ts b/packages/web-component-learningmap/src/types.ts new file mode 100644 index 00000000..71a66494 --- /dev/null +++ b/packages/web-component-learningmap/src/types.ts @@ -0,0 +1,97 @@ +import { Node, Edge, Box } from "@xyflow/react"; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export interface UnlockCondition { + after?: string[]; + date?: string; + password?: string; +} + +export interface CompletionNeed { + id: string; + source?: string; + target?: string; +} + +export interface Completion { + needs?: CompletionNeed[]; + optional?: CompletionNeed[]; +} + +export interface NodeData { + state: "locked" | "unlocked" | "started" | "completed" | "mastered"; + label: string; + description?: string; + duration?: string; + unlock?: UnlockCondition; + completion?: Completion; + video?: string; + resources?: { label: string; url: string }[]; + summary?: string; + [key: string]: any; +} + +export interface ImageNodeData { + data?: string; // base64 encoded image +} + +export interface TextNodeData { + text?: string; + fontSize?: number; + color?: string; + rotation?: number; +} + +export type BackgroundNodeData = ImageNodeData | TextNodeData; + +export interface BackgroundConfig { + color?: string; + nodes?: Node[]; +} + +export interface Settings { + title?: string; + background?: BackgroundConfig; +} + +export interface EdgeConfig { + animated?: boolean; + color?: string; + width?: number; + type?: string; +} + +export interface RoadmapData { + nodes?: Node[]; + edges?: Edge[]; + settings: Settings; + version: number; +} + +export interface RoadmapState { + nodes: Record; + x: number; + y: number; + zoom: number; +} + +export type Orientation = "horizontal" | "vertical"; + +export type HelperLine = { + // Used to filter out helper lines corresponding to the node being dragged + node: Node; + // We use it to check that the helper line is within the viewport. + nodeBox: Box; + // 0 for horizontal, 1 for vertical + orientation: Orientation; + // If orientation is 'horizontal', `position` holds the Y coordinate of the helper line. + // (Might correspond to the top or bottom position of a node, or other anchors). + // If orientation is 'vertical', `position` holds the X coordinate of the helper line. + position: number; + // Optional color for the helper line + color?: string; + anchorName: string; +}; diff --git a/packages/web-component-learningmap/src/useUndoable/errors.ts b/packages/web-component-learningmap/src/useUndoable/errors.ts new file mode 100644 index 00000000..6cb01026 --- /dev/null +++ b/packages/web-component-learningmap/src/useUndoable/errors.ts @@ -0,0 +1,9 @@ +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/web-component-learningmap/src/useUndoable/index.ts b/packages/web-component-learningmap/src/useUndoable/index.ts new file mode 100644 index 00000000..228a67b1 --- /dev/null +++ b/packages/web-component-learningmap/src/useUndoable/index.ts @@ -0,0 +1,126 @@ +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/web-component-learningmap/src/useUndoable/mutate.ts b/packages/web-component-learningmap/src/useUndoable/mutate.ts new file mode 100644 index 00000000..b5b9b857 --- /dev/null +++ b/packages/web-component-learningmap/src/useUndoable/mutate.ts @@ -0,0 +1,117 @@ +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/web-component-learningmap/src/useUndoable/reducer.ts b/packages/web-component-learningmap/src/useUndoable/reducer.ts new file mode 100644 index 00000000..8bfeed0c --- /dev/null +++ b/packages/web-component-learningmap/src/useUndoable/reducer.ts @@ -0,0 +1,88 @@ +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/web-component-learningmap/src/useUndoable/types.ts b/packages/web-component-learningmap/src/useUndoable/types.ts new file mode 100644 index 00000000..5fd95b05 --- /dev/null +++ b/packages/web-component-learningmap/src/useUndoable/types.ts @@ -0,0 +1,63 @@ +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/web-component-learningmap/tsconfig.json b/packages/web-component-learningmap/tsconfig.json index 5e84980d..60e6789c 100644 --- a/packages/web-component-learningmap/tsconfig.json +++ b/packages/web-component-learningmap/tsconfig.json @@ -1,7 +1,10 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "declaration": true, + "rootDir": "./src", "jsx": "react-jsx" - } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } From 1e3431e592ddaca1c9c226fb0a6ecb8767adb02a Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Thu, 9 Oct 2025 20:56:02 +0200 Subject: [PATCH 09/19] remove separate editor package --- .../CHANGELOG.md | 13 - .../README.md | 74 -- .../index.html | 83 --- .../package.json | 52 -- .../src/ColorSelector.tsx | 28 - .../src/Drawer.tsx | 143 ---- .../src/EdgeDrawer.tsx | 60 -- .../src/EditorDrawer.tsx | 241 ------ .../src/EditorDrawerEdgeContent.tsx | 48 -- .../src/EditorDrawerImageContent.tsx | 43 -- .../src/EditorDrawerTaskContent.tsx | 215 ------ .../src/EditorDrawerTextContent.tsx | 44 -- .../src/EditorDrawerTopicContent.tsx | 6 - .../src/EditorToolbar.tsx | 92 --- .../src/FloatingEdge.tsx | 39 - .../src/HyperbookLearningmapEditor.tsx | 22 - .../src/LearningMap.tsx | 253 ------- .../src/LearningMapEditor.tsx | 689 ------------------ .../src/ProgressTracker.tsx | 25 - .../src/RotationInput.tsx | 38 - .../src/SettingsDrawer.tsx | 61 -- .../src/Video.tsx | 54 -- .../src/autoLayoutElk.ts | 43 -- .../src/helper.ts | 86 --- .../src/icons/StarCircle.tsx | 23 - .../src/index.css | 651 ----------------- .../src/index.ts | 16 - .../src/nodes/ImageNode.tsx | 25 - .../src/nodes/TaskNode.tsx | 44 -- .../src/nodes/TextNode.tsx | 19 - .../src/nodes/TopicNode.tsx | 44 -- .../src/types.ts | 90 --- .../src/useUndoable/errors.ts | 9 - .../src/useUndoable/index.ts | 126 ---- .../src/useUndoable/mutate.ts | 117 --- .../src/useUndoable/reducer.ts | 88 --- .../src/useUndoable/types.ts | 63 -- .../tsconfig.json | 10 - .../vite.config.ts | 22 - 39 files changed, 3799 deletions(-) delete mode 100644 packages/web-component-learningmap-editor/CHANGELOG.md delete mode 100644 packages/web-component-learningmap-editor/README.md delete mode 100644 packages/web-component-learningmap-editor/index.html delete mode 100644 packages/web-component-learningmap-editor/package.json delete mode 100644 packages/web-component-learningmap-editor/src/ColorSelector.tsx delete mode 100644 packages/web-component-learningmap-editor/src/Drawer.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EdgeDrawer.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawer.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx delete mode 100644 packages/web-component-learningmap-editor/src/EditorToolbar.tsx delete mode 100644 packages/web-component-learningmap-editor/src/FloatingEdge.tsx delete mode 100644 packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx delete mode 100644 packages/web-component-learningmap-editor/src/LearningMap.tsx delete mode 100644 packages/web-component-learningmap-editor/src/LearningMapEditor.tsx delete mode 100644 packages/web-component-learningmap-editor/src/ProgressTracker.tsx delete mode 100644 packages/web-component-learningmap-editor/src/RotationInput.tsx delete mode 100644 packages/web-component-learningmap-editor/src/SettingsDrawer.tsx delete mode 100644 packages/web-component-learningmap-editor/src/Video.tsx delete mode 100644 packages/web-component-learningmap-editor/src/autoLayoutElk.ts delete mode 100644 packages/web-component-learningmap-editor/src/helper.ts delete mode 100644 packages/web-component-learningmap-editor/src/icons/StarCircle.tsx delete mode 100644 packages/web-component-learningmap-editor/src/index.css delete mode 100644 packages/web-component-learningmap-editor/src/index.ts delete mode 100644 packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx delete mode 100644 packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx delete mode 100644 packages/web-component-learningmap-editor/src/nodes/TextNode.tsx delete mode 100644 packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx delete mode 100644 packages/web-component-learningmap-editor/src/types.ts delete mode 100644 packages/web-component-learningmap-editor/src/useUndoable/errors.ts delete mode 100644 packages/web-component-learningmap-editor/src/useUndoable/index.ts delete mode 100644 packages/web-component-learningmap-editor/src/useUndoable/mutate.ts delete mode 100644 packages/web-component-learningmap-editor/src/useUndoable/reducer.ts delete mode 100644 packages/web-component-learningmap-editor/src/useUndoable/types.ts delete mode 100644 packages/web-component-learningmap-editor/tsconfig.json delete mode 100644 packages/web-component-learningmap-editor/vite.config.ts diff --git a/packages/web-component-learningmap-editor/CHANGELOG.md b/packages/web-component-learningmap-editor/CHANGELOG.md deleted file mode 100644 index 5db19d3c..00000000 --- a/packages/web-component-learningmap-editor/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @hyperbook/web-component-learningmap-editor - -## 0.1.0 - -### Minor Changes - -- Initial release of the learningmap editor web component -- Visual editor for creating and editing learning maps -- Support for drag-and-drop node positioning -- Configurable node settings (label, description, resources, unlock rules, completion rules) -- Background customization (color and image) -- Auto-layout support using ELK algorithm -- Change event fires when saving diff --git a/packages/web-component-learningmap-editor/README.md b/packages/web-component-learningmap-editor/README.md deleted file mode 100644 index a6e32328..00000000 --- a/packages/web-component-learningmap-editor/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# @hyperbook/web-component-learningmap-editor - -A web component for editing learning maps/roadmaps with drag-and-drop nodes, customizable settings, and visual editing capabilities. - -## Features - -- **Visual Editor**: Drag-and-drop interface for creating and positioning nodes -- **Node Types**: Support for Task and Topic nodes -- **Node Settings**: Edit labels, descriptions, resources, durations, and more -- **Unlock Rules**: Configure password, date, and dependency-based unlocking -- **Completion Rules**: Set completion needs and optional dependencies -- **Background Customization**: Configure background color and images -- **Auto-Layout**: Automatic node positioning using ELK algorithm -- **Edge Management**: Connect nodes with customizable edges - -## Usage - -```html - -``` - -## Events - -The component fires a `change` event when the Save button is pressed: - -```javascript -const editor = document.querySelector('hyperbook-learningmap-editor'); -editor.addEventListener('change', (event) => { - const roadmapData = event.detail; - console.log('Roadmap data:', roadmapData); -}); -``` - -## Roadmap Data Format - -```json -{ - "nodes": [ - { - "id": "node1", - "type": "task", - "position": { "x": 0, "y": 0 }, - "data": { - "label": "Introduction", - "description": "Learn the basics", - "resources": [ - { "label": "Documentation", "url": "https://example.com" } - ], - "unlock": { - "password": "secret", - "date": "2024-01-01", - "after": ["node0"] - }, - "completion": { - "needs": [{ "id": "node0" }], - "optional": [{ "id": "node2" }] - } - } - } - ], - "edges": [], - "background": { - "color": "#ffffff", - "image": { - "src": "bg.png", - "x": 0, - "y": 0 - } - } -} -``` diff --git a/packages/web-component-learningmap-editor/index.html b/packages/web-component-learningmap-editor/index.html deleted file mode 100644 index 3001c499..00000000 --- a/packages/web-component-learningmap-editor/index.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - Learningmap Editor Test - - - - - - - - - - - - diff --git a/packages/web-component-learningmap-editor/package.json b/packages/web-component-learningmap-editor/package.json deleted file mode 100644 index f526a56c..00000000 --- a/packages/web-component-learningmap-editor/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@hyperbook/web-component-learningmap-editor", - "version": "0.1.0", - "author": "Mike Barkmin", - "homepage": "https://github.com/openpatch/hyperbook#readme", - "license": "MIT", - "type": "module", - "main": "dist/index.umd.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "require": "./dist/index.umd.js" - } - }, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/openpatch/hyperbook.git", - "directory": "packages/web-component-learningmap-editor" - }, - "bugs": { - "url": "https://github.com/openpatch/hyperbook/issues" - }, - "scripts": { - "version": "pnpm build", - "lint": "tsc --noEmit", - "build": "vite build" - }, - "dependencies": { - "@r2wc/react-to-web-component": "^2.0.4", - "@szhsin/react-menu": "^4.5.0", - "@xyflow/react": "^12.8.6", - "elkjs": "^0.11.0", - "html-to-image": "1.11.11", - "lucide-react": "^0.544.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "tslib": "^2.8.1" - }, - "devDependencies": { - "@rollup/plugin-typescript": "^12.1.2", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", - "vite": "^6.1.0" - } -} diff --git a/packages/web-component-learningmap-editor/src/ColorSelector.tsx b/packages/web-component-learningmap-editor/src/ColorSelector.tsx deleted file mode 100644 index 911ee2f4..00000000 --- a/packages/web-component-learningmap-editor/src/ColorSelector.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; - -interface ColorSelectorProps { - value: string; - onChange: (color: string) => void; - label?: string; -} - -export const ColorSelector: React.FC = ({ value, onChange, label }) => { - return ( - - {label && {label}} - onChange(e.target.value)} - style={{ width: 32, height: 32, border: "none", background: "none", padding: 0 }} - /> - onChange(e.target.value)} - placeholder="#e5e7eb" - style={{ width: 100 }} - /> - - ); -}; diff --git a/packages/web-component-learningmap-editor/src/Drawer.tsx b/packages/web-component-learningmap-editor/src/Drawer.tsx deleted file mode 100644 index 77847dc0..00000000 --- a/packages/web-component-learningmap-editor/src/Drawer.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Node } from "@xyflow/react"; -import { NodeData } from "./types"; -import { X, Lock, CheckCircle } from "lucide-react"; -import { Video } from "./Video"; -import StarCircle from "./icons/StarCircle"; - -interface DrawerProps { - open: boolean; - onClose: () => void; - onUpdate: (node: Node) => void; - node: Node; - nodes: Node[]; - onNodeClick: (_: any, node: Node, focus: boolean) => void; -} - -function getUnlockConditions(node: Node, nodes: Node[]): Node[] { - const unmetNeeds: Node[] = []; - if (node.data?.unlock?.after) { - node.data.unlock.after.forEach((depId: string) => { - const depNode = nodes.find(n => n.id === depId); - if (depNode && depNode.data?.state !== 'completed' && depNode.data?.state !== 'mastered') { - unmetNeeds.push(depNode); - } - }); - } - return unmetNeeds; -} - -function getCompletionNeeds(node: Node, nodes: Node[]): Node[] { - const unmetNeeds: Node[] = []; - if (node.data?.completion?.needs) { - node.data.completion.needs.forEach((needId: string) => { - const needNode = nodes.find(n => n.id === needId); - if (needNode && needNode.data?.state !== 'completed' && needNode.data?.state !== 'mastered') { - unmetNeeds.push(needNode); - } - }); - } - return unmetNeeds; -} - -export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick }: DrawerProps) { - if (!open) return null; - - const locked = node.data?.state === 'locked' || false; - const unlocked = node.data?.state === 'unlocked' || false; - const completed = node.data?.state === 'completed' || false; - const started = node.data?.state === 'started' || false; - const mastered = node.data?.state === 'mastered' || false; - - const unlockConditions = getUnlockConditions(node, nodes); - const completionNeeds = getCompletionNeeds(node, nodes); - - const handleStateChange = (newState: 'locked' | 'unlocked' | 'started' | 'completed') => () => { - if (node.type === "topic" || locked) return; - - onUpdate({ - ...node, - data: { - ...node.data, - state: newState - } - }); - }; - - return ( - <> - - - > - ); -} diff --git a/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx b/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx deleted file mode 100644 index b8357d0a..00000000 --- a/packages/web-component-learningmap-editor/src/EdgeDrawer.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { X, Trash2, Save } from "lucide-react"; -import { Edge } from "@xyflow/react"; -import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent"; - -interface EdgeDrawerProps { - edge: Edge | null; - isOpen: boolean; - onClose: () => void; - onUpdate: (edge: Edge) => void; - onDelete: () => void; -} - -export const EdgeDrawer: React.FC = ({ - edge: selectedEdge, - isOpen: edgeDrawerOpen, - onClose: closeDrawer, - onUpdate: updateEdge, - onDelete: deleteEdge, -}) => { - if (!selectedEdge || !edgeDrawerOpen) return null; - return ( - - - - - Edit Edge - - - - - { - let updated = { ...selectedEdge }; - if (field === "color") { - updated = { - ...updated, - style: { ...updated.style, stroke: value }, - }; - } else if (field === "animated") { - updated = { ...updated, animated: value }; - } else if (field === "type") { - updated = { ...updated, type: value }; - } - updateEdge(updated); - }} - /> - - - Delete Edge - - - Save Changes - - - - - ); -}; diff --git a/packages/web-component-learningmap-editor/src/EditorDrawer.tsx b/packages/web-component-learningmap-editor/src/EditorDrawer.tsx deleted file mode 100644 index 0bdec2bd..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawer.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { X, Trash2, Save } from "lucide-react"; -import { Node, useReactFlow } from "@xyflow/react"; -import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; -import { EditorDrawerTopicContent } from "./EditorDrawerTopicContent"; -import { EditorDrawerImageContent } from "./EditorDrawerImageContent"; -import { EditorDrawerTextContent } from "./EditorDrawerTextContent"; -import { NodeData } from "./types"; - -interface EditorDrawerProps { - node: Node | null; - isOpen: boolean; - onClose: () => void; - onUpdate: (node: Node) => void; - onDelete: () => void; -} - -export const EditorDrawer: React.FC = ({ - node, - isOpen, - onClose, - onUpdate, - onDelete, -}) => { - const [localNode, setLocalNode] = useState | null>(node); - const { getNodes } = useReactFlow(); - const allNodes = getNodes(); - - useEffect(() => { - setLocalNode(node); - }, [node]); - - 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"); - - // Helper for dropdowns - const renderNodeSelect = (value: string, onChange: (id: string) => void) => ( - onChange(e.target.value)}> - Select node... - {nodeOptions.map(n => ( - - {n.data.label || n.id} - - ))} - - ); - - // Completion Needs - const handleCompletionNeedsChange = (idx: number, id: string) => { - if (!localNode) return; - const needs = [...(localNode.data.completion?.needs || [])]; - needs[idx] = { id }; - handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); - }; - const addCompletionNeed = () => { - if (!localNode) return; - const needs = [...(localNode.data.completion?.needs || []), { id: "" }]; - handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); - }; - const removeCompletionNeed = (idx: number) => { - if (!localNode) return; - const needs = (localNode.data.completion?.needs || []).filter((_: any, i: number) => i !== idx); - handleFieldChange("completion", { ...(localNode.data.completion || {}), needs }); - }; - - // Completion Optional - const handleCompletionOptionalChange = (idx: number, id: string) => { - if (!localNode) return; - const optional = [...(localNode.data.completion?.optional || [])]; - optional[idx] = { id }; - handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); - }; - const addCompletionOptional = () => { - if (!localNode) return; - const optional = [...(localNode.data.completion?.optional || []), { id: "" }]; - handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); - }; - const removeCompletionOptional = (idx: number) => { - if (!localNode) return; - const optional = (localNode.data.completion?.optional || []).filter((_: any, i: number) => i !== idx); - handleFieldChange("completion", { ...(localNode.data.completion || {}), optional }); - }; - - // Unlock After - const handleUnlockAfterChange = (idx: number, id: string) => { - if (!localNode) return; - const after = [...(localNode.data.unlock?.after || [])]; - after[idx] = id; - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); - }; - const addUnlockAfter = () => { - if (!localNode) return; - const after = [...(localNode.data.unlock?.after || []), ""]; - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); - }; - const removeUnlockAfter = (idx: number) => { - if (!localNode) return; - const after = (localNode.data.unlock?.after || []).filter((_: any, i: number) => i !== idx); - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after }); - }; - - const handleSave = () => { - if (!localNode) return; - onUpdate(localNode); - onClose(); - }; - - const handleFieldChange = (field: string, value: any) => { - setLocalNode((prev: Node | null) => ({ - ...prev!, - data: { ...prev!.data, [field]: value }, - })); - }; - - const handleResourceChange = (index: number, field: string, value: string) => { - if (!localNode) return; - const resources = [...(localNode.data.resources || [])]; - resources[index] = { ...resources[index], [field]: value }; - handleFieldChange("resources", resources); - }; - - const addResource = () => { - if (!localNode) return; - const resources = [...(localNode.data.resources || []), { label: "", url: "" }]; - handleFieldChange("resources", resources); - }; - - const removeResource = (index: number) => { - if (!localNode) return; - const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index); - handleFieldChange("resources", resources); - }; - - let content; - if (localNode.type === "task") { - content = ( - <> - - Edit Task - - - - - - > - ); - } else if (localNode.type === "topic") { - content = ( - <> - - Edit Topic - - - - - - > - ); - } else if (localNode.type === "image") { - content = ( - <> - - Edit Image - - - - - - > - ); - } else if (localNode.type === "text") { - content = ( - <> - - Edit Text - - - - - - > - ); - } - - return ( - <> - - - {content} - - - Delete Node - - - Save Changes - - - - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx deleted file mode 100644 index 938cbb34..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawerEdgeContent.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ColorSelector } from "./ColorSelector"; - -import { Edge } from "@xyflow/react"; - -interface Props { - localEdge: Edge; - handleFieldChange: (field: string, value: any) => void; -} - -export function EditorDrawerEdgeContent({ - localEdge, - handleFieldChange -}: Props) { - return ( - - - handleFieldChange("color", color)} - /> - - - - handleFieldChange("animated", e.target.checked)} - /> - Animated - - - - Type - handleFieldChange("type", e.target.value)} - > - Default - Straight - Step - Smoothstep - Simple Bezier - - - - ); -} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx deleted file mode 100644 index bac69992..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawerImageContent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Node } from "@xyflow/react"; -import { ImageNodeData } from "./types"; - -interface Props { - localNode: Node; - handleFieldChange: (field: string, value: any) => void; -} - -export function EditorDrawerImageContent({ localNode, handleFieldChange }: Props) { - // Convert file to base64 and update node data - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") { - handleFieldChange("data", reader.result); - } - }; - reader.readAsDataURL(file); - }; - - return ( - - - Upload Image (JPG, PNG, SVG) - - - {localNode.data.data && ( - - Preview: - - - - - )} - - ); -} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx deleted file mode 100644 index d23816e3..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawerTaskContent.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { Node } from "@xyflow/react"; -import { Plus, Trash2 } from "lucide-react"; -import { NodeData } from "./types"; - -interface Props { - localNode: Node; - handleFieldChange: (field: string, value: any) => void; - handleResourceChange: (index: number, field: string, value: string) => void; - addResource: () => void; - removeResource: (index: number) => void; - handleUnlockAfterChange: (idx: number, id: string) => void; - addUnlockAfter: () => void; - removeUnlockAfter: (idx: number) => void; - renderNodeSelect: (value: string, onChange: (id: string) => void) => React.ReactNode; - handleCompletionNeedsChange: (idx: number, id: string) => void; - addCompletionNeed: () => void; - removeCompletionNeed: (idx: number) => void; - handleCompletionOptionalChange: (idx: number, id: string) => void; - addCompletionOptional: () => void; - removeCompletionOptional: (idx: number) => void; -} - -export function EditorDrawerTaskContent({ - localNode, - handleFieldChange, - handleResourceChange, - addResource, - removeResource, - handleUnlockAfterChange, - addUnlockAfter, - removeUnlockAfter, - renderNodeSelect, - handleCompletionNeedsChange, - addCompletionNeed, - removeCompletionNeed, - handleCompletionOptionalChange, - addCompletionOptional, - removeCompletionOptional, -}: Props) { - // Color options for the dropdown - const colorOptions = [ - { value: "blue", label: "Blue", className: "react-flow__node-topic blue" }, - { value: "yellow", label: "Yellow", className: "react-flow__node-topic yellow" }, - { value: "lila", label: "Lila", className: "react-flow__node-topic lila" }, - { value: "pink", label: "Pink", className: "react-flow__node-topic pink" }, - { value: "teal", label: "Teal", className: "react-flow__node-topic teal" }, - { value: "red", label: "Red", className: "react-flow__node-topic red" }, - { value: "black", label: "Black", className: "react-flow__node-topic black" }, - { value: "white", label: "White", className: "react-flow__node-topic white" }, - ]; - - // Determine default color based on node type - let defaultColor = "blue"; - if (localNode.type === "topic") defaultColor = "yellow"; - const selectedColor = localNode.data?.color || defaultColor; - return ( - - - Node Color - - {colorOptions.map(opt => ( - handleFieldChange("color", opt.value)} - className={opt.className} - style={{ - width: 28, - height: 28, - borderRadius: 6, - cursor: "pointer", - fontWeight: "bold", - boxSizing: "border-box", - display: "inline-block", - padding: 0, - }} - >{selectedColor === opt.value ? "X" : ""} - ))} - - - - Label * - handleFieldChange("label", e.target.value)} - placeholder="Node label" - /> - - - Summary - handleFieldChange("summary", e.target.value)} - placeholder="Short summary" - /> - - - Description - handleFieldChange("description", e.target.value)} - placeholder="Detailed description" - rows={4} - /> - - - Duration - handleFieldChange("duration", e.target.value)} - placeholder="e.g., 30 min" - /> - - - Video URL - handleFieldChange("video", e.target.value)} - placeholder="YouTube or video URL" - /> - - - Resources - {(localNode.data.resources || []).map((resource: { label: string; url: string }, idx: number) => ( - - handleResourceChange(idx, "label", e.target.value)} - placeholder="Label" - style={{ flex: 1 }} - /> - handleResourceChange(idx, "url", e.target.value)} - placeholder="URL" - style={{ flex: 2 }} - /> - removeResource(idx)} className="icon-button"> - - - - ))} - - Add Resource - - - - Unlock Password - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })} - placeholder="Optional password" - /> - - - Unlock Date - handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })} - /> - - - Unlock After - {(localNode.data.unlock?.after || []).map((id: string, idx: number) => ( - - {renderNodeSelect(id, newId => handleUnlockAfterChange(idx, newId))} - removeUnlockAfter(idx)} className="icon-button"> - - - - ))} - - Add Unlock After - - - {localNode.type === "topic" && - Completion Needs - {(localNode.data.completion?.needs || []).map((need: string, idx: number) => ( - - {renderNodeSelect(need, newId => handleCompletionNeedsChange(idx, newId))} - removeCompletionNeed(idx)} className="icon-button"> - - - - ))} - - Add Need - - } - {localNode.type === "topic" && - Completion Optional - {(localNode.data.completion?.optional || []).map((opt: string, idx: number) => ( - - {renderNodeSelect(opt, newId => handleCompletionOptionalChange(idx, newId))} - removeCompletionOptional(idx)} className="icon-button"> - - - - ))} - - Add Optional - - } - - ); -} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx deleted file mode 100644 index d136691d..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawerTextContent.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Node } from "@xyflow/react"; -import { TextNodeData } from "./types"; -import { ColorSelector } from "./ColorSelector"; -import { RotationInput } from "./RotationInput"; - -interface Props { - localNode: Node; - handleFieldChange: (field: string, value: any) => void; -} - -export function EditorDrawerTextContent({ localNode, handleFieldChange }: Props) { - return ( - - - Text - handleFieldChange("text", e.target.value)} - placeholder="Background Text" - /> - - - Font Size - handleFieldChange("fontSize", Number(e.target.value))} - /> - - - handleFieldChange("color", color)} - /> - - handleFieldChange("rotation", v)} - /> - - ); -} diff --git a/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx b/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx deleted file mode 100644 index ee7ed8e3..00000000 --- a/packages/web-component-learningmap-editor/src/EditorDrawerTopicContent.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent"; - -// For now, topic content is the same as task content. You can customize later if needed. -export function EditorDrawerTopicContent(props: any) { - return ; -} diff --git a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx b/packages/web-component-learningmap-editor/src/EditorToolbar.tsx deleted file mode 100644 index 7ed88b7d..00000000 --- a/packages/web-component-learningmap-editor/src/EditorToolbar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import { Menu, MenuButton, MenuItem, SubMenu } from "@szhsin/react-menu"; -import "@szhsin/react-menu/dist/index.css"; -import '@szhsin/react-menu/dist/transitions/zoom.css'; -import { Save, Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown } from "lucide-react"; - -interface EditorToolbarProps { - saved: boolean; - 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; - onSave: () => void; - onDownlad: () => void; - onOpen: () => void; - onExportSVG: () => void; -} - -export const EditorToolbar: React.FC = ({ - saved, - debugMode, - previewMode, - showCompletionNeeds, - showCompletionOptional, - showUnlockAfter, - onTogglePreviewMode, - onToggleDebugMode, - onSetShowCompletionNeeds, - onSetShowCompletionOptional, - onSetShowUnlockAfter, - onAddNewNode, - onOpenSettingsDrawer, - onSave, - onDownlad, - onOpen, - onExportSVG -}) => ( - - - Nodes}> - onAddNewNode("task")}>Add Task - onAddNewNode("topic")}>Add Topic - onAddNewNode("image")}>Add Image - onAddNewNode("text")}>Add Text - - - Settings - - - - }> - Debug>}> - - Enable Debug Mode - - onSetShowCompletionNeeds(e.checked ?? false)} disabled={!debugMode}> - Show Completion Needs Edges - - onSetShowCompletionOptional(e.checked ?? false)} disabled={!debugMode}> - Show Completion Optional Edges - - onSetShowUnlockAfter(e.checked ?? false)} disabled={!debugMode}> - Show Unlock After Edges - - - - Preview - - - Save{!saved ? "*" : ""} - - - Download - - - Open - - {false && - Export as SVG - } - - - -); diff --git a/packages/web-component-learningmap-editor/src/FloatingEdge.tsx b/packages/web-component-learningmap-editor/src/FloatingEdge.tsx deleted file mode 100644 index 29b7528b..00000000 --- a/packages/web-component-learningmap-editor/src/FloatingEdge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Edge, getBezierPath, useInternalNode } from '@xyflow/react'; - -import { getEdgeParams } from './helper'; - -const FloatingEdge = ({ id, source, target, markerEnd, style }: Edge) => { - const sourceNode = useInternalNode(source); - const targetNode = useInternalNode(target); - - if (!sourceNode || !targetNode) { - return null; - } - - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( - sourceNode, - targetNode, - ); - - const [edgePath] = getBezierPath({ - sourceX: sx, - sourceY: sy, - sourcePosition: sourcePos, - targetPosition: targetPos, - targetX: tx, - targetY: ty, - }); - - return ( - - ); -} - -export default FloatingEdge; - diff --git a/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx b/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx deleted file mode 100644 index 2c4bd301..00000000 --- a/packages/web-component-learningmap-editor/src/HyperbookLearningmapEditor.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { - ReactFlowProvider, -} from "@xyflow/react"; -import { LearningMapEditor } from "./LearningMapEditor"; -import { RoadmapData } from "./types"; - -export function HyperbookLearningmapEditor({ - roadmapData, - language -}: { - roadmapData: string | RoadmapData; - language?: string; -}) { - return ( - - - - ); -} diff --git a/packages/web-component-learningmap-editor/src/LearningMap.tsx b/packages/web-component-learningmap-editor/src/LearningMap.tsx deleted file mode 100644 index 1394764a..00000000 --- a/packages/web-component-learningmap-editor/src/LearningMap.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Controls, Edge, Node, Panel, ReactFlow, useEdgesState, useNodesState, 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, Settings } from "./types"; -import { useCallback, useEffect, useState } from "react"; -import { parseRoadmapData } from "./helper"; -import { Drawer } from "./Drawer"; -import { ProgressTracker } from "./ProgressTracker"; - -const nodeTypes = { - topic: TopicNode, - task: TaskNode, - image: ImageNode, - 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"; -} - -const countCompletedNodes = (nodes: Node[]) => { - let completed = 0; - let mastered = 0; - let total = 0; - nodes.forEach(n => { - if (n.type === "task" || n.type === "topic") { - total++; - if (n.data?.state === 'completed') { - completed++; - } - else if (n.data?.state === 'mastered') { - completed++; - mastered++; - } - } - }); - return { completed, mastered, total }; -} - -export function LearningMap({ - roadmapData, - onChange, - language = "en" -}: { - roadmapData: string | RoadmapData; - language?: string; - onChange?: (state: Record) => void; -}) { - 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(); - const { fitView } = useReactFlow(); - - const { completed, mastered, total } = countCompletedNodes(nodes); - - 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) => ({ - ...n, - draggable: false, - connectable: false, - selectable: isInteractableNode(n), - focusable: isInteractableNode(n), - })); - - rawNodes = updateNodesStates(rawNodes); - - setEdges(edgesArr); - setNodes(rawNodes); - } - loadRoadmap(); - }, [roadmapData]); - - const onNodeClick = useCallback((_: any, node: Node, focus: boolean = false) => { - if (!isInteractableNode(node)) return; - setSelectedNode(node); - setDrawerOpen(true); - - if (focus) { - fitView({ nodes: [node], duration: 150 }); - } - }, [fitView]); - - const closeDrawer = useCallback(() => { - setDrawerOpen(false); - setSelectedNode(null); - }, []); - - const updateNode = useCallback( - (updatedNode: Node) => { - setNodes((nds) => { - let newNodes = nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) - newNodes = updateNodesStates(newNodes); - return newNodes; - } - ); - setSelectedNode(updatedNode); - }, - [setNodes] - ); - - useEffect(() => { - const minimalState: Record = {}; - nodes.forEach((n) => { - if (n.data.state) { - minimalState[n.id] = { state: n.data.state }; - } - }); - if (onChange) { - onChange(minimalState); - } else { - const root = document.querySelector("hyperbook-learningmap"); - if (root) { - root.dispatchEvent(new CustomEvent("change", { detail: minimalState })); - } - } - }, [nodes]); - - const defaultEdgeOptions = { - animated: false, - style: { - stroke: "#94a3b8", - strokeWidth: 2, - }, - type: "default", - }; - - return ( - - { - const className = []; - if (n.data?.color) { - className.push(n.data.color); - } - className.push(n.data?.state); - return { - ...n, - selected: selectedNode?.id === n.id, - className: className.join(" "), - }; - })} - edges={edges} - onEdgesChange={onEdgesChange} - onNodeClick={onNodeClick} - onNodesChange={onNodesChange} - nodeTypes={nodeTypes} - fitView - nodeOrigin={[0.5, 0.5]} - proOptions={{ hideAttribution: true }} - defaultEdgeOptions={defaultEdgeOptions} - nodesDraggable={false} - nodesConnectable={false} - > - {settings?.title && ( - - - {settings.title} - - - )} - - - - - - - - ) -} diff --git a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx b/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx deleted file mode 100644 index 6216b208..00000000 --- a/packages/web-component-learningmap-editor/src/LearningMapEditor.tsx +++ /dev/null @@ -1,689 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - ReactFlow, - Controls, - useNodesState, - useEdgesState, - ColorMode, - useReactFlow, - Node, - addEdge, - Connection, - Edge, - Background, - ControlButton, - OnNodesChange, - OnEdgesChange, - getNodesBounds, - getViewportForBounds, - Panel, -} from "@xyflow/react"; -import { toSvg } from "html-to-image"; -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 { 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 } from "lucide-react"; -import useUndoable from "./useUndoable"; - -const nodeTypes = { - topic: TopicNode, - task: TaskNode, - image: ImageNode, - text: TextNode, -}; - -const edgeTypes = { - floating: FloatingEdge -}; - - -export function LearningMapEditor({ - roadmapData, - language = "en", - onChange, -}: { - roadmapData: string | RoadmapData; - language?: string; - onChange?: (data: RoadmapData) => void; -}) { - const keyboardShortcuts = [ - { action: "Save", shortcut: "Ctrl+S" }, - { action: "Undo", shortcut: "Ctrl+Z" }, - { action: "Redo", shortcut: "Ctrl+Y or Ctrl+Shift+Z" }, - { action: "Add Task Node", shortcut: "Ctrl+A" }, - { action: "Add Topic Node", shortcut: "Ctrl+O" }, - { action: "Add Image Node", shortcut: "Ctrl+I" }, - { action: "Add Text Node", shortcut: "Ctrl+X" }, - { action: "Delete Node/Edge", shortcut: "Delete" }, - { action: "Toggle Preview Mode", shortcut: "Ctrl+P" }, - { action: "Toggle Debug Mode", shortcut: "Ctrl+D" }, - { action: "Show Help", shortcut: "Ctrl+? or Help Button" }, - ]; - - const { screenToFlowPosition, getViewport, setViewport } = useReactFlow(); - const parsedRoadmap = parseRoadmapData(roadmapData); - const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset }] = 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([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [settings, setSettings] = useState({ background: { color: "#ffffff" } }); - - const [helpOpen, setHelpOpen] = useState(false); - const [colorMode] = useState("light"); - const [selectedNode, setSelectedNode] = useState | null>(null); - 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); - - // Edge drawer state - const [selectedEdge, setSelectedEdge] = useState(null); - const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false); - - // Track Shift key state - const [shiftPressed, setShiftPressed] = useState(false); - - 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, - 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(() => { - loadRoadmapStateIntoReactFlowState(parsedRoadmap); - }, []); - - 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) => { - setSelectedNode(node); - 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); - setSelectedNode(null); - setEdgeDrawerOpen(false); - setSelectedEdge(null); - setSettingsDrawerOpen(false) - }, []); - - const updateNode = useCallback( - (updatedNode: Node) => { - setNodes((nds) => - nds.map((n) => (n.id === updatedNode.id ? updatedNode : n)) - ); - setSelectedNode(updatedNode); - setSaved(false); - }, - [setNodes, setSelectedNode, 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 (!selectedNode) return; - setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); - setEdges((eds) => - eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id) - ); - setSaved(false); - closeDrawer(); - }, [selectedNode, setNodes, setEdges, closeDrawer, setSaved]); - - const addNewNode = useCallback( - (type: "task" | "topic" | "image" | "text") => { - const centerPos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - if (type === "task") { - const newNode: Node = { - id: `node${nextNodeId}`, - type, - position: centerPos, - data: { - label: `New ${type}`, - summary: "", - description: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } else if (type === "topic") { - const newNode: Node = { - id: `node${nextNodeId}`, - type, - position: centerPos, - data: { - label: `New ${type}`, - 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: centerPos, - data: { - src: "", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } else if (type === "text") { - const newNode: Node = { - id: `background-node${nextNodeId}`, - type, - position: centerPos, - zIndex: -1, - data: { - text: "Background Text", - fontSize: 32, - color: "#e5e7eb", - }, - }; - setNodes((nds) => [...nds, newNode]); - setNextNodeId((id) => id + 1); - } - setSaved(false); - }, - [nextNodeId, screenToFlowPosition, setNodes, setSaved] - ); - - const handleSave = useCallback(() => { - const roadmapData: RoadmapData = { - nodes: nodes.map((n) => ({ - id: n.id, - type: n.type, - position: n.position, - 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]); - - 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", "roadmap.json"); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); - }, [roadmapState]); - - const defaultEdgeOptions = { - animated: false, - style: { - stroke: "#94a3b8", - strokeWidth: 2, - }, - type: "default", - }; - - const handleExportSVG = useCallback(async () => { - const nodesBounds = getNodesBounds(nodes); - const imageWidth = nodesBounds.width; - const imageHeight = nodesBounds.height; - let viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.1, 5); - - const dom = document.querySelector(".react-flow__viewport") as HTMLElement; - if (!dom) return; - - toSvg(dom, { - backgroundColor: settings?.background?.color || "#ffffff", - width: imageWidth, - height: imageHeight, - style: { - transform: `translate(${viewport.x / 2.0}px, ${viewport.y / 2.0}px) scale(${viewport.zoom})`, - width: `${imageWidth}px`, - height: `${imageHeight}px`, - } - }).then((dataUrl) => { - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute("href", dataUrl); - downloadAnchorNode.setAttribute("download", "roadmap.svg"); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); - - // Restore old viewport - }).catch((err) => { - alert("Failed to export SVG: " + err.message); - }); - }, [nodes, roadmapState]); - - const handleOpen = useCallback(() => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json,application/json'; - input.onchange = (e: any) => { - const file = e.target.files[0]; - if (!file) return; - - if (!window.confirm("Opening a file will replace your current map. Continue?")) { - 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('Failed to load the file. Please make sure it is a valid roadmap JSON file.'); - } - }; - reader.readAsText(file); - }; - input.click(); - }, [setRoadmapState, setDidUndoRedo]); - - // 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]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift") setShiftPressed(true); - //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.toLowerCase() === 'a' && !e.shiftKey) { - e.preventDefault(); - addNewNode("task"); - } - // add topic node shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o' && !e.shiftKey) { - e.preventDefault(); - addNewNode("topic"); - } - // add image node shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i' && !e.shiftKey) { - e.preventDefault(); - addNewNode("image"); - } - // add text node shortcut - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) { - e.preventDefault(); - addNewNode("text"); - } - - 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(); - } - // Dismiss with Escape - if (helpOpen && e.key === 'Escape') { - setHelpOpen(false); - } - }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === "Shift") setShiftPressed(false); - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]); - - return ( - - - {previewMode && } - {!previewMode && <> - - { - const className = []; - if (n.data?.color) { - className.push(n.data.color); - } - return { - ...n, - className: className.join(" ") - }; - })} - edges={edges} - onEdgesChange={handleEdgesChange} - onNodeDoubleClick={onNodeClick} - onEdgeDoubleClick={onEdgeClick} - onNodesChange={handleNodesChange} - onConnect={onConnect} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - fitView - snapToGrid={!shiftPressed} - nodeOrigin={[0.5, 0.5]} - proOptions={{ hideAttribution: true }} - defaultEdgeOptions={defaultEdgeOptions} - nodesDraggable={true} - elevateNodesOnSelect={false} - nodesConnectable={true} - colorMode={colorMode} - > - - - - - - - - - - - - setHelpOpen(true)}> - - - - {!saved && { handleSave(); }}> - - } - - - - - - setHelpOpen(false)} - > - Keyboard Shortcuts - - - - Action - Shortcut - - - - {keyboardShortcuts.map((item) => ( - - {item.action} - {item.shortcut} - - ))} - - - setHelpOpen(false)}>Close - - > - } - - ); -} diff --git a/packages/web-component-learningmap-editor/src/ProgressTracker.tsx b/packages/web-component-learningmap-editor/src/ProgressTracker.tsx deleted file mode 100644 index 1d14849d..00000000 --- a/packages/web-component-learningmap-editor/src/ProgressTracker.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { CheckCircle } from "lucide-react"; -import StarCircle from "./icons/StarCircle"; - -export const ProgressTracker = ({ completed, mastered, total }: { completed: number; mastered: number; total: number }) => { - const progress = total > 0 ? (completed / total) * 100 : 0; - - return ( - <> - - - {completed} / {total} - - - {progress}% - - - - - - {mastered} - - > - ); -} - diff --git a/packages/web-component-learningmap-editor/src/RotationInput.tsx b/packages/web-component-learningmap-editor/src/RotationInput.tsx deleted file mode 100644 index 04da8b3d..00000000 --- a/packages/web-component-learningmap-editor/src/RotationInput.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; - -interface RotationInputProps { - value: number; - onChange: (value: number) => void; -} - -export function RotationInput({ value, onChange }: RotationInputProps) { - return ( - - Rotation (degrees): {value}° - - onChange(Number(e.target.value))} - style={{ flex: 1 }} - /> - { - let v = Number(e.target.value); - if (isNaN(v)) v = 0; - if (v < 0) v = 0; - if (v > 360) v = 360; - onChange(v); - }} - style={{ width: 100 }} - /> - - - ); -} diff --git a/packages/web-component-learningmap-editor/src/SettingsDrawer.tsx b/packages/web-component-learningmap-editor/src/SettingsDrawer.tsx deleted file mode 100644 index 06f6d9d6..00000000 --- a/packages/web-component-learningmap-editor/src/SettingsDrawer.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { X, Save } from "lucide-react"; -import { Settings } from "./types"; -import { ColorSelector } from "./ColorSelector"; - -interface SettingsDrawerProps { - isOpen: boolean; - onClose: () => void; - settings: Settings; - onUpdate: (s: Settings) => void; -} - -export const SettingsDrawer: React.FC = ({ - isOpen, - onClose, - settings, - onUpdate, -}) => { - const [localSettings, setLocalSettings] = useState(settings); - - useEffect(() => { - setLocalSettings(settings); - }, [settings]); - - if (!isOpen) return null; - - const handleSave = () => { - onUpdate(localSettings); - onClose(); - }; - - return ( - <> - - - - Background Settings - - - - - - - - setLocalSettings(settings => ({ ...settings, background: { ...settings.background, color } }))} - /> - - - - - - Save Changes - - - - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/Video.tsx b/packages/web-component-learningmap-editor/src/Video.tsx deleted file mode 100644 index 81b2ab8b..00000000 --- a/packages/web-component-learningmap-editor/src/Video.tsx +++ /dev/null @@ -1,54 +0,0 @@ -function isYoutubeUrl(url: string) { - return ( - typeof url === "string" && - (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/")) - ); -} - -function getYoutubeEmbedUrl(url: string) { - if (url.includes("youtube.com/watch?v=")) { - const videoId = url.split("v=")[1].split("&")[0]; - return `https://www.youtube-nocookie.com/embed/${videoId}`; - } - if (url.includes("youtu.be/")) { - const videoId = url.split("youtu.be/")[1].split("?")[0]; - return `https://www.youtube-nocookie.com/embed/${videoId}`; - } - return url; -} - -function getVideoMimeType(url: string) { - if (url.endsWith(".webm")) return "video/webm"; - if (url.endsWith(".mp4")) return "video/mp4"; - return "video/mp4"; -} - -export const Video: React.FC<{ url: string; title?: string }> = ({ url, title }) => { - if (isYoutubeUrl(url)) { - const embedUrl = getYoutubeEmbedUrl(url); - return ( - - - - ); - } else { - const mimeType = getVideoMimeType(url); - return ( - - - Your browser does not support the video tag. - - ); - } -}; diff --git a/packages/web-component-learningmap-editor/src/autoLayoutElk.ts b/packages/web-component-learningmap-editor/src/autoLayoutElk.ts deleted file mode 100644 index 73cd091e..00000000 --- a/packages/web-component-learningmap-editor/src/autoLayoutElk.ts +++ /dev/null @@ -1,43 +0,0 @@ -import ELK from "elkjs/lib/elk.bundled.js"; -import type { Node, Edge } from "@xyflow/react"; - -export async function getAutoLayoutedNodesElk( - nodes: Node[], - edges: Edge[], - nodeWidth = 320, - nodeHeight = 120 -) { - const elk = new ELK(); - const elkNodes = nodes.map((node: Node) => ({ - id: node.id, - width: nodeWidth, - height: nodeHeight, - ...node, - })); - const elkEdges = edges.map((edge: Edge) => ({ - id: edge.id, - sources: [edge.source], - targets: [edge.target], - })); - const elkGraph = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": "DOWN", - "elk.layered.spacing.nodeNodeBetweenLayers": "100", - "elk.spacing.nodeNode": "80", - }, - children: elkNodes, - edges: elkEdges, - }; - const layout: any = await elk.layout(elkGraph); - return nodes.map((node: Node) => { - if (node.position) return node; - const layoutNode = layout.children.find((n: any) => n.id === node.id); - return { - ...node, - position: { x: layoutNode.x, y: layoutNode.y }, - autoPositioned: true, - }; - }); -} diff --git a/packages/web-component-learningmap-editor/src/helper.ts b/packages/web-component-learningmap-editor/src/helper.ts deleted file mode 100644 index 50d7345e..00000000 --- a/packages/web-component-learningmap-editor/src/helper.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Position, Node } from "@xyflow/react"; -import { RoadmapData } from "./types"; - -// this helper function returns the intersection point -// of the line between the center of the intersectionNode and the target node -function getNodeIntersection(intersectionNode: Node, targetNode: Node) { - // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a - const { width: intersectionNodeWidth, height: intersectionNodeHeight } = - intersectionNode.measured; - const intersectionNodePosition = intersectionNode.internals.positionAbsolute; - const targetPosition = targetNode.internals.positionAbsolute; - - const w = intersectionNodeWidth / 2; - const h = intersectionNodeHeight / 2; - - const x2 = intersectionNodePosition.x + w; - const y2 = intersectionNodePosition.y + h; - const x1 = targetPosition.x + targetNode.measured.width / 2; - const y1 = targetPosition.y + targetNode.measured.height / 2; - - const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); - const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); - const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); - const xx3 = a * xx1; - const yy3 = a * yy1; - const x = w * (xx3 + yy3) + x2; - const y = h * (-xx3 + yy3) + y2; - - return { x, y }; -} - -// returns the position (top,right,bottom or right) passed node compared to the intersection point -function getEdgePosition(node: Node, intersectionPoint: any) { - const n = { ...node.internals.positionAbsolute, ...node }; - const nx = Math.round(n.x); - const ny = Math.round(n.y); - const px = Math.round(intersectionPoint.x); - const py = Math.round(intersectionPoint.y); - - if (px <= nx + 1) { - return Position.Left; - } - if (px >= nx + n.measured.width - 1) { - return Position.Right; - } - if (py <= ny + 1) { - return Position.Top; - } - if (py >= n.y + n.measured.height - 1) { - return Position.Bottom; - } - - return Position.Top; -} - -// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge -export function getEdgeParams(source: Node, target: Node) { - const sourceIntersectionPoint = getNodeIntersection(source, target); - const targetIntersectionPoint = getNodeIntersection(target, source); - - const sourcePos = getEdgePosition(source, sourceIntersectionPoint); - const targetPos = getEdgePosition(target, targetIntersectionPoint); - - return { - sx: sourceIntersectionPoint.x, - sy: sourceIntersectionPoint.y, - tx: targetIntersectionPoint.x, - ty: targetIntersectionPoint.y, - sourcePos, - targetPos, - }; -} - -export const parseRoadmapData = ( - roadmapData: string | RoadmapData, -): RoadmapData => { - if (typeof roadmapData !== "string") { - return roadmapData || {}; - } - try { - return JSON.parse(roadmapData); - } catch (err) { - console.error("Failed to parse roadmap data:", err); - return { settings: { title: "New Roadmap" }, version: 1 }; - } -}; diff --git a/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx b/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx deleted file mode 100644 index 109f5aa7..00000000 --- a/packages/web-component-learningmap-editor/src/icons/StarCircle.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FC, SVGProps } from "react"; - -const StarCircle: FC> = (props) => ( - - - - -); - -export default StarCircle; diff --git a/packages/web-component-learningmap-editor/src/index.css b/packages/web-component-learningmap-editor/src/index.css deleted file mode 100644 index c27d34a4..00000000 --- a/packages/web-component-learningmap-editor/src/index.css +++ /dev/null @@ -1,651 +0,0 @@ -/* Container */ -.hyperbook-learningmap-editor-container { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background: var(--color-nav, #f9fafb); -} - -/* Toolbar */ -.editor-toolbar { - width: 100%; - background: var(--color-nav, #ffffff); - border-bottom: 1px solid var(--color-nav-border, #e5e7eb); - padding: 12px 24px; - display: flex; - justify-content: space-between; - align-items: center; - z-index: 10; - box-sizing: border-box; -} - -.toolbar-group { - display: flex; - gap: 8px; -} - -.toolbar-button { - padding: 8px 16px; - border: 1px solid var(--color-nav-border, #d1d5db); - border-radius: 6px; - background: var(--color-nav, white); - color: var(--color-text, #1f2937); - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s; -} - -.toolbar-button:hover { - background: var(--color-spacer, #f3f4f6); - border-color: var(--color-brand, #3b82f6); -} - -.toolbar-button.primary { - background: var(--color-brand, #3b82f6); - color: white; - border-color: var(--color-brand, #3b82f6); -} - -.toolbar-button.primary:hover { - background: #2563eb; - border-color: #2563eb; -} - -.toolbar-button.active { - background: var(--color-brand, #3b82f6); - color: white; - border-color: var(--color-brand, #3b82f6); -} - -.toolbar-button.active:hover { - background: #2563eb; - border-color: #2563eb; -} - -.toolbar-button:disabled { - background: var(--color-nav, #f3f4f6); - color: var(--color-text, #9ca3af); - border-color: var(--color-nav-border, #e5e7eb); - cursor: not-allowed; -} - -/* Editor Canvas */ -.editor-canvas { - flex: 1; - min-height: 0; - position: relative; - width: 100%; -} - -.react-flow__node-background img { - max-width: none; -} - -/* Drawer Styles */ -.drawer-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 1002; - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes slideIn { - from { - transform: translateX(100%); - } - - to { - transform: translateX(0); - } -} - -.drawer { - position: fixed; - top: 0; - right: 0; - bottom: 0; - width: 500px; - max-width: 90vw; - background: var(--color-nav, white); - box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1); - z-index: 1003; - display: flex; - flex-direction: column; - animation: slideIn 0.3s ease; -} - -.drawer-header { - padding: 24px; - border-bottom: 1px solid var(--color-nav-border, #e5e7eb); - display: flex; - justify-content: space-between; - align-items: center; -} - -.drawer-title { - font-size: 24px; - font-weight: 700; - margin: 0; - border: none; -} - -.drawer-footer button { - width: 100%; -} - -.close-button { - background: none; - border: none; - cursor: pointer; - padding: 4px; - color: var(--color-text, #6b7280); - transition: color 0.2s; -} - -.close-button:hover { - color: var(--color-text, #1f2937); -} - -.drawer-content { - flex: 1; - overflow-y: auto; - padding: 24px; -} - -.drawer-footer { - padding: 24px; - border-top: 1px solid var(--color-nav-border, #e5e7eb); - display: flex; - gap: 12px; - justify-content: flex-end; -} - -/* Form Styles */ -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - font-size: 14px; - font-weight: 600; - margin-bottom: 6px; - color: var(--color-text, #374151); -} - -.form-group input[type="text"], -.form-group input[type="date"], -.form-group input[type="number"], -.form-group input[type="color"], -.form-group textarea, -.form-group select { - width: 100%; - padding: 10px 12px; - border: 1px solid var(--color-nav-border, #d1d5db); - border-radius: 6px; - font-size: 14px; - background: var(--color-nav, white); - color: var(--color-text, #1f2937); - box-sizing: border-box; - transition: border-color 0.2s; -} - -.form-group input[type="color"] { - height: 64px; -} - -.form-group input:focus, -.form-group textarea:focus, -.form-group select:focus { - outline: none; - border-color: var(--color-brand, #3b82f6); -} - -.form-group textarea { - resize: vertical; - font-family: inherit; -} - -/* Buttons */ -.primary-button { - padding: 10px 20px; - background: var(--color-brand, #3b82f6); - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.2s; -} - -.primary-button:hover { - background: #2563eb; -} - -.secondary-button { - padding: 8px 16px; - background: var(--color-nav, white); - color: var(--color-text, #1f2937); - border: 1px solid var(--color-nav-border, #d1d5db); - border-radius: 6px; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s; - width: 100%; - justify-content: center; -} - -.secondary-button:hover { - background: var(--color-spacer, #f3f4f6); -} - -.danger-button { - padding: 10px 20px; - background: #ef4444; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.2s; -} - -.danger-button:hover { - background: #dc2626; -} - -.drawer-button { - padding: 14px 20px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.2s; - background: var(--color-nav, white); - color: var(--color-text, #1f2937); - border: 1px solid var(--color-nav-border, #d1d5db); - border-radius: 6px; - font-size: 14px; - cursor: pointer; -} - -.drawer-button:hover { - background: var(--color-spacer, #f3f4f6); - border-color: var(--color-brand, #3b82f6); -} - -.drawer-button.locked { - background: var(--color-nav, #f3f4f6); - color: var(--color-text, #9ca3af); - border-color: var(--color-nav-border, #e5e7eb); - cursor: not-allowed; -} - -.drawer-button.started { - background: #fef3c7; - border-color: #f59e42; - color: #b45309; -} - -.drawer-button.completed { - background: #d1fae5; - border-color: #10b981; - color: #065f46; -} - -.drawer-button.mastered { - background: #d1fae5; - border-color: #10b981; - color: #065f46; -} - -.icon-button { - padding: 6px; - background: none; - border: 1px solid var(--color-nav-border, #d1d5db); - border-radius: 4px; - cursor: pointer; - color: var(--color-text, #6b7280); - transition: all 0.2s; -} - -.icon-button:hover { - background: var(--color-spacer, #f3f4f6); - color: #ef4444; -} - -.react-flow__controls-button svg.lucide { - fill: none; -} - -.react-flow__controls-button { - transition: all 0.2s; -} - -.react-flow__controls-button:hover { - background: var(--color-spacer, #f3f4f6); -} - -.react-flow__controls-button:disabled { - background: var(--color-nav, #f3f4f6); - color: var(--color-text, #9ca3af); -} - -/* React Flow Handles */ -.react-flow__handle { - width: 10px; - height: 10px; - background: var(--color-brand, #3b82f6); - border: 2px solid white; -} - -.react-flow__edge.selected { - outline: 1px solid var(--color-brand, #3b82f6); -} - -.react-flow__node.selected { - outline: 2px solid var(--color-brand, #3b82f6); - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3); -} - -.react-flow__node-image img { - width: 100%; -} - -.react-flow__node-task { - padding: 16px 24px; - border-radius: 16px; - border: 2px solid; - border-color: #3b82f6; - background: #f0f7ff; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - - .icon { - position: absolute; - top: -10px; - right: -10px; - fill: #fff; - stroke: #3b82f6; - } - -} - -.react-flow__node-topic { - padding: 16px 24px; - border-radius: 16px; - border: 2px solid; - border-color: #f59e42; - background: #fffbe6; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - - .icon { - position: absolute; - top: -10px; - left: -10px; - fill: #fff; - stroke: #f59e42; - } -} - -.react-flow__node-topic.black, -.react-flow__node-task.black { - border-color: #374151; - background: #f3f4f6; - - .icon { - stroke: #374151; - } -} - -.react-flow__node-topic.white, -.react-flow__node-task.white { - border-color: #9ca3af; - background: #f9fafb; - - .icon { - stroke: #9ca3af; - } -} - -.react-flow__node-topic.lila, -.react-flow__node-task.lila { - border-color: #9e86ed; - background: #f3e8ff; - - .icon { - stroke: #9e86ed; - } -} - -.react-flow__node-topic.pink, -.react-flow__node-task.pink { - border-color: #ec4899; - background: #fdf2f8; - - .icon { - stroke: #ec4899; - } -} - -.react-flow__node-topic.teal, -.react-flow__node-task.teal { - border-color: #14b8a6; - background: #e0f2fe; - - .icon { - stroke: #14b8a6; - } -} - -.react-flow__node-topic.yellow, -.react-flow__node-task.yellow { - border-color: #f59e42; - background: #fffbeb; - - .icon { - stroke: #f59e42; - } -} - -.react-flow__node-topic.red, -.react-flow__node-task.red { - border-color: #ef4444; - background: #fef2f2; - - .icon { - stroke: #ef4444; - } -} - -.react-flow__node-topic.blue, -.react-flow__node-task.blue { - border-color: #3b82f6; - background: #eff6ff; - - .icon { - stroke: #3b82f6; - } -} - -.react-flow__node-task.completed, -.react-flow__node-topic.completed { - text-decoration: line-through; - border-color: #10b981; - background: #ecfdf5; - - .icon { - stroke: #10b981; - } -} - -.react-flow__node-task.locked, -.react-flow__node-topic.locked { - border-color: #6b7280; - background: #e5e7eb; - - .icon { - stroke: #6b7280; - } -} - -.react-flow__node-task.mastered, -.react-flow__node-topic.mastered { - text-decoration: line-through; - border-color: #10b981; - background: #ecfdf5; - - .icon { - stroke: #10b981; - } -} - -dialog.help[open] { - width: 600px; - max-width: 90vw; - border: none; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); - padding: 24px; - background: var(--color-nav, white); - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - gap: 16px; - display: flex; - flex-direction: column; - - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border: 1px solid var(--color-nav-border, #e5e7eb); - padding: 8px; - text-align: left; - } - - th { - background: var(--color-spacer, #f3f4f6); - } - - button { - width: 100%; - } -} - -.szh-menu__item { - svg { - margin-right: 8px; - } -} - -.szh-menu__submenu.active, -.szh-menu__item.active { - color: var(--color-brand, #3b82f6); - -} - -.link-button { - color: var(--color-brand, #3b82f6); - text-decoration: underline; - background: none; - border: none; - padding: 0; - font: inherit; - cursor: pointer; -} - -.progress-panel { - width: 80%; - padding: 8px; - border-radius: 6px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - background: #fff; - border: 1px solid var(--color-nav-border, #d1d5db); - display: flex; - justify-content: space-between; - font-size: 14px; - margin-bottom: 4px; - - .mastered-counter, - .completed-counter { - color: #10b981; - width: 150px; - user-select: none; - } - - .completed-counter { - display: flex; - justify-content: flex-start; - } - - .mastered-counter { - display: flex; - justify-content: flex-end; - } - - .progress-value { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - color: #266651; - user-select: none; - font-weight: 600; - } - - .progress-bar-container { - position: relative; - width: 100%; - border: 1px solid var(--color-nav-border, #d1d5db); - background: var(--color-nav, #f3f4f6); - border-radius: 9999px; - overflow: hidden; - } - - .progress-bar-fill { - height: 100%; - background: #10b981; - border-radius: 9999px; - transition: width 0.3s ease; - } - - .progress-text { - font-size: 12px; - margin-top: 4px; - } -} diff --git a/packages/web-component-learningmap-editor/src/index.ts b/packages/web-component-learningmap-editor/src/index.ts deleted file mode 100644 index c4e55941..00000000 --- a/packages/web-component-learningmap-editor/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import r2wc from "@r2wc/react-to-web-component"; -import { HyperbookLearningmapEditor } from "./HyperbookLearningmapEditor"; -import "@xyflow/react/dist/style.css"; -import "./index.css"; - -const LearningmapEditorWC = r2wc(HyperbookLearningmapEditor, { - props: { - roadmapData: "string", - language: "string", - }, - events: { - change: true, - }, -}); - -customElements.define("hyperbook-learningmap-editor", LearningmapEditorWC); diff --git a/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx b/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx deleted file mode 100644 index c7bbf411..00000000 --- a/packages/web-component-learningmap-editor/src/nodes/ImageNode.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Node, NodeResizer } from "@xyflow/react"; -import { ImageNodeData } from "../types"; - -export const ImageNode = ({ data, selected }: Node) => { - return ( - <> - {data.data ? ( - <> - - - - - > - ) : ( - No Image - )} - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx deleted file mode 100644 index 1960629a..00000000 --- a/packages/web-component-learningmap-editor/src/nodes/TaskNode.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; -import { NodeData } from "../types"; -import { CircleCheck } from "lucide-react"; - -export const TaskNode = ({ data, selected, isConnectable, ...props }: Node) => { - return ( - <> - {isConnectable && } - - - - {data.label || "Untitled"} - - - {data.summary && ( - - {data.summary} - - )} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx deleted file mode 100644 index 0eb899c8..00000000 --- a/packages/web-component-learningmap-editor/src/nodes/TextNode.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Node } from "@xyflow/react"; -import { TextNodeData } from "../types"; - -export const TextNode = ({ data }: Node) => { - return ( - <> - - {data.text || "No Text"} - - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx b/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx deleted file mode 100644 index 0590043a..00000000 --- a/packages/web-component-learningmap-editor/src/nodes/TopicNode.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Handle, Node, NodeResizer, Position } from "@xyflow/react"; -import { NodeData } from "../types"; -import StarCircle from "../icons/StarCircle"; - -export const TopicNode = ({ data, selected, isConnectable }: Node) => { - return ( - <> - {isConnectable && } - {data.state === "mastered" && } - - - {data.label || "Untitled"} - - - {data.summary && ( - - {data.summary} - - )} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - - {["Bottom", "Top", "Left", "Right"].map((pos) => ( - - ))} - > - ); -}; diff --git a/packages/web-component-learningmap-editor/src/types.ts b/packages/web-component-learningmap-editor/src/types.ts deleted file mode 100644 index d4d2365b..00000000 --- a/packages/web-component-learningmap-editor/src/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Node, Edge, Box } from "@xyflow/react"; - -// ============================================================================ -// TYPES & INTERFACES -// ============================================================================ - -export interface UnlockCondition { - after?: string[]; - date?: string; - password?: string; -} - -export interface CompletionNeed { - id: string; - source?: string; - target?: string; -} - -export interface Completion { - needs?: CompletionNeed[]; - optional?: CompletionNeed[]; -} - -export interface NodeData { - state: "locked" | "unlocked" | "started" | "completed" | "mastered"; - label: string; - description?: string; - duration?: string; - unlock?: UnlockCondition; - completion?: Completion; - video?: string; - resources?: { label: string; url: string }[]; - summary?: string; - [key: string]: any; -} - -export interface ImageNodeData { - data?: string; // base64 encoded image -} - -export interface TextNodeData { - text?: string; - fontSize?: number; - color?: string; - rotation?: number; -} - -export type BackgroundNodeData = ImageNodeData | TextNodeData; - -export interface BackgroundConfig { - color?: string; - nodes?: Node[]; -} - -export interface Settings { - title?: string; - background?: BackgroundConfig; -} - -export interface EdgeConfig { - animated?: boolean; - color?: string; - width?: number; - type?: string; -} - -export interface RoadmapData { - nodes?: Node[]; - edges?: Edge[]; - settings: Settings; - version: number; -} - -export type Orientation = "horizontal" | "vertical"; - -export type HelperLine = { - // Used to filter out helper lines corresponding to the node being dragged - node: Node; - // We use it to check that the helper line is within the viewport. - nodeBox: Box; - // 0 for horizontal, 1 for vertical - orientation: Orientation; - // If orientation is 'horizontal', `position` holds the Y coordinate of the helper line. - // (Might correspond to the top or bottom position of a node, or other anchors). - // If orientation is 'vertical', `position` holds the X coordinate of the helper line. - position: number; - // Optional color for the helper line - color?: string; - anchorName: string; -}; diff --git a/packages/web-component-learningmap-editor/src/useUndoable/errors.ts b/packages/web-component-learningmap-editor/src/useUndoable/errors.ts deleted file mode 100644 index 6cb01026..00000000 --- a/packages/web-component-learningmap-editor/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/web-component-learningmap-editor/src/useUndoable/index.ts b/packages/web-component-learningmap-editor/src/useUndoable/index.ts deleted file mode 100644 index 228a67b1..00000000 --- a/packages/web-component-learningmap-editor/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/web-component-learningmap-editor/src/useUndoable/mutate.ts b/packages/web-component-learningmap-editor/src/useUndoable/mutate.ts deleted file mode 100644 index b5b9b857..00000000 --- a/packages/web-component-learningmap-editor/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/web-component-learningmap-editor/src/useUndoable/reducer.ts b/packages/web-component-learningmap-editor/src/useUndoable/reducer.ts deleted file mode 100644 index 8bfeed0c..00000000 --- a/packages/web-component-learningmap-editor/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 =
No data yet. Click Save in the editor.
{text}
- {unlockInfo.hint} -