TutorialModal.ts
1 import { Modal, App } from 'obsidian'; 2 import { createRoot, Root } from 'react-dom/client'; 3 import React from 'react'; 4 import { ManimText } from './ManimText'; 5 import { tutorialService, TutorialStep } from './TutorialService'; 6 7 /** 8 * TutorialModal - Native Obsidian modal for tutorial steps 9 * 10 * Uses Obsidian's Modal API with React rendering for ManimText animation 11 */ 12 export class TutorialModal extends Modal { 13 private root: Root | null = null; 14 private currentStep: TutorialStep | null = null; 15 private autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null; 16 17 constructor(app: App) { 18 super(app); 19 } 20 21 onOpen() { 22 const { contentEl } = this; 23 24 // Style the modal 25 this.modalEl.addClass('tutorial-modal'); 26 contentEl.empty(); 27 28 // Create container for React 29 const container = contentEl.createDiv({ cls: 'tutorial-modal-content' }); 30 31 // Get current step 32 this.currentStep = tutorialService.getCurrentStep(); 33 34 if (!this.currentStep) { 35 console.warn('🎓 [TutorialModal] No current step available'); 36 this.close(); 37 return; 38 } 39 40 console.log('🎓 [TutorialModal] Opening with step:', this.currentStep.title); 41 42 // Render React component 43 this.root = createRoot(container); 44 this.renderStep(); 45 46 // Set up auto-advance 47 if (this.currentStep.duration) { 48 this.autoAdvanceTimer = setTimeout(() => { 49 this.nextStep(); 50 }, this.currentStep.duration); 51 } 52 } 53 54 private renderStep() { 55 if (!this.root || !this.currentStep) return; 56 57 const step = this.currentStep; 58 59 this.root.render( 60 React.createElement('div', { className: 'tutorial-step' }, 61 // ManimText animation - key forces remount on text change 62 React.createElement(ManimText, { 63 key: step.title, // Force remount when title changes 64 text: step.title, 65 strokeDuration: 2, 66 fillDelay: 0.3, 67 fadeStroke: true, 68 fontSize: 48 69 }), 70 71 // Description 72 React.createElement('div', { 73 className: 'tutorial-description', 74 style: { 75 marginTop: '2rem', 76 fontSize: '1.2rem', 77 textAlign: 'center', 78 opacity: 0, 79 animation: 'fadeIn 0.5s ease-in 2.5s forwards' 80 } 81 }, step.description), 82 83 // Button container 84 React.createElement('div', { 85 className: 'tutorial-buttons', 86 style: { 87 marginTop: '2rem', 88 display: 'flex', 89 gap: '1rem', 90 justifyContent: 'center' 91 } 92 }, 93 // Skip button 94 React.createElement('button', { 95 className: 'mod-cta', 96 onClick: () => this.skipTutorial() 97 }, 'Skip Tutorial'), 98 99 // Next button (if not auto-advancing) 100 !step.duration && React.createElement('button', { 101 className: 'mod-cta', 102 onClick: () => this.nextStep() 103 }, 'Next') 104 ) 105 ) 106 ); 107 } 108 109 private nextStep() { 110 if (this.autoAdvanceTimer) { 111 globalThis.clearTimeout(this.autoAdvanceTimer); 112 this.autoAdvanceTimer = null; 113 } 114 115 tutorialService.next(); 116 this.currentStep = tutorialService.getCurrentStep(); 117 118 if (this.currentStep) { 119 // Re-render with new step 120 this.renderStep(); 121 122 // Set up auto-advance for new step 123 if (this.currentStep.duration) { 124 this.autoAdvanceTimer = setTimeout(() => { 125 this.nextStep(); 126 }, this.currentStep.duration); 127 } 128 } else { 129 // Tutorial complete 130 this.close(); 131 } 132 } 133 134 private skipTutorial() { 135 if (this.autoAdvanceTimer) { 136 globalThis.clearTimeout(this.autoAdvanceTimer); 137 this.autoAdvanceTimer = null; 138 } 139 140 tutorialService.skip(); 141 this.close(); 142 } 143 144 onClose() { 145 const { contentEl } = this; 146 147 // Clean up auto-advance timer 148 if (this.autoAdvanceTimer) { 149 globalThis.clearTimeout(this.autoAdvanceTimer); 150 this.autoAdvanceTimer = null; 151 } 152 153 // Clean up React 154 if (this.root) { 155 this.root.unmount(); 156 this.root = null; 157 } 158 159 contentEl.empty(); 160 } 161 }