@@ -17,6 +17,26 @@ const CODE_QUERY_KEY = 'c'
1717// Note: 'default' theme is used internally for SSR/hydration plain text styling and doesn't appear in this list
1818const SYNTAX_THEMES = [ 'nord' , 'vscode' , 'solarized' , 'minimal' , 'monokai' , 'base16' ]
1919
20+ // Theme color representatives (2 colors per theme)
21+ const THEME_COLORS : Record < string , [ string , string ] > = {
22+ nord : [ '#fabd2f' , '#fb4934' ] , // Gruvbox: yellow and red
23+ vscode : [ '#569cd6' , '#ce9178' ] , // VS Code: blue and orange
24+ solarized : [ '#c678dd' , '#98c379' ] , // One Dark: purple and green
25+ minimal : [ '#b0b0b0' , '#808080' ] , // Minimal: light gray and medium gray
26+ monokai : [ '#f92672' , '#a6e22e' ] , // Monokai: pink and green
27+ base16 : [ '#bb9af7' , '#9ece6a' ] , // Tokyo Night: purple and green
28+ }
29+
30+ function ThemeColorBlocks ( { theme } : { theme : string } ) {
31+ const colors = THEME_COLORS [ theme ] || [ '#000000' , '#000000' ]
32+ return (
33+ < span className = "flex items-center justify-center gap-1" >
34+ < span className = "w-3 h-3 rounded-sm" style = { { backgroundColor : colors [ 0 ] } } />
35+ < span className = "w-3 h-3 rounded-sm" style = { { backgroundColor : colors [ 1 ] } } />
36+ </ span >
37+ )
38+ }
39+
2040function ControlButton ( {
2141 id,
2242 checked,
@@ -33,7 +53,7 @@ function ControlButton({
3353 return (
3454 < button className = "control-button" >
3555 < input id = { id } type = "checkbox" checked = { checked } onChange = { ( event ) => onChange ?.( event . target . checked ) } />
36- < label className = "controls-manager-label" data-checked = { checked } htmlFor = { id } >
56+ < label className = "controls-manager-label whitespace-nowrap " data-checked = { checked } htmlFor = { id } >
3757 < span className = "mr-2" > { prefix ? prefix : checked ? '●' : '○' } </ span >
3858 { ` ${ propName } ` }
3959 </ label >
@@ -424,85 +444,99 @@ function DropdownMenu({
424444} & React . HTMLAttributes < HTMLDivElement > ) {
425445 const nodeRef = useRef < HTMLDivElement > ( null )
426446 const [ isOpen , setIsOpen ] = useState ( false )
447+ const openTimeRef = useRef ( 0 )
427448
428449 // Close the dropdown when clicking outside
429450 useEffect ( ( ) => {
430- const handleClickOutside = ( event : MouseEvent ) => {
451+ const handlePointerDownOutside = ( event : PointerEvent ) => {
452+ // Prevent closing immediately after opening (mobile touch issue)
453+ if ( Date . now ( ) - openTimeRef . current < 100 ) {
454+ return
455+ }
456+
431457 const dropdown = nodeRef . current
432458 if ( dropdown && ! dropdown . contains ( event . target as Node ) ) {
433459 setIsOpen ( false )
434460 }
435461 }
436- document . addEventListener ( 'mousedown ' , handleClickOutside )
462+ document . addEventListener ( 'pointerdown ' , handlePointerDownOutside )
437463 return ( ) => {
438- document . removeEventListener ( 'mousedown ' , handleClickOutside )
464+ document . removeEventListener ( 'pointerdown ' , handlePointerDownOutside )
439465 }
440466 } , [ ] )
441467
468+ const arrowRef = useRef < SVGSVGElement > ( null )
469+
470+ const handlePointerDown = ( e : React . PointerEvent < HTMLButtonElement > ) => {
471+ const arrowElement = arrowRef . current
472+ if ( ! arrowElement ) {
473+ const newState = ! isOpen
474+ setIsOpen ( newState )
475+ if ( newState ) {
476+ openTimeRef . current = Date . now ( )
477+ }
478+ return
479+ }
480+
481+ const arrowRect = arrowElement . getBoundingClientRect ( )
482+ const arrowLeft = arrowRect . left
483+ const clientX = e . clientX
484+
485+ // Check if pointer is on the arrow (right side) - use a wider touch target
486+ const touchPadding = e . pointerType === 'touch' ? 12 : 8 // Extra padding for touch
487+ if ( clientX >= arrowLeft - touchPadding ) {
488+ // Pointer on arrow area - toggle dropdown
489+ e . preventDefault ( )
490+ const newState = ! isOpen
491+ setIsOpen ( newState )
492+ if ( newState ) {
493+ openTimeRef . current = Date . now ( )
494+ }
495+ } else if ( onNext ) {
496+ // Pointer on left side (not arrow) - switch theme
497+ e . preventDefault ( )
498+ setIsOpen ( false )
499+ onNext ( )
500+ }
501+ }
502+
442503 return (
443504 < div { ...props } ref = { nodeRef } className = { cx ( 'dropdown-menu relative' , props . className ) } >
444- < span className = { cx ( 'dropdown-menu-button-group' , ! ! onNext && 'dropdown-menu-button-group--merged' ) } >
445- < button ref = { buttonRef } className = "dropdown-menu-button flex-1" onClick = { ( ) => setIsOpen ( ! isOpen ) } >
446- < span className = "flex items-center gap-1" >
447- { buttonText }
448- { /* dropdown indicator */ }
449- < svg
450- width = "14"
451- height = "14"
452- xmlns = "http://www.w3.org/2000/svg"
453- viewBox = "0 0 24 24"
454- fill = "none"
455- stroke = "currentColor"
456- strokeWidth = "2"
457- className = { `ml-auto transition-transform duration-200 ease-out ${ isOpen ? 'rotate-180' : '' } ` }
458- >
459- < path d = "M6 9l6 6 6-6" strokeLinecap = "round" strokeLinejoin = "round" />
460- </ svg >
461- </ span >
462- </ button >
463- { ! ! onNext && (
464- < >
465- { /* divider */ }
466- < span className = "dropdown-menu-divider" />
467- { /* A button to go to the next */ }
468-
469- < button
470- className = "dropdown-menu-button--next"
471- onClick = { ( ) => {
472- setIsOpen ( false )
473- onNext ( )
474- } }
475- title = "Next theme"
476- >
477- { /* next/forward arrow icon */ }
478- < svg
479- width = "12"
480- height = "12"
481- xmlns = "http://www.w3.org/2000/svg"
482- viewBox = "0 0 24 24"
483- fill = "none"
484- stroke = "currentColor"
485- strokeWidth = "2"
486- className = "dropdown-menu-next-icon"
487- >
488- < path d = "M9 18l6-6-6-6" strokeLinecap = "round" strokeLinejoin = "round" />
489- </ svg >
490- </ button >
491- </ >
492- ) }
493- </ span >
505+ < button
506+ ref = { buttonRef }
507+ className = "dropdown-menu-button relative w-full"
508+ onPointerDown = { handlePointerDown }
509+ >
510+ < span className = "flex items-center gap-1 min-w-0 w-full relative" >
511+ < span className = "flex-1 flex items-center justify-center" > { buttonText } </ span >
512+ { /* dropdown indicator */ }
513+ < svg
514+ ref = { arrowRef }
515+ width = "14"
516+ height = "14"
517+ xmlns = "http://www.w3.org/2000/svg"
518+ viewBox = "0 0 24 24"
519+ fill = "none"
520+ stroke = "currentColor"
521+ strokeWidth = "2"
522+ className = { `flex-shrink-0 transition-transform duration-200 ease-out pointer-events-none ${ isOpen ? 'rotate-180' : '' } ` }
523+ >
524+ < path d = "M6 9l6 6 6-6" strokeLinecap = "round" strokeLinejoin = "round" />
525+ </ svg >
526+ </ span >
527+ </ button >
494528 { isOpen && (
495529 < ul className = "dropdown-menu-list absolute top-full left-0 w-full min-w-max" >
496530 { items . map ( ( item , index ) => (
497531 < li
498532 key = { index }
499- className = "dropdown-menu-list-item"
533+ className = "dropdown-menu-list-item flex items-center gap-2 "
500534 onClick = { ( ) => {
501535 setIsOpen ( false )
502536 onChange ( item . text )
503537 } }
504538 >
505- { item . text }
539+ < ThemeColorBlocks theme = { item . text } />
506540 </ li >
507541 ) ) }
508542 </ ul >
@@ -560,90 +594,77 @@ export function LiveEditor({
560594 return (
561595 < div >
562596 < div className = "editor-layout" >
563- < div className = "controls flex flex-row md:flex-nowrap mt-8 md:mt-8 mb-4 md:mb-4 items-start md:items-center justify-start md:justify-center" >
597+ < div className = "controls flex flex-row md:flex-nowrap mt-8 md:mt-8 mb-4 md:mb-4 items-start md:items-center justify-start md:justify-center w-full " >
564598 { /* Left controls */ }
565- < div className = "inline-flex flex-col gap-y-2 flex-wrap p-4 rounded-lg bg-[var(--app-editor-bg-color)] controls-left-panel" >
566- < div className = "controls-manager" >
567- < ControlButton id = "control-control" checked = { controls } onChange = { setControls } text = "controls" />
568- < ControlButton
569- id = "control-line-numbers"
570- checked = { lineNumbers }
571- onChange = { setLineNumbers }
572- text = "line no."
573- />
574- { /* control of theme: light/dark */ }
575- < ControlButton
576- id = "control-theme"
577- checked = { theme === 'dark' }
578- onChange = { ( checked ) => setTheme ( checked ? 'dark' : 'light' ) }
579- text = { theme === 'dark' ? 'dark' : 'light' }
580- prefix = { < span > { theme === 'dark' ? '🌙' : '☀️' } </ span > }
581- />
582- </ div >
583- { /* row */ }
584- < div className = "flex flex-wrap gap-2" >
585- < RangeSelector
586- text = "indent"
587- className = "range-control"
588- value = { lineNumbersWidth }
589- min = { 2 }
590- max = { 3 }
591- step = { 0.1 }
592- onChange = { setLineNumbersWidth }
593- displayValue = { false }
594- />
595- { /* selector highlight styling theme */ }
596- < DropdownMenu
597- className = "dropdown-menu-highlight w-36"
598- buttonRef = { themeButtonRef }
599- buttonText = {
600- < >
601- < span className = "mr-2" > 🎨</ span >
602- < span > { highlightTheme } </ span >
603- </ >
599+ < div className = "grid grid-cols-2 md:grid-cols-3 gap-2 p-4 rounded-lg bg-[var(--app-editor-bg-color)] controls-left-panel flex-1" >
600+ < ControlButton id = "control-control" checked = { controls } onChange = { setControls } text = "controls" />
601+ < ControlButton
602+ id = "control-line-numbers"
603+ checked = { lineNumbers }
604+ onChange = { setLineNumbers }
605+ text = "line no."
606+ />
607+ < ControlButton
608+ id = "control-theme"
609+ checked = { theme === 'dark' }
610+ onChange = { ( checked ) => setTheme ( checked ? 'dark' : 'light' ) }
611+ text = { theme === 'dark' ? 'dark' : 'light' }
612+ prefix = { < span > { theme === 'dark' ? '🌙' : '☀️' } </ span > }
613+ />
614+ < RangeSelector
615+ text = "indent"
616+ className = "range-control"
617+ value = { lineNumbersWidth }
618+ min = { 2 }
619+ max = { 3 }
620+ step = { 0.1 }
621+ onChange = { setLineNumbersWidth }
622+ displayValue = { false }
623+ />
624+ < DropdownMenu
625+ className = "dropdown-menu-highlight"
626+ buttonRef = { themeButtonRef }
627+ buttonText = { < ThemeColorBlocks theme = { highlightTheme } /> }
628+ items = { SYNTAX_THEMES . map ( ( theme ) => ( { text : theme } ) ) }
629+ onChange = { ( text ) => {
630+ setHighlightTheme ( text )
631+ } }
632+ onNext = { ( ) => {
633+ const nextIndex = SYNTAX_THEMES . indexOf ( highlightTheme ) + 1
634+ const nextTheme = SYNTAX_THEMES [ nextIndex % SYNTAX_THEMES . length ]
635+ setHighlightTheme ( nextTheme )
636+ } }
637+ />
638+ < ControlButton
639+ id = "control-format"
640+ checked
641+ onChange = { async ( ) => {
642+ setFormat ( ! _f )
643+ setIsFormatting ( true )
644+ try {
645+ const [ prettierPluginBabel , prettierPluginEstree ] = await Promise . all ( [
646+ import ( 'prettier/plugins/babel' ) ,
647+ import ( 'prettier/plugins/estree' ) ,
648+ ] )
649+ const formattedCode = await prettierFormat ( code , {
650+ parser : 'babel' ,
651+ plugins : [ prettierPluginBabel . default , prettierPluginEstree . default ] ,
652+ printWidth : 120 ,
653+ singleQuote : true ,
654+ trailingComma : 'es5' ,
655+ semi : false ,
656+ tabWidth : 2 ,
657+ } )
658+ setCode ( formattedCode )
659+ } catch ( error ) {
660+ console . error ( 'Formatting error:' , error )
661+ } finally {
662+ setTimeout ( ( ) => setIsFormatting ( false ) , 600 )
604663 }
605- items = { SYNTAX_THEMES . map ( ( theme ) => ( { text : theme } ) ) }
606- onChange = { ( text ) => {
607- setHighlightTheme ( text )
608- } }
609- onNext = { ( ) => {
610- const nextIndex = SYNTAX_THEMES . indexOf ( highlightTheme ) + 1
611- const nextTheme = SYNTAX_THEMES [ nextIndex % SYNTAX_THEMES . length ]
612- setHighlightTheme ( nextTheme )
613- } }
614- />
615-
616- < ControlButton
617- id = "control-format"
618- checked
619- onChange = { async ( ) => {
620- setFormat ( ! _f )
621- setIsFormatting ( true )
622- try {
623- const [ prettierPluginBabel , prettierPluginEstree ] = await Promise . all ( [
624- import ( 'prettier/plugins/babel' ) ,
625- import ( 'prettier/plugins/estree' ) ,
626- ] )
627- const formattedCode = await prettierFormat ( code , {
628- parser : 'babel' ,
629- plugins : [ prettierPluginBabel . default , prettierPluginEstree . default ] ,
630- printWidth : 120 ,
631- singleQuote : true ,
632- trailingComma : 'es5' ,
633- semi : false ,
634- tabWidth : 2 ,
635- } )
636- setCode ( formattedCode )
637- } catch ( error ) {
638- console . error ( 'Formatting error:' , error )
639- } finally {
640- setTimeout ( ( ) => setIsFormatting ( false ) , 600 )
641- }
642- } }
643- text = "format"
644- prefix = { < FormatIcon width = { 14 } height = { 14 } stroke = "currentColor" strokeWidth = "2" className = { isFormatting ? 'mop-animate' : '' } /> }
645- />
646- </ div >
664+ } }
665+ text = "format"
666+ prefix = { < FormatIcon width = { 14 } height = { 14 } stroke = "currentColor" strokeWidth = "2" className = { isFormatting ? 'mop-animate' : '' } /> }
667+ />
647668 </ div >
648669 { /* Right side screenshot button */ }
649670 < div className = "inline-flex items-center self-stretch gap-2 flex-shrink-0" >
0 commit comments