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 Background; +}; + +// ============================================================================ +// 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

+ +
+ +
+
+ + +
+ +
+ + handleFieldChange("label", e.target.value)} + placeholder="Node label" + /> +
+ +
+ + handleFieldChange("summary", e.target.value)} + placeholder="Short summary" + /> +
+ +
+ +