diff --git a/.changeset/lemon-geckos-run.md b/.changeset/lemon-geckos-run.md new file mode 100644 index 00000000..a5a1c62d --- /dev/null +++ b/.changeset/lemon-geckos-run.md @@ -0,0 +1,10 @@ +--- +"@hyperbook/web-component-learningmap": minor +"@hyperbook/markdown": minor +"@hyperbook/types": minor +"@hyperbook/fs": minor +"hyperbook": minor +"hyperbook-studio": minor +--- + +Vastly improved learningmap element diff --git a/packages/fs/src/hyperbook.ts b/packages/fs/src/hyperbook.ts index 80fcc717..fa1eec60 100644 --- a/packages/fs/src/hyperbook.ts +++ b/packages/fs/src/hyperbook.ts @@ -177,6 +177,7 @@ export const getPagesAndSections = async ( } const page: HyperbookPage = { ...data, + path: file.path, }; const repo = makeRepoLink(hyperbook.repo, file); if (repo) { diff --git a/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap b/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap index 8072bccd..f0633651 100644 --- a/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap +++ b/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap @@ -5,16 +5,37 @@ exports[`hyperbook > should get navigation 1`] = ` "current": { "href": "/paradigms", "name": "Paradigms", + "path": { + "absolute": "single-hyperbook/book/paradigms.md", + "directory": "", + "href": "/paradigms", + "permalink": null, + "relative": "paradigms.md", + }, "repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/paradigms.md", }, "next": { "href": "/hyperbook-test", "name": "hyperbook-test", + "path": { + "absolute": "single-hyperbook/book/hyperbook-test.md", + "directory": "", + "href": "/hyperbook-test", + "permalink": null, + "relative": "hyperbook-test.md", + }, "repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/hyperbook-test.md", }, "previous": { "href": "/", "name": "Home", + "path": { + "absolute": "single-hyperbook/book/index.md", + "directory": "", + "href": "/", + "permalink": null, + "relative": "index.md", + }, "repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/index.md", }, } diff --git a/packages/fs/tests/hyperbook.test.ts b/packages/fs/tests/hyperbook.test.ts index 784b5fa9..dfea4cbb 100644 --- a/packages/fs/tests/hyperbook.test.ts +++ b/packages/fs/tests/hyperbook.test.ts @@ -1,8 +1,17 @@ import path from "path"; import { describe, it, expect } from "vitest"; import { hyperbook, vfile } from "../src"; +import { HyperbookPage } from "@hyperbook/types/dist"; describe("hyperbook", () => { + const relative = (s: string) => + path.relative(path.join(__dirname, "fixtures"), s); + const makeFileRelative = (p: HyperbookPage) => { + return { + ...p, + path: { ...p.path, absolute: relative(p.path?.absolute || "") }, + } as HyperbookPage; + }; it("should get navigation", async () => { let hyperbookPath = path.join(__dirname, "fixtures", "single-hyperbook"); let files = await vfile.list(hyperbookPath); @@ -17,7 +26,10 @@ describe("hyperbook", () => { pagesAndSections.sections, pagesAndSections.pages, ); - const navigation = await hyperbook.getNavigationForFile(pageList, current); + const navigation = await hyperbook.getNavigationForFile( + pageList.map(makeFileRelative), + current, + ); expect(navigation).toMatchSnapshot(); }); }); diff --git a/packages/markdown/assets/directive-learningmap/client.js b/packages/markdown/assets/directive-learningmap/client.js index 8821f74d..fff82dd3 100644 --- a/packages/markdown/assets/directive-learningmap/client.js +++ b/packages/markdown/assets/directive-learningmap/client.js @@ -6,29 +6,14 @@ hyperbook.learningmap = (function () { const map = elem.getElementsByTagName("hyperbook-learningmap")[0]; if (map) { const result = await store.learningmap.get(elem.id); - if (result && result.nodeState) { - map.nodeState = result.nodeState; - map.x = result.x || 0; - map.y = result.y || 0; - map.zoom = result.zoom || 1; + if (result) { + map.initialState = result; } map.addEventListener("change", function (event) { store.learningmap .update(elem.id, { - nodeState: event.detail, - }) - .then((updated) => { - if (updated == 0) { - store.learningmap.put({ - id: elem.id, - nodeState: event.detail, - }); - } - }); - }); - map.addEventListener("viewport-change", function (event) { - store.learningmap - .update(elem.id, { + id: elem.id, + nodes: event.detail.nodes, x: event.detail.x, y: event.detail.y, zoom: event.detail.zoom, @@ -37,6 +22,7 @@ hyperbook.learningmap = (function () { if (updated == 0) { store.learningmap.put({ id: elem.id, + nodes: event.detail.nodes, x: event.detail.x, y: event.detail.y, zoom: event.detail.zoom, diff --git a/packages/markdown/assets/store.js b/packages/markdown/assets/store.js index bc719a8c..9e7b60f3 100644 --- a/packages/markdown/assets/store.js +++ b/packages/markdown/assets/store.js @@ -26,7 +26,7 @@ store.version(1).stores({ webide: `id,html,css,js`, h5p: `id,userData`, geogebra: `id,state`, - learningmap: `id,nodeState,x,y,zoom`, + learningmap: `id,nodes,x,y,zoom`, }); var sqlIdeDB = new Dexie("SQL-IDE"); sqlIdeDB.version(0.1).stores({ diff --git a/packages/markdown/src/helper.ts b/packages/markdown/src/helper.ts new file mode 100644 index 00000000..ef78e170 --- /dev/null +++ b/packages/markdown/src/helper.ts @@ -0,0 +1,25 @@ +import { HyperbookContext } from "@hyperbook/types"; +import path from "path"; +import fs from "fs"; + +export const readFile = (src: string, ctx: HyperbookContext) => { + let srcFile = null; + try { + srcFile = fs.readFileSync(path.join(ctx.root, "public", src), "utf-8"); + } catch (e) { + try { + srcFile = fs.readFileSync(path.join(ctx.root, "book", src), "utf-8"); + } catch (e) { + srcFile = fs.readFileSync( + path.join( + ctx.root, + "book", + ctx.navigation.current?.path?.directory || "", + src, + ), + "utf-8", + ); + } + } + return srcFile; +}; diff --git a/packages/markdown/src/rehypeDirectiveP5.ts b/packages/markdown/src/rehypeDirectiveP5.ts index 4d31ef0d..21ab71d3 100644 --- a/packages/markdown/src/rehypeDirectiveP5.ts +++ b/packages/markdown/src/rehypeDirectiveP5.ts @@ -3,7 +3,6 @@ // import { HyperbookContext } from "@hyperbook/types"; import { Root } from "mdast"; -import fs from "fs"; import path from "path"; import { visit } from "unist-util-visit"; import { VFile } from "vfile"; @@ -16,6 +15,7 @@ import { import { toText } from "./mdastUtilToText"; import hash from "./objectHash"; import { i18n } from "./i18n"; +import { readFile } from "./helper"; interface CodeBundle { js?: string; @@ -94,10 +94,7 @@ ${(code.scripts ? [cdnLibraryUrl, ...code.scripts] : []).map((src) => ` + + + 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 && } + 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..59423151 --- /dev/null +++ b/packages/web-component-learningmap/src/Drawer.tsx @@ -0,0 +1,147 @@ +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"; +import { getTranslations } from "./translations"; + +interface DrawerProps { + open: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + node: Node; + nodes: Node[]; + onNodeClick: (_: any, node: Node, focus: boolean) => void; + language?: string; +} + +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, language = "en" }: DrawerProps) { + const t = getTranslations(language); + + 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..00a5a75f --- /dev/null +++ b/packages/web-component-learningmap/src/EdgeDrawer.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { X, Trash2, Save } from "lucide-react"; +import { Edge } from "@xyflow/react"; +import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent"; +import { getTranslations } from "./translations"; + +interface EdgeDrawerProps { + edge: Edge | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (edge: Edge) => void; + onDelete: () => void; + language?: string; +} + +export const EdgeDrawer: React.FC = ({ + edge: selectedEdge, + isOpen: edgeDrawerOpen, + onClose: closeDrawer, + onUpdate: updateEdge, + onDelete: deleteEdge, + language = "en", +}) => { + const t = getTranslations(language); + + if (!selectedEdge || !edgeDrawerOpen) return null; + return ( +
+
+
+
+

{t.editEdge}

+ +
+ { + 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); + }} + language={language} + /> +
+ + +
+
+
+ ); +}; diff --git a/packages/web-component-learningmap/src/EditorDrawer.tsx b/packages/web-component-learningmap/src/EditorDrawer.tsx new file mode 100644 index 00000000..fcf0df5f --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawer.tsx @@ -0,0 +1,249 @@ +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"; +import { getTranslations } from "./translations"; + +interface EditorDrawerProps { + node: Node | null; + isOpen: boolean; + onClose: () => void; + onUpdate: (node: Node) => void; + onDelete: () => void; + language?: string; +} + +export const EditorDrawer: React.FC = ({ + node, + isOpen, + onClose, + onUpdate, + onDelete, + language = "en", +}) => { + const t = getTranslations(language); + 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) => ( + + ); + + // 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 = ( + <> +
+

