33import { useState , useCallback } from 'react' ;
44import { useSkinBuilder } from '@/lib/contexts/SkinBuilderContext' ;
55import { MotionState , AnimationItem , Asset } from '@/lib/types/skin' ;
6- import { TrashIcon , PlusIcon } from '@/components/Icons' ;
6+ import { TrashIcon , PlusIcon , RefreshIcon } from '@/components/Icons' ;
77import styles from '../page.module.css' ;
88
99interface StateEditorModalProps {
@@ -19,49 +19,208 @@ export default function StateEditorModal({ stateId, onClose }: StateEditorModalP
1919 currentState || { state : stateId , items : [ ] }
2020 ) ;
2121 const [ showAssetPicker , setShowAssetPicker ] = useState ( false ) ;
22- const [ editingFrameIndex , setEditingFrameIndex ] = useState < number | null > ( null ) ;
22+ const [ targetRepeatPath , setTargetRepeatPath ] = useState < number [ ] | null > ( null ) ; // null = add to root, array = path to repeat-item
2323
2424 const handleAddFrame = useCallback ( ( asset : Asset ) => {
2525 const newItem : AnimationItem = {
2626 type : 'item' ,
2727 drawable : asset . filename . replace ( / \. ( p n g | j p g | j p e g | g i f ) $ / i, '' ) ,
2828 duration : 250 ,
2929 } ;
30+
31+ if ( targetRepeatPath === null ) {
32+ // Add to root
33+ setEditedState ( prev => ( {
34+ ...prev ,
35+ items : [ ...prev . items , newItem ] ,
36+ } ) ) ;
37+ } else {
38+ // Add to specific repeat-item
39+ setEditedState ( prev => ( {
40+ ...prev ,
41+ items : addItemToPath ( prev . items , targetRepeatPath , newItem ) ,
42+ } ) ) ;
43+ }
44+ setShowAssetPicker ( false ) ;
45+ setTargetRepeatPath ( null ) ;
46+ } , [ targetRepeatPath ] ) ;
47+
48+ // Helper to add item to a repeat-item at given path
49+ const addItemToPath = ( items : AnimationItem [ ] , path : number [ ] , newItem : AnimationItem ) : AnimationItem [ ] => {
50+ if ( path . length === 0 ) return [ ...items , newItem ] ;
51+ const [ first , ...rest ] = path ;
52+ return items . map ( ( item , i ) => {
53+ if ( i !== first ) return item ;
54+ if ( rest . length === 0 && item . type === 'repeat-item' ) {
55+ return { ...item , items : [ ...( item . items || [ ] ) , newItem ] } ;
56+ }
57+ if ( item . type === 'repeat-item' && item . items ) {
58+ return { ...item , items : addItemToPath ( item . items , rest , newItem ) } ;
59+ }
60+ return item ;
61+ } ) ;
62+ } ;
63+
64+ // Helper to update nested items by path
65+ const updateItemAtPath = ( items : AnimationItem [ ] , path : number [ ] , updater : ( item : AnimationItem ) => AnimationItem | null ) : AnimationItem [ ] => {
66+ if ( path . length === 0 ) return items ;
67+ const [ first , ...rest ] = path ;
68+ return items . map ( ( item , i ) => {
69+ if ( i !== first ) return item ;
70+ if ( rest . length === 0 ) {
71+ const result = updater ( item ) ;
72+ return result ; // may be null for removal
73+ }
74+ if ( item . type === 'repeat-item' && item . items ) {
75+ return { ...item , items : updateItemAtPath ( item . items , rest , updater ) } ;
76+ }
77+ return item ;
78+ } ) . filter ( ( item ) : item is AnimationItem => item !== null ) ;
79+ } ;
80+
81+ const handleRemoveItem = useCallback ( ( path : number [ ] ) => {
3082 setEditedState ( prev => ( {
3183 ...prev ,
32- items : [ ... prev . items , newItem ] ,
84+ items : updateItemAtPath ( prev . items , path , ( ) => null ) ,
3385 } ) ) ;
34- setShowAssetPicker ( false ) ;
3586 } , [ ] ) ;
3687
37- const handleRemoveFrame = useCallback ( ( index : number ) => {
88+ const handleUpdateDuration = useCallback ( ( path : number [ ] , duration : number ) => {
3889 setEditedState ( prev => ( {
3990 ...prev ,
40- items : prev . items . filter ( ( _ , i ) => i !== index ) ,
91+ items : updateItemAtPath ( prev . items , path , ( item ) => ( { ... item , duration } ) ) ,
4192 } ) ) ;
4293 } , [ ] ) ;
4394
44- const handleUpdateDuration = useCallback ( ( index : number , duration : number ) => {
95+ const handleUpdateRepeatCount = useCallback ( ( path : number [ ] , repeatCount : number ) => {
4596 setEditedState ( prev => ( {
4697 ...prev ,
47- items : prev . items . map ( ( item , i ) =>
48- i === index ? { ...item , duration } : item
49- ) ,
98+ items : updateItemAtPath ( prev . items , path , ( item ) => ( { ...item , repeatCount } ) ) ,
5099 } ) ) ;
51100 } , [ ] ) ;
52101
102+ const handleAddRepeatItem = useCallback ( ( targetPath ?: number [ ] ) => {
103+ const newRepeat : AnimationItem = {
104+ type : 'repeat-item' ,
105+ repeatCount : 2 ,
106+ items : [ ] ,
107+ } ;
108+
109+ if ( targetPath === undefined ) {
110+ // Add to root
111+ setEditedState ( prev => ( {
112+ ...prev ,
113+ items : [ ...prev . items , newRepeat ] ,
114+ } ) ) ;
115+ } else {
116+ // Add to specific repeat-item
117+ setEditedState ( prev => ( {
118+ ...prev ,
119+ items : addItemToPath ( prev . items , targetPath , newRepeat ) ,
120+ } ) ) ;
121+ }
122+ } , [ ] ) ;
123+
53124 const handleSave = useCallback ( ( ) => {
54125 updateState ( editedState ) ;
55126 onClose ( ) ;
56127 } , [ editedState , updateState , onClose ] ) ;
57128
58- const getFlatFrames = ( items : AnimationItem [ ] ) : { item : AnimationItem ; index : number } [ ] => {
59- return items
60- . filter ( item => item . type === 'item' )
61- . map ( ( item , index ) => ( { item, index } ) ) ;
129+ // Count total frames (for display)
130+ const countTotalFrames = ( items : AnimationItem [ ] ) : number => {
131+ let count = 0 ;
132+ for ( const item of items ) {
133+ if ( item . type === 'item' ) {
134+ count ++ ;
135+ } else if ( item . type === 'repeat-item' && item . items ) {
136+ count += countTotalFrames ( item . items ) ;
137+ }
138+ }
139+ return count ;
62140 } ;
63141
64- const flatFrames = getFlatFrames ( editedState . items ) ;
142+ // Render items hierarchically
143+ const renderItems = ( items : AnimationItem [ ] , parentPath : number [ ] = [ ] ) => {
144+ return items . map ( ( item , idx ) => {
145+ const currentPath = [ ...parentPath , idx ] ;
146+
147+ if ( item . type === 'item' ) {
148+ const asset = getAssetByFilename ( item . drawable || '' ) ;
149+ return (
150+ < div key = { currentPath . join ( '-' ) } className = { styles . frameItem } >
151+ { asset ? (
152+ < img src = { asset . dataUrl } alt = { item . drawable } />
153+ ) : (
154+ < div style = { { width : 48 , height : 48 , background : '#ccc' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , fontSize : '0.6rem' } } >
155+ { item . drawable }
156+ </ div >
157+ ) }
158+ < input
159+ type = "number"
160+ value = { item . duration || 250 }
161+ onChange = { ( e ) => handleUpdateDuration ( currentPath , Number ( e . target . value ) ) }
162+ onClick = { ( e ) => e . stopPropagation ( ) }
163+ style = { { width : 50 , padding : '0.2rem' , fontSize : '0.7rem' , textAlign : 'center' , border : '2px solid var(--neo-black)' } }
164+ />
165+ < span className = { styles . frameDuration } > ms</ span >
166+ < button
167+ onClick = { ( ) => handleRemoveItem ( currentPath ) }
168+ style = { { background : 'var(--neo-red)' , color : 'white' , border : 'none' , cursor : 'pointer' , padding : '0.2rem 0.4rem' , fontSize : '0.7rem' } }
169+ >
170+ < TrashIcon size = { 10 } />
171+ </ button >
172+ </ div >
173+ ) ;
174+ } else if ( item . type === 'repeat-item' ) {
175+ return (
176+ < div key = { currentPath . join ( '-' ) } className = { styles . repeatGroup } >
177+ < div className = { styles . repeatHeader } >
178+ < RefreshIcon size = { 14 } />
179+ < span > Repeat</ span >
180+ < input
181+ type = "number"
182+ min = { 1 }
183+ max = { 100 }
184+ value = { item . repeatCount || 1 }
185+ onChange = { ( e ) => handleUpdateRepeatCount ( currentPath , Number ( e . target . value ) ) }
186+ onClick = { ( e ) => e . stopPropagation ( ) }
187+ style = { { width : 40 , padding : '0.2rem' , fontSize : '0.7rem' , textAlign : 'center' , border : '2px solid var(--neo-black)' } }
188+ />
189+ < span style = { { fontSize : '0.7rem' } } > times</ span >
190+ < button
191+ onClick = { ( ) => handleRemoveItem ( currentPath ) }
192+ style = { { background : 'var(--neo-red)' , color : 'white' , border : 'none' , cursor : 'pointer' , padding : '0.2rem 0.4rem' , fontSize : '0.7rem' , marginLeft : 'auto' } }
193+ >
194+ < TrashIcon size = { 10 } />
195+ </ button >
196+ </ div >
197+ < div className = { styles . repeatContent } >
198+ { item . items && renderItems ( item . items , currentPath ) }
199+ < button
200+ className = { styles . addFrameBtnSmall }
201+ onClick = { ( ) => {
202+ setTargetRepeatPath ( currentPath ) ;
203+ setShowAssetPicker ( true ) ;
204+ } }
205+ title = "Add frame to this repeat group"
206+ >
207+ < PlusIcon size = { 14 } />
208+ </ button >
209+ < button
210+ className = { styles . addFrameBtnSmall }
211+ onClick = { ( ) => handleAddRepeatItem ( currentPath ) }
212+ title = "Add nested repeat group"
213+ style = { { background : 'var(--neo-purple)' } }
214+ >
215+ < RefreshIcon size = { 14 } />
216+ </ button >
217+ </ div >
218+ </ div >
219+ ) ;
220+ }
221+ return null ;
222+ } ) ;
223+ } ;
65224
66225 return (
67226 < div className = { styles . modalOverlay } onClick = { onClose } >
@@ -106,41 +265,27 @@ export default function StateEditorModal({ stateId, onClose }: StateEditorModalP
106265 </ div >
107266
108267 { /* Frame Timeline */ }
109- < label style = { { fontWeight : 700 , fontSize : '0.8rem' , textTransform : 'uppercase' , display : 'block' , marginBottom : '0.5rem' } } >
110- Frames ({ flatFrames . length } )
111- </ label >
268+ < div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , marginBottom : '0.5rem' } } >
269+ < label style = { { fontWeight : 700 , fontSize : '0.8rem' , textTransform : 'uppercase' } } >
270+ Animation Items ({ countTotalFrames ( editedState . items ) } frames)
271+ </ label >
272+ < button
273+ onClick = { ( ) => handleAddRepeatItem ( ) }
274+ className = { styles . btn }
275+ style = { { padding : '0.25rem 0.5rem' , fontSize : '0.75rem' } }
276+ >
277+ < RefreshIcon size = { 12 } /> Add Repeat Group
278+ </ button >
279+ </ div >
112280 < div className = { styles . frameTimeline } >
113- { flatFrames . map ( ( { item, index } ) => {
114- const asset = getAssetByFilename ( item . drawable || '' ) ;
115- return (
116- < div key = { index } className = { styles . frameItem } >
117- { asset ? (
118- < img src = { asset . dataUrl } alt = { item . drawable } />
119- ) : (
120- < div style = { { width : 48 , height : 48 , background : '#ccc' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , fontSize : '0.6rem' } } >
121- { item . drawable }
122- </ div >
123- ) }
124- < input
125- type = "number"
126- value = { item . duration || 250 }
127- onChange = { ( e ) => handleUpdateDuration ( index , Number ( e . target . value ) ) }
128- onClick = { ( e ) => e . stopPropagation ( ) }
129- style = { { width : 50 , padding : '0.2rem' , fontSize : '0.7rem' , textAlign : 'center' , border : '2px solid var(--neo-black)' } }
130- />
131- < span className = { styles . frameDuration } > ms</ span >
132- < button
133- onClick = { ( ) => handleRemoveFrame ( index ) }
134- style = { { background : 'var(--neo-red)' , color : 'white' , border : 'none' , cursor : 'pointer' , padding : '0.2rem 0.4rem' , fontSize : '0.7rem' } }
135- >
136- < TrashIcon size = { 10 } />
137- </ button >
138- </ div >
139- ) ;
140- } ) }
281+ { renderItems ( editedState . items ) }
141282 < button
142283 className = { styles . addFrameBtn }
143- onClick = { ( ) => setShowAssetPicker ( true ) }
284+ onClick = { ( ) => {
285+ setTargetRepeatPath ( null ) ;
286+ setShowAssetPicker ( true ) ;
287+ } }
288+ title = "Add frame"
144289 >
145290 < PlusIcon size = { 20 } />
146291 </ button >
@@ -156,11 +301,13 @@ export default function StateEditorModal({ stateId, onClose }: StateEditorModalP
156301
157302 { /* Asset Picker Modal */ }
158303 { showAssetPicker && (
159- < div className = { styles . modalOverlay } onClick = { ( ) => setShowAssetPicker ( false ) } style = { { background : 'rgba(0,0,0,0.7)' } } >
304+ < div className = { styles . modalOverlay } onClick = { ( ) => { setShowAssetPicker ( false ) ; setTargetRepeatPath ( null ) ; } } style = { { background : 'rgba(0,0,0,0.7)' } } >
160305 < div className = { styles . modal } onClick = { ( e ) => e . stopPropagation ( ) } style = { { maxWidth : '500px' } } >
161306 < div className = { styles . modalHeader } >
162- < span className = { styles . modalTitle } > Select Sprite</ span >
163- < button className = { styles . modalClose } onClick = { ( ) => setShowAssetPicker ( false ) } > ×</ button >
307+ < span className = { styles . modalTitle } >
308+ Select Sprite { targetRepeatPath !== null && '(for repeat group)' }
309+ </ span >
310+ < button className = { styles . modalClose } onClick = { ( ) => { setShowAssetPicker ( false ) ; setTargetRepeatPath ( null ) ; } } > ×</ button >
164311 </ div >
165312 < div className = { styles . modalContent } >
166313 { state . skinData . assets . length > 0 ? (
0 commit comments