import './EditorRoadmapRenderer.css'; import { lazy, useCallback, useEffect, useRef } from 'react'; import { renderResourceProgress, updateResourceProgress, type ResourceProgressType, renderTopicProgress, refreshProgressCounters, } from '../../lib/resource-progress'; import { pageProgressMessage } from '../../stores/page'; import { useToast } from '../../hooks/use-toast'; import type { Edge, Node } from '@roadmapsh/editor'; import { slugify } from '../../lib/slugger'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; const Renderer = lazy(() => import('@roadmapsh/editor').then((mod) => ({ default: mod.Renderer, })), ); export type RoadmapRendererProps = { resourceId: string; nodes: Node[]; edges: Edge[]; dimensions: { width: number; height: number; }; }; type RoadmapNodeDetails = { nodeId: string; nodeType: string; targetGroup: SVGElement; title?: string; }; function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null { const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; const nodeId = targetGroup?.dataset?.nodeId; const nodeType = targetGroup?.dataset?.type; const title = targetGroup?.dataset?.title; if (!nodeId || !nodeType) { return null; } return { nodeId, nodeType, targetGroup, title }; } const allowedNodeTypes = [ 'topic', 'subtopic', 'button', 'link-item', 'resourceButton', 'todo', 'todo-checkbox', 'checklist-item', ]; export function EditorRoadmapRenderer(props: RoadmapRendererProps) { const { resourceId, nodes = [], edges = [] } = props; const roadmapRef = useRef(null); const toast = useToast(); async function updateTopicStatus( topicId: string, newStatus: ResourceProgressType, ) { pageProgressMessage.set('Updating progress'); updateResourceProgress( { resourceId, resourceType: 'roadmap', topicId, }, newStatus, ) .then(() => { renderTopicProgress(topicId, newStatus); }) .catch((err) => { toast.error('Something went wrong, please try again.'); console.error(err); }) .finally(() => { pageProgressMessage.set(''); refreshProgressCounters(); }); return; } const handleSvgClick = useCallback((e: MouseEvent) => { const target = e.target as SVGElement; const { nodeId, nodeType, targetGroup, title } = getNodeDetails(target) || {}; if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) { return; } if ( nodeType === 'button' || nodeType === 'link-item' || nodeType === 'resourceButton' ) { const link = targetGroup?.dataset?.link || ''; const isExternalLink = link.startsWith('http'); if (isExternalLink) { window.open(link, '_blank'); } else { window.location.href = link; } return; } const isCurrentStatusLearning = targetGroup?.classList.contains('learning'); const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped'); if (nodeType === 'todo-checkbox') { e.preventDefault(); if (!isLoggedIn()) { showLoginPopup(); return; } const newStatus = targetGroup?.classList.contains('done') ? 'pending' : 'done'; updateTopicStatus(nodeId, newStatus); return; } if (e.shiftKey) { e.preventDefault(); if (!isLoggedIn()) { showLoginPopup(); return; } updateTopicStatus( nodeId, isCurrentStatusLearning ? 'pending' : 'learning', ); return; } else if (e.altKey) { e.preventDefault(); if (!isLoggedIn()) { showLoginPopup(); return; } updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped'); return; } // for the click on rect of checklist-item if (nodeType === 'checklist-item' && target.tagName === 'rect') { e.preventDefault(); if (!isLoggedIn()) { showLoginPopup(); return; } const newStatus = targetGroup?.classList.contains('done') ? 'pending' : 'done'; updateTopicStatus(nodeId, newStatus); return; } // we don't have the topic popup for checklist-item if (nodeType === 'checklist-item') { return; } if (!title) { return; } const detailsPattern = `${slugify(title)}@${nodeId}`; window.dispatchEvent( new CustomEvent('roadmap.node.click', { detail: { topicId: detailsPattern, resourceId, resourceType: 'roadmap', }, }), ); }, []); const handleSvgRightClick = useCallback((e: MouseEvent) => { e.preventDefault(); const target = e.target as SVGElement; const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {}; if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) { return; } if (nodeType === 'button') { return; } if (!isLoggedIn()) { showLoginPopup(); return; } const isCurrentStatusDone = targetGroup?.classList.contains('done'); updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done'); }, []); useEffect(() => { if (!roadmapRef?.current) { return; } roadmapRef?.current?.addEventListener('click', handleSvgClick); roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick); return () => { roadmapRef?.current?.removeEventListener('click', handleSvgClick); roadmapRef?.current?.removeEventListener( 'contextmenu', handleSvgRightClick, ); }; }, []); return ( { roadmapRef.current?.setAttribute('data-renderer', 'editor'); renderResourceProgress('roadmap', resourceId).finally(); }} /> ); }