{t.editTask}

+ +
+ + + ); + } else if (localNode.type === "topic") { + content = ( + <> +
+

{t.editTopic}

+ +
+ + + ); + } else if (localNode.type === "image") { + content = ( + <> +
+

{t.editImage}

+ +
+ + + ); + } else if (localNode.type === "text") { + content = ( + <> +
+

{t.editText}

+ +
+ + + ); + } + + return ( + <> +
+
+ {content} +
+ + +
+
+ + ); +}; diff --git a/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx new file mode 100644 index 00000000..30423100 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx @@ -0,0 +1,52 @@ +import { ColorSelector } from "./ColorSelector"; +import { Edge } from "@xyflow/react"; +import { getTranslations } from "./translations"; + +interface Props { + localEdge: Edge; + handleFieldChange: (field: string, value: any) => void; + language?: string; +} + +export function EditorDrawerEdgeContent({ + localEdge, + handleFieldChange, + language = "en", +}: Props) { + const t = getTranslations(language); + + return ( +
+
+ handleFieldChange("color", color)} + /> +
+
+ +
+
+ + +
+
+ ); +} diff --git a/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx new file mode 100644 index 00000000..7717b333 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx @@ -0,0 +1,47 @@ +import { Node } from "@xyflow/react"; +import { ImageNodeData } from "./types"; +import { getTranslations } from "./translations"; + +interface Props { + localNode: Node; + handleFieldChange: (field: string, value: any) => void; + language?: string; +} + +export function EditorDrawerImageContent({ localNode, handleFieldChange, language = "en" }: Props) { + const t = getTranslations(language); + + // 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 ( +
+
+ + +
+ {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..66e48083 --- /dev/null +++ b/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx @@ -0,0 +1,220 @@ +import { Node } from "@xyflow/react"; +import { Plus, Trash2 } from "lucide-react"; +import { NodeData } from "./types"; +import { getTranslations } from "./translations"; + +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; + language?: string; +} + +export function EditorDrawerTaskContent({ + localNode, + handleFieldChange, + handleResourceChange, + addResource, + removeResource, + handleUnlockAfterChange, + addUnlockAfter, + removeUnlockAfter, + renderNodeSelect, + handleCompletionNeedsChange, + addCompletionNeed, + removeCompletionNeed, + handleCompletionOptionalChange, + addCompletionOptional, + removeCompletionOptional, + language = "en", +}: Props) { + const t = getTranslations(language); + + // Color options for the dropdown + const colorOptions = [ + { value: "blue", label: t.blue, className: "react-flow__node-topic blue" }, + { value: "yellow", label: t.yellow, className: "react-flow__node-topic yellow" }, + { value: "lila", label: t.lila, className: "react-flow__node-topic lila" }, + { value: "pink", label: t.pink, className: "react-flow__node-topic pink" }, + { value: "teal", label: t.teal, className: "react-flow__node-topic teal" }, + { value: "red", label: t.red, className: "react-flow__node-topic red" }, + { value: "black", label: t.black, className: "react-flow__node-topic black" }, + { value: "white", label: t.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 ( +
+
+ +
+ {colorOptions.map(opt => ( + + ))} +
+
+
+ + handleFieldChange("label", e.target.value)} + placeholder={t.placeholderNodeLabel} + /> +
+
+ + handleFieldChange("summary", e.target.value)} + placeholder={t.placeholderShortSummary} + /> +
+
+ +