fix: resolve infinite render loop when clicking Next in tutorial player

Added isInitializing flag to prevent onValueChange callback feedback loop
during step transitions. When a new step initializes, AbacusReact would
call onValueChange with the startValue, causing TutorialPlayer to set the
same value again, creating an infinite loop.

Also removed problematic auto-click play function from EditingMode story
that was interfering with component state.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-20 17:52:18 -05:00
parent 7122ad7fb4
commit 4ef6ac5f16
2 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
import type { Meta, StoryObj } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { TutorialEditor } from './TutorialEditor'
import { DevAccessProvider } from '../../hooks/useAccessControl'
import { getTutorialForEditor } from '../../utils/tutorialConverter'
import { TutorialValidation } from '../../types/tutorial'
const meta: Meta<typeof TutorialEditor> = {
title: 'Tutorial/TutorialEditor',
component: TutorialEditor,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: `
The TutorialEditor component provides a comprehensive editing interface for creating and modifying tutorial content.
It includes both the editor interface and an integrated preview player for testing changes.
## Features
- Visual step editor with form-based editing
- Real-time validation and error reporting
- Integrated tutorial player for preview
- Step management (add, duplicate, delete, reorder)
- Tutorial metadata editing
- Save/load functionality hooks
- Access control integration
## Editor Capabilities
- Edit tutorial metadata (title, description, category, difficulty, tags)
- Manage tutorial steps with detailed form controls
- Set start/target values and highlight configurations
- Edit tooltips, error messages, and multi-step instructions
- Validate tutorial structure and content
- Preview changes in real-time
`
}
}
},
decorators: [
(Story) => (
<DevAccessProvider>
<div style={{ height: '100vh' }}>
<Story />
</div>
</DevAccessProvider>
)
],
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
const mockTutorial = getTutorialForEditor()
// Mock validation function that returns realistic validation results
const mockValidate = async (tutorial: any): Promise<TutorialValidation> => {
const errors = []
const warnings = []
// Simulate some validation logic
if (!tutorial.title.trim()) {
errors.push({
stepId: '',
field: 'title',
message: 'Tutorial title is required',
severity: 'error' as const
})
}
if (tutorial.steps.length === 0) {
errors.push({
stepId: '',
field: 'steps',
message: 'Tutorial must have at least one step',
severity: 'error' as const
})
}
// Add some warnings for demonstration
if (tutorial.description.length < 50) {
warnings.push({
stepId: '',
field: 'description',
message: 'Tutorial description could be more detailed',
severity: 'warning' as const
})
}
tutorial.steps.forEach((step: any, index: number) => {
if (step.startValue === step.targetValue) {
warnings.push({
stepId: step.id,
field: 'values',
message: `Step ${index + 1}: Start and target values are the same`,
severity: 'warning' as const
})
}
})
return {
isValid: errors.length === 0,
errors,
warnings
}
}
export const Default: Story = {
args: {
tutorial: mockTutorial,
onSave: action('save-tutorial'),
onValidate: mockValidate,
onPreview: action('preview-step')
},
parameters: {
docs: {
description: {
story: 'Default tutorial editor with the guided addition tutorial loaded for editing.'
}
}
}
}
export const EditingMode: Story = {
args: {
...Default.args
},
parameters: {
docs: {
description: {
story: `
Tutorial editor in editing mode. This story demonstrates:
**Editor Features:**
- Click "Edit Tutorial" to enable editing mode
- Modify tutorial metadata in the left sidebar
- Click on steps to expand detailed editing forms
- Add, duplicate, or delete steps using step controls
- Use "Preview" buttons to test steps in the integrated player
- Real-time validation shows errors and warnings
**Try These Actions:**
1. Click "Edit Tutorial" to enable editing
2. Modify the tutorial title or description
3. Click on a step to expand its editing form
4. Change step values and see validation updates
5. Add a new step using the "+ Add Step" button
6. Preview specific steps using the "Preview" buttons
`
}
}
},
}
export const WithValidationErrors: Story = {
args: {
tutorial: {
...mockTutorial,
title: '', // This will trigger a validation error
description: 'Short desc', // This will trigger a warning
steps: mockTutorial.steps.map(step => ({
...step,
startValue: step.targetValue // This will trigger warnings
}))
},
onSave: action('save-tutorial'),
onValidate: mockValidate,
onPreview: action('preview-step')
},
parameters: {
docs: {
description: {
story: 'Tutorial editor with validation errors and warnings to demonstrate the validation system.'
}
}
}
}
export const MinimalTutorial: Story = {
args: {
tutorial: {
...mockTutorial,
steps: mockTutorial.steps.slice(0, 2) // Only 2 steps for easier editing demo
},
onSave: action('save-tutorial'),
onValidate: mockValidate,
onPreview: action('preview-step')
},
parameters: {
docs: {
description: {
story: 'Tutorial editor with a minimal tutorial (2 steps) for easier demonstration of editing features.'
}
}
}
}
export const ReadOnlyPreview: Story = {
args: {
tutorial: mockTutorial,
onSave: undefined, // No save function = read-only mode
onValidate: mockValidate,
onPreview: action('preview-step')
},
parameters: {
docs: {
description: {
story: 'Tutorial editor in read-only mode (no save function provided) showing the preview functionality.'
}
}
}
}
export const CustomTutorial: Story = {
args: {
tutorial: {
id: 'custom-tutorial',
title: 'Custom Math Tutorial',
description: 'A custom tutorial for demonstrating the editor capabilities with different content.',
category: 'Advanced Operations',
difficulty: 'intermediate' as const,
estimatedDuration: 15,
steps: [
{
id: 'custom-1',
title: 'Custom Step 1',
problem: '5 + 5',
description: 'Add 5 to 5 using the heaven bead',
startValue: 5,
targetValue: 10,
highlightBeads: [{ columnIndex: 0, beadType: 'heaven' as const }],
expectedAction: 'add' as const,
actionDescription: 'Click the heaven bead',
tooltip: {
content: 'Using heaven bead for 10',
explanation: 'When adding 5 to 5, use the tens place heaven bead'
},
errorMessages: {
wrongBead: 'Click the tens place heaven bead',
wrongAction: 'Move the bead down to activate',
hint: 'Think about place value'
}
},
{
id: 'custom-2',
title: 'Custom Step 2',
problem: '7 + 8',
description: 'A more complex addition problem',
startValue: 7,
targetValue: 15,
highlightBeads: [
{ columnIndex: 1, beadType: 'heaven' as const },
{ columnIndex: 0, beadType: 'heaven' as const }
],
expectedAction: 'multi-step' as const,
actionDescription: 'Activate both heaven beads for 15',
multiStepInstructions: [
'Click the tens place heaven bead',
'Click the ones place heaven bead'
],
tooltip: {
content: 'Complex addition',
explanation: '7 + 8 = 15, which needs both tens and ones heaven beads'
},
errorMessages: {
wrongBead: 'Follow the two-step process',
wrongAction: 'Activate both heaven beads',
hint: '15 = 10 + 5'
}
}
],
tags: ['custom', 'demo', 'advanced'],
author: 'Demo Author',
version: '1.0.0',
createdAt: new Date(),
updatedAt: new Date(),
isPublished: false
},
onSave: action('save-tutorial'),
onValidate: mockValidate,
onPreview: action('preview-step')
},
parameters: {
docs: {
description: {
story: 'Tutorial editor with custom tutorial content to demonstrate editing different types of mathematical operations.'
}
}
}
}

