1- import React , { useReducer , useRef , useEffect } from "react"
1+ import React , { useReducer , useRef , useEffect , useMemo , useState } from "react"
22import type { BaseSolver } from "../BaseSolver"
33import { SolverBreadcrumbInputDownloader } from "./SolverBreadcrumbInputDownloader"
44
5+ type RendererOption = "vector" | "canvas"
6+
57export interface GenericSolverToolbarProps {
68 solver : BaseSolver
79 triggerRender : ( ) => void
810 animationSpeed ?: number
11+ renderer : RendererOption
12+ onRendererChange : ( renderer : RendererOption ) => void
13+ onAnimationSpeedChange : ( speed : number ) => void
14+ onDownloadVisualization : ( ) => void
915 onSolverStarted ?: ( solver : BaseSolver ) => void
1016 onSolverCompleted ?: ( solver : BaseSolver ) => void
1117}
@@ -14,13 +20,57 @@ export const GenericSolverToolbar = ({
1420 solver,
1521 triggerRender,
1622 animationSpeed = 25 ,
23+ renderer,
24+ onRendererChange,
25+ onAnimationSpeedChange,
26+ onDownloadVisualization,
1727 onSolverStarted,
1828 onSolverCompleted,
1929} : GenericSolverToolbarProps ) => {
2030 const [ isAnimating , setIsAnimating ] = useReducer ( ( x ) => ! x , false )
2131 const animationRef = useRef < NodeJS . Timeout | undefined > ( undefined )
2232 const lastIterationInputRef = useRef < string | null > ( null )
2333 const lastIterationStorageKey = "solver-debugger-last-iteration"
34+ const [ openMenu , setOpenMenu ] = useState <
35+ "renderer" | "debug" | "animation" | null
36+ > ( null )
37+ const menuContainerRef = useRef < HTMLDivElement | null > ( null )
38+
39+ const animationSpeedOptions = useMemo (
40+ ( ) => [
41+ { label : "Slow" , value : 250 } ,
42+ { label : "Normal" , value : 100 } ,
43+ { label : "Fast" , value : 25 } ,
44+ { label : "Very Fast" , value : 10 } ,
45+ ] ,
46+ [ ] ,
47+ )
48+
49+ const startAnimation = ( ) => {
50+ animationRef . current = setInterval ( ( ) => {
51+ if ( solver . solved || solver . failed ) {
52+ if ( animationRef . current ) {
53+ clearInterval ( animationRef . current )
54+ animationRef . current = undefined
55+ }
56+ setIsAnimating ( )
57+ triggerRender ( )
58+ if ( onSolverCompleted && solver . solved ) {
59+ onSolverCompleted ( solver )
60+ }
61+ return
62+ }
63+ solver . step ( )
64+ triggerRender ( )
65+ } , animationSpeed )
66+ }
67+
68+ const stopAnimation = ( ) => {
69+ if ( animationRef . current ) {
70+ clearInterval ( animationRef . current )
71+ animationRef . current = undefined
72+ }
73+ }
2474
2575 const handleStep = ( ) => {
2676 if ( ! solver . solved && ! solver . failed ) {
@@ -44,29 +94,11 @@ export const GenericSolverToolbar = ({
4494
4595 const handleAnimate = ( ) => {
4696 if ( isAnimating ) {
47- if ( animationRef . current ) {
48- clearInterval ( animationRef . current )
49- animationRef . current = undefined
50- }
97+ stopAnimation ( )
5198 setIsAnimating ( )
5299 } else {
53100 setIsAnimating ( )
54- animationRef . current = setInterval ( ( ) => {
55- if ( solver . solved || solver . failed ) {
56- if ( animationRef . current ) {
57- clearInterval ( animationRef . current )
58- animationRef . current = undefined
59- }
60- setIsAnimating ( )
61- triggerRender ( )
62- if ( onSolverCompleted && solver . solved ) {
63- onSolverCompleted ( solver )
64- }
65- return
66- }
67- solver . step ( )
68- triggerRender ( )
69- } , animationSpeed )
101+ startAnimation ( )
70102 }
71103 }
72104
@@ -119,26 +151,143 @@ export const GenericSolverToolbar = ({
119151 // Cleanup animation on unmount or solver completion
120152 useEffect ( ( ) => {
121153 return ( ) => {
122- if ( animationRef . current ) {
123- clearInterval ( animationRef . current )
124- }
154+ stopAnimation ( )
125155 }
126156 } , [ ] )
127157
128158 useEffect ( ( ) => {
129159 if ( ( solver . solved || solver . failed ) && isAnimating ) {
130- if ( animationRef . current ) {
131- clearInterval ( animationRef . current )
132- animationRef . current = undefined
133- }
160+ stopAnimation ( )
134161 setIsAnimating ( )
135162 }
136163 } , [ solver . solved , solver . failed , isAnimating ] )
137164
165+ useEffect ( ( ) => {
166+ if ( isAnimating ) {
167+ stopAnimation ( )
168+ startAnimation ( )
169+ }
170+ } , [ animationSpeed , isAnimating ] )
171+
172+ useEffect ( ( ) => {
173+ if ( typeof document === "undefined" ) return
174+ const handleClickOutside = ( event : MouseEvent ) => {
175+ if (
176+ menuContainerRef . current &&
177+ ! menuContainerRef . current . contains ( event . target as Node )
178+ ) {
179+ setOpenMenu ( null )
180+ }
181+ }
182+ document . addEventListener ( "mousedown" , handleClickOutside )
183+ return ( ) => {
184+ document . removeEventListener ( "mousedown" , handleClickOutside )
185+ }
186+ } , [ ] )
187+
138188 return (
139189 < div className = "space-y-2 p-2 border-b" >
140190 < div className = "flex items-center" >
141- < SolverBreadcrumbInputDownloader solver = { solver } />
191+ < div className = "flex items-center gap-2" ref = { menuContainerRef } >
192+ < div className = "flex h-9 items-center space-x-1 rounded-md border border-slate-200 bg-white p-1 shadow-sm" >
193+ < div className = "relative" >
194+ < button
195+ type = "button"
196+ onClick = { ( ) =>
197+ setOpenMenu ( openMenu === "renderer" ? null : "renderer" )
198+ }
199+ className = "flex select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100"
200+ data-state = { openMenu === "renderer" ? "open" : "closed" }
201+ >
202+ Renderer
203+ </ button >
204+ { openMenu === "renderer" && (
205+ < div className = "absolute left-0 z-50 mt-1 min-w-[10rem] rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md" >
206+ { ( [ "vector" , "canvas" ] as RendererOption [ ] ) . map ( ( option ) => (
207+ < button
208+ type = "button"
209+ key = { option }
210+ onClick = { ( ) => {
211+ onRendererChange ( option )
212+ setOpenMenu ( null )
213+ } }
214+ className = "flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-slate-100"
215+ >
216+ { option === "vector" ? "Vector" : "Canvas" }
217+ { renderer === option && (
218+ < span className = "ml-auto text-xs text-slate-500" >
219+ ✓
220+ </ span >
221+ ) }
222+ </ button >
223+ ) ) }
224+ </ div >
225+ ) }
226+ </ div >
227+ < div className = "relative" >
228+ < button
229+ type = "button"
230+ onClick = { ( ) =>
231+ setOpenMenu ( openMenu === "debug" ? null : "debug" )
232+ }
233+ className = "flex select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100"
234+ data-state = { openMenu === "debug" ? "open" : "closed" }
235+ >
236+ Debug
237+ </ button >
238+ { openMenu === "debug" && (
239+ < div className = "absolute left-0 z-50 mt-1 min-w-[12rem] rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md" >
240+ < button
241+ type = "button"
242+ onClick = { ( ) => {
243+ onDownloadVisualization ( )
244+ setOpenMenu ( null )
245+ } }
246+ className = "flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-slate-100"
247+ >
248+ Download Visualization
249+ </ button >
250+ </ div >
251+ ) }
252+ </ div >
253+ < div className = "relative" >
254+ < button
255+ type = "button"
256+ onClick = { ( ) =>
257+ setOpenMenu ( openMenu === "animation" ? null : "animation" )
258+ }
259+ className = "flex select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100"
260+ data-state = { openMenu === "animation" ? "open" : "closed" }
261+ >
262+ Animation
263+ </ button >
264+ { openMenu === "animation" && (
265+ < div className = "absolute left-0 z-50 mt-1 min-w-[12rem] rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md" >
266+ { animationSpeedOptions . map ( ( option ) => (
267+ < button
268+ type = "button"
269+ key = { option . value }
270+ onClick = { ( ) => {
271+ onAnimationSpeedChange ( option . value )
272+ setOpenMenu ( null )
273+ } }
274+ className = "flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-slate-100"
275+ >
276+ { option . label }
277+ < span className = "ml-auto text-xs text-slate-500" >
278+ { option . value } ms
279+ </ span >
280+ { animationSpeed === option . value && (
281+ < span className = "ml-2 text-xs text-slate-500" > ✓</ span >
282+ ) }
283+ </ button >
284+ ) ) }
285+ </ div >
286+ ) }
287+ </ div >
288+ </ div >
289+ < SolverBreadcrumbInputDownloader solver = { solver } />
290+ </ div >
142291 </ div >
143292 < div className = "flex gap-2 items-center flex-wrap" >
144293 < button
0 commit comments