Skip to content

Commit 1e2a0b2

Browse files
author
minhnq
committed
feat: Implement nested repeat animation groups in the state editor with hierarchical item management.
1 parent c7b37c7 commit 1e2a0b2

4 files changed

Lines changed: 405 additions & 143 deletions

File tree

src/app/aneko-builder/components/StateEditorModal.tsx

Lines changed: 197 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState, useCallback } from 'react';
44
import { useSkinBuilder } from '@/lib/contexts/SkinBuilderContext';
55
import { MotionState, AnimationItem, Asset } from '@/lib/types/skin';
6-
import { TrashIcon, PlusIcon } from '@/components/Icons';
6+
import { TrashIcon, PlusIcon, RefreshIcon } from '@/components/Icons';
77
import styles from '../page.module.css';
88

99
interface 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(/\.(png|jpg|jpeg|gif)$/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

Comments
 (0)