View File

@@ -0,0 +1,568 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { AbacusReact } from '@soroban/abacus-react'
import { css } from '../../styled-system/css'
import { stack, hstack, vstack } from '../../styled-system/patterns'
import { Tutorial, TutorialStep, TutorialEvent, NavigationState, UIState } from '../../types/tutorial'
interface TutorialPlayerProps {
tutorial: Tutorial
initialStepIndex?: number
isDebugMode?: boolean
showDebugPanel?: boolean
onStepChange?: (stepIndex: number, step: TutorialStep) => void
onStepComplete?: (stepIndex: number, step: TutorialStep, success: boolean) => void
onTutorialComplete?: (score: number, timeSpent: number) => void
onEvent?: (event: TutorialEvent) => void
className?: string
}
export function TutorialPlayer({
tutorial,
initialStepIndex = 0,
isDebugMode = false,
showDebugPanel = false,
onStepChange,
onStepComplete,
onTutorialComplete,
onEvent,
className
}: TutorialPlayerProps) {
const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex)
const [currentValue, setCurrentValue] = useState(0)
const [isStepCompleted, setIsStepCompleted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isInitializing, setIsInitializing] = useState(false)
const [events, setEvents] = useState<TutorialEvent[]>([])
const [startTime] = useState(Date.now())
const [stepStartTime, setStepStartTime] = useState(Date.now())
const [uiState, setUIState] = useState<UIState>({
isPlaying: true,
isPaused: false,
isEditing: false,
showDebugPanel,
showStepList: false,
autoAdvance: false,
playbackSpeed: 1
})
const currentStep = tutorial.steps[currentStepIndex]
const beadRefs = useRef<Map<string, SVGElement>>(new Map())
// Navigation state
const navigationState: NavigationState = {
currentStepIndex,
canGoNext: currentStepIndex < tutorial.steps.length - 1,
canGoPrevious: currentStepIndex > 0,
totalSteps: tutorial.steps.length,
completionPercentage: (currentStepIndex / tutorial.steps.length) * 100
}
// Event logging
const logEvent = useCallback((event: TutorialEvent) => {
setEvents(prev => [...prev, event])
onEvent?.(event)
}, [onEvent])
// Initialize step
useEffect(() => {
if (currentStep) {
setIsInitializing(true)
setCurrentValue(currentStep.startValue)
setIsStepCompleted(false)
setError(null)
setStepStartTime(Date.now())
logEvent({
type: 'STEP_STARTED',
stepId: currentStep.id,
timestamp: new Date()
})
onStepChange?.(currentStepIndex, currentStep)
// Clear initialization flag after a brief delay
setTimeout(() => setIsInitializing(false), 50)
}
}, [currentStepIndex, currentStep, onStepChange, logEvent])
// Check if step is completed
useEffect(() => {
if (currentStep && currentValue === currentStep.targetValue && !isStepCompleted) {
setIsStepCompleted(true)
setError(null)
logEvent({
type: 'STEP_COMPLETED',
stepId: currentStep.id,
success: true,
timestamp: new Date()
})
onStepComplete?.(currentStepIndex, currentStep, true)
// Auto-advance if enabled
if (uiState.autoAdvance && navigationState.canGoNext) {
setTimeout(() => goToNextStep(), 1500)
}
}
}, [currentValue, currentStep, isStepCompleted, uiState.autoAdvance, navigationState.canGoNext, logEvent, onStepComplete, currentStepIndex])
// Navigation functions
const goToStep = useCallback((stepIndex: number) => {
if (stepIndex >= 0 && stepIndex < tutorial.steps.length) {
setCurrentStepIndex(stepIndex)
}
}, [tutorial.steps.length])
const goToNextStep = useCallback(() => {
if (navigationState.canGoNext) {
goToStep(currentStepIndex + 1)
} else if (currentStepIndex === tutorial.steps.length - 1) {
// Tutorial completed
const timeSpent = (Date.now() - startTime) / 1000
const score = events.filter(e => e.type === 'STEP_COMPLETED' && e.success).length / tutorial.steps.length * 100
logEvent({
type: 'TUTORIAL_COMPLETED',
tutorialId: tutorial.id,
score,
timestamp: new Date()
})
onTutorialComplete?.(score, timeSpent)
}
}, [navigationState.canGoNext, currentStepIndex, tutorial.steps.length, tutorial.id, startTime, events, logEvent, onTutorialComplete, goToStep])
const goToPreviousStep = useCallback(() => {
if (navigationState.canGoPrevious) {
goToStep(currentStepIndex - 1)
}
}, [navigationState.canGoPrevious, currentStepIndex, goToStep])
// Abacus event handlers
const handleValueChange = useCallback((newValue: number) => {
// Ignore value changes during step initialization to prevent loops
if (isInitializing) {
return
}
const oldValue = currentValue
setCurrentValue(newValue)
logEvent({
type: 'VALUE_CHANGED',
stepId: currentStep.id,
oldValue,
newValue,
timestamp: new Date()
})
}, [currentValue, currentStep, logEvent, isInitializing])
const handleBeadClick = useCallback((beadInfo: any) => {
logEvent({
type: 'BEAD_CLICKED',
stepId: currentStep.id,
beadInfo,
timestamp: new Date()
})
// Check if this is the correct action
if (currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads)) {
const isCorrectBead = currentStep.highlightBeads.some(highlight =>
highlight.columnIndex === beadInfo.columnIndex &&
highlight.beadType === beadInfo.beadType &&
(highlight.position === undefined || highlight.position === beadInfo.position)
)
if (!isCorrectBead) {
setError(currentStep.errorMessages.wrongBead)
logEvent({
type: 'ERROR_OCCURRED',
stepId: currentStep.id,
error: currentStep.errorMessages.wrongBead,
timestamp: new Date()
})
} else {
setError(null)
}
}
}, [currentStep, logEvent])
const handleBeadRef = useCallback((bead: any, element: SVGElement | null) => {
const key = `${bead.columnIndex}-${bead.type}-${bead.position}`
if (element) {
beadRefs.current.set(key, element)
} else {
beadRefs.current.delete(key)
}
}, [])
// UI state updaters
const toggleDebugPanel = useCallback(() => {
setUIState(prev => ({ ...prev, showDebugPanel: !prev.showDebugPanel }))
}, [])
const toggleStepList = useCallback(() => {
setUIState(prev => ({ ...prev, showStepList: !prev.showStepList }))
}, [])
const toggleAutoAdvance = useCallback(() => {
setUIState(prev => ({ ...prev, autoAdvance: !prev.autoAdvance }))
}, [])
if (!currentStep) {
return <div>No steps available</div>
}
return (
<div className={`${css({
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '600px'
})} ${className || ''}`}>
{/* Header */}
<div className={css({
borderBottom: '1px solid',
borderColor: 'gray.200',
p: 4,
bg: 'white'
})}>
<div className={hstack({ justifyContent: 'space-between', alignItems: 'center' })}>
<div>
<h1 className={css({ fontSize: 'xl', fontWeight: 'bold' })}>
{tutorial.title}
</h1>
<p className={css({ fontSize: 'sm', color: 'gray.600' })}>
Step {currentStepIndex + 1} of {tutorial.steps.length}: {currentStep.title}
</p>
</div>
<div className={hstack({ gap: 2 })}>
{isDebugMode && (
<>
<button
onClick={toggleDebugPanel}
className={css({
px: 3,
py: 1,
fontSize: 'sm',
border: '1px solid',
borderColor: 'blue.300',
borderRadius: 'md',
bg: uiState.showDebugPanel ? 'blue.100' : 'white',
color: 'blue.700',
cursor: 'pointer',
_hover: { bg: 'blue.50' }
})}
>
Debug
</button>
<button
onClick={toggleStepList}
className={css({
px: 3,
py: 1,
fontSize: 'sm',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
bg: uiState.showStepList ? 'gray.100' : 'white',
cursor: 'pointer',
_hover: { bg: 'gray.50' }
})}
>
Steps
</button>
<label className={hstack({ gap: 2, fontSize: 'sm' })}>
<input
type="checkbox"
checked={uiState.autoAdvance}
onChange={toggleAutoAdvance}
/>
Auto-advance
</label>
</>
)}
</div>
</div>
{/* Progress bar */}
<div className={css({ mt: 2, bg: 'gray.200', borderRadius: 'full', h: 2 })}>
<div
className={css({
bg: 'blue.500',
h: 'full',
borderRadius: 'full',
transition: 'width 0.3s ease'
})}
style={{ width: `${navigationState.completionPercentage}%` }}
/>
</div>
</div>
<div className={hstack({ flex: 1, gap: 0 })}>
{/* Step list sidebar */}
{uiState.showStepList && (
<div className={css({
w: '300px',
borderRight: '1px solid',
borderColor: 'gray.200',
bg: 'gray.50',
p: 4,
overflowY: 'auto'
})}>
<h3 className={css({ fontWeight: 'bold', mb: 3 })}>Tutorial Steps</h3>
<div className={stack({ gap: 2 })}>
{tutorial.steps && Array.isArray(tutorial.steps) ? tutorial.steps.map((step, index) => (
<button
key={step.id}
onClick={() => goToStep(index)}
className={css({
p: 3,
textAlign: 'left',
border: '1px solid',
borderColor: index === currentStepIndex ? 'blue.300' : 'gray.200',
borderRadius: 'md',
bg: index === currentStepIndex ? 'blue.50' : 'white',
cursor: 'pointer',
_hover: { bg: index === currentStepIndex ? 'blue.100' : 'gray.50' }
})}
>
<div className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
{index + 1}. {step.title}
</div>
<div className={css({ fontSize: 'xs', color: 'gray.600', mt: 1 })}>
{step.problem}
</div>
</button>
)) : (
<div className={css({ color: 'gray.500', textAlign: 'center', py: 4 })}>
No tutorial steps available
</div>
)}
</div>
</div>
)}
{/* Main content */}
<div className={css({ flex: 1, display: 'flex', flexDirection: 'column' })}>
{/* Step content */}
<div className={css({ flex: 1, p: 6 })}>
<div className={vstack({ gap: 6, alignItems: 'center' })}>
{/* Step instructions */}
<div className={css({ textAlign: 'center', maxW: '600px' })}>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: 2 })}>
{currentStep.problem}
</h2>
<p className={css({ fontSize: 'lg', color: 'gray.700', mb: 4 })}>
{currentStep.description}
</p>
<p className={css({ fontSize: 'md', color: 'blue.600' })}>
{currentStep.actionDescription}
</p>
</div>
{/* Error message */}
{error && (
<div className={css({
p: 4,
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
borderRadius: 'md',
color: 'red.700',
maxW: '600px'
})}>
{error}
</div>
)}
{/* Success message */}
{isStepCompleted && (
<div className={css({
p: 4,
bg: 'green.50',
border: '1px solid',
borderColor: 'green.200',
borderRadius: 'md',
color: 'green.700',
maxW: '600px'
})}>
Great! You completed this step correctly.
</div>
)}
{/* Abacus */}
<div className={css({
bg: 'white',
border: '2px solid',
borderColor: 'gray.200',
borderRadius: 'lg',
p: 6,
shadow: 'lg'
})}>
<AbacusReact
value={currentValue}
columns={5}
interactive={true}
animated={true}
scaleFactor={2.5}
colorScheme="place-value"
highlightBeads={currentStep.highlightBeads}
customStyles={currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads) ? {
beads: currentStep.highlightBeads.reduce((acc, highlight) => ({
...acc,
[highlight.columnIndex]: {
[highlight.beadType]: highlight.beadType === 'earth' && highlight.position !== undefined
? { [highlight.position]: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 } }
: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 }
}
}), {})
} : undefined}
onValueChange={handleValueChange}
callbacks={{
onBeadClick: handleBeadClick,
onBeadRef: handleBeadRef
}}
/>
</div>
{/* Tooltip */}
{currentStep.tooltip && (
<div className={css({
maxW: '500px',
p: 4,
bg: 'yellow.50',
border: '1px solid',
borderColor: 'yellow.200',
borderRadius: 'md'
})}>
<h4 className={css({ fontWeight: 'bold', color: 'yellow.800', mb: 1 })}>
{currentStep.tooltip.content}
</h4>
<p className={css({ fontSize: 'sm', color: 'yellow.700' })}>
{currentStep.tooltip.explanation}
</p>
</div>
)}
</div>
</div>
{/* Navigation controls */}
<div className={css({
borderTop: '1px solid',
borderColor: 'gray.200',
p: 4,
bg: 'gray.50'
})}>
<div className={hstack({ justifyContent: 'space-between' })}>
<button
onClick={goToPreviousStep}
disabled={!navigationState.canGoPrevious}
className={css({
px: 4,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
bg: 'white',
cursor: navigationState.canGoPrevious ? 'pointer' : 'not-allowed',
opacity: navigationState.canGoPrevious ? 1 : 0.5,
_hover: navigationState.canGoPrevious ? { bg: 'gray.50' } : {}
})}
>
Previous
</button>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
Step {currentStepIndex + 1} of {navigationState.totalSteps}
</div>
<button
onClick={goToNextStep}
disabled={!navigationState.canGoNext && !isStepCompleted}
className={css({
px: 4,
py: 2,
border: '1px solid',
borderColor: navigationState.canGoNext || isStepCompleted ? 'blue.300' : 'gray.300',
borderRadius: 'md',
bg: navigationState.canGoNext || isStepCompleted ? 'blue.500' : 'gray.200',
color: navigationState.canGoNext || isStepCompleted ? 'white' : 'gray.500',
cursor: navigationState.canGoNext || isStepCompleted ? 'pointer' : 'not-allowed',
_hover: navigationState.canGoNext || isStepCompleted ? { bg: 'blue.600' } : {}
})}
>
{navigationState.canGoNext ? 'Next →' : 'Complete Tutorial'}
</button>
</div>
</div>
</div>
{/* Debug panel */}
{uiState.showDebugPanel && (
<div className={css({
w: '400px',
borderLeft: '1px solid',
borderColor: 'gray.200',
bg: 'gray.50',
p: 4,
overflowY: 'auto'
})}>
<h3 className={css({ fontWeight: 'bold', mb: 3 })}>Debug Panel</h3>
<div className={stack({ gap: 4 })}>
{/* Current state */}
<div>
<h4 className={css({ fontWeight: 'medium', mb: 2 })}>Current State</h4>
<div className={css({ fontSize: 'sm', fontFamily: 'mono', bg: 'white', p: 2, borderRadius: 'md' })}>
<div>Step: {currentStepIndex + 1}/{navigationState.totalSteps}</div>
<div>Value: {currentValue}</div>
<div>Target: {currentStep.targetValue}</div>
<div>Completed: {isStepCompleted ? 'Yes' : 'No'}</div>
<div>Time: {Math.round((Date.now() - stepStartTime) / 1000)}s</div>
</div>
</div>
{/* Event log */}
<div>
<h4 className={css({ fontWeight: 'medium', mb: 2 })}>Event Log</h4>
<div className={css({
maxH: '300px',
overflowY: 'auto',
fontSize: 'xs',
fontFamily: 'mono',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: 'md'
})}>
{events.slice(-20).reverse().map((event, index) => (
<div key={index} className={css({ p: 2, borderBottom: '1px solid', borderColor: 'gray.100' })}>
<div className={css({ fontWeight: 'bold', color: 'blue.600' })}>
{event.type}
</div>
<div className={css({ color: 'gray.600' })}>
{event.timestamp.toLocaleTimeString()}
</div>
{event.type === 'VALUE_CHANGED' && (
<div>{event.oldValue} {event.newValue}</div>
)}
{event.type === 'ERROR_OCCURRED' && (
<div className={css({ color: 'red.600' })}>{event.error}</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}