Skip to content

Commit 8e7351a

Browse files
committed
style(editor): match the legacy canvas, timeline and bedrock builder
1 parent cea70dc commit 8e7351a

9 files changed

Lines changed: 511 additions & 310 deletions

File tree

resources/css/editor-extras.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,20 @@
184184
.badge-warn {
185185
color: var(--warning-color);
186186
}
187+
188+
/* MiniMessage is rendered one span per character so a gradient can colour each
189+
letter. A span holding only a space collapses to nothing between its
190+
neighbours, which glues the words together, so every container that shows
191+
rendered text keeps its whitespace. */
192+
.bedrock-form-title,
193+
.bedrock-content-line,
194+
.bedrock-button-text,
195+
.bedrock-component-label,
196+
.menu-title-preview,
197+
.item-preview-name,
198+
.item-preview-lore-line,
199+
.item-tooltip-name,
200+
.item-tooltip-lore-line,
201+
.preview-line {
202+
white-space: pre-wrap;
203+
}

resources/js/components/visual/AnimationEditor.tsx

Lines changed: 174 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -8,176 +8,207 @@ interface AnimationEditorProps {
88
onChange: (menu: VisualJavaMenu) => void;
99
}
1010

11-
/** Ticks per frame, as the plugin counts them. */
11+
/** Ticks per frame, as the plugin counts them. A tick is 50ms. */
1212
const DEFAULT_INTERVAL = 2;
13+
const TICK_MS = 50;
1314

15+
/**
16+
* The animation strip: one card per frame, played back at the configured rate
17+
* so the sequence can be judged without opening the game.
18+
*/
1419
export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEditorProps) {
15-
const entries = Object.entries(menu.animations);
20+
const names = Object.keys(menu.animations);
21+
const [selected, setSelected] = useState<string | null>(names[0] ?? null);
22+
const [playing, setPlaying] = useState(false);
23+
const [frameIndex, setFrameIndex] = useState(0);
1624

17-
const update = (key: string, animation: VisualAnimation): void =>
18-
onChange({ ...menu, animations: { ...menu.animations, [key]: animation } });
25+
const active = selected === null ? undefined : menu.animations[selected];
26+
const frames = active === undefined ? [] : Object.entries(active.frames);
1927

20-
return (
21-
<section className="item-editor-section">
22-
<header className="section-heading">
23-
<h3 className="section-heading">Animations</h3>
24-
<button
25-
type="button"
26-
onClick={() =>
27-
onChange({
28-
...menu,
29-
animations: {
30-
...menu.animations,
31-
[nextKey(menu.animations)]: { interval: DEFAULT_INTERVAL, frames: {} },
32-
},
33-
})
34-
}
35-
className="btn btn-secondary btn-sm"
36-
>
37-
Add animation
38-
</button>
39-
</header>
40-
41-
{entries.length === 0 && <p className="empty-note">This menu has no animated items.</p>}
42-
43-
{entries.map(([key, animation]) => (
44-
<article key={key} className="entry-card">
45-
<header className="entry-card-header">
46-
<span>{key}</span>
47-
<FramePreview animation={animation} serverVersion={serverVersion} />
48-
<button
49-
type="button"
50-
onClick={() => {
51-
const animations = { ...menu.animations };
52-
delete animations[key];
53-
onChange({ ...menu, animations });
54-
}}
55-
className="btn btn-danger btn-sm"
56-
>
57-
Remove
28+
useEffect(() => {
29+
if (!playing || active === undefined || frames.length < 2) {
30+
return;
31+
}
32+
33+
const period = Math.max(1, active.interval) * TICK_MS;
34+
const timer = setInterval(() => setFrameIndex(current => (current + 1) % frames.length), period);
35+
36+
return () => clearInterval(timer);
37+
}, [playing, active, frames.length]);
38+
39+
if (names.length === 0) {
40+
return (
41+
<div className="animation-timeline-container">
42+
<div className="timeline-header">
43+
<span className="timeline-title">⚡ Animation Timeline (Individual Slot)</span>
44+
<div className="timeline-controls">
45+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => addAnimation(menu, onChange)}>
46+
+ Animation
5847
</button>
59-
</header>
48+
</div>
49+
</div>
50+
<div className="timeline-frames">
51+
<span className="empty-note">This menu has no animated items.</span>
52+
</div>
53+
</div>
54+
);
55+
}
56+
57+
const update = (animation: VisualAnimation): void => {
58+
if (selected !== null) {
59+
onChange({ ...menu, animations: { ...menu.animations, [selected]: animation } });
60+
}
61+
};
62+
63+
return (
64+
<div className="animation-timeline-container">
65+
<div className="timeline-header">
66+
<span className="timeline-title">⚡ Animation Timeline (Individual Slot)</span>
67+
68+
<div className="timeline-controls">
69+
<select
70+
className="inline-input"
71+
style={{ width: 'auto' }}
72+
value={selected ?? ''}
73+
onChange={event => {
74+
setSelected(event.target.value);
75+
setFrameIndex(0);
76+
}}
77+
aria-label="Animation"
78+
>
79+
{names.map(name => (
80+
<option key={name} value={name}>
81+
{name}
82+
</option>
83+
))}
84+
</select>
85+
86+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(true)} title="Play">
87+
▶️
88+
</button>
89+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(false)} title="Stop">
90+
⏹️
91+
</button>
92+
<button
93+
type="button"
94+
className="btn btn-secondary btn-sm"
95+
title="Add Frame"
96+
onClick={() =>
97+
active !== undefined &&
98+
update({
99+
...active,
100+
frames: {
101+
...active.frames,
102+
[`frame${frames.length + 1}`]: lastFrame(frames) ?? { material: 'STONE', amount: 1 },
103+
},
104+
})
105+
}
106+
>
107+
+ Frame
108+
</button>
109+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => addAnimation(menu, onChange)} title="Add animation">
110+
+ Animation
111+
</button>
60112

61-
<label>
62-
Interval (ticks per frame)
113+
<span className="timeline-interval">
114+
Interval:{' '}
63115
<input
64116
type="number"
65117
min={1}
66-
value={animation.interval}
67-
onChange={event => update(key, { ...animation, interval: Number(event.target.value) })}
68-
className="inline-input" style={{ width: '90px' }}
69-
/>
70-
</label>
71-
72-
<Frames
73-
animation={animation}
74-
serverVersion={serverVersion}
75-
onChange={next => update(key, next)}
76-
/>
77-
</article>
78-
))}
79-
</section>
118+
max={100}
119+
style={{ width: '60px' }}
120+
value={active?.interval ?? DEFAULT_INTERVAL}
121+
onChange={event => active !== undefined && update({ ...active, interval: Number(event.target.value) })}
122+
aria-label="Interval in ticks"
123+
/>{' '}
124+
ticks
125+
</span>
126+
</div>
127+
</div>
128+
129+
<div className="timeline-frames">
130+
{frames.map(([key, frame], index) => (
131+
<div
132+
key={key}
133+
className={`timeline-frame${playing && index === frameIndex ? ' active' : ''}`}
134+
title={`Frame ${index + 1}: ${frame.material}`}
135+
role="button"
136+
tabIndex={0}
137+
onClick={() => setFrameIndex(index)}
138+
onKeyDown={event => event.key === 'Enter' && setFrameIndex(index)}
139+
>
140+
<div className="timeline-frame-number">{index + 1}</div>
141+
<div className="timeline-frame-preview">
142+
<ItemIcon item={frame} serverVersion={serverVersion} variant="canvas" />
143+
</div>
144+
</div>
145+
))}
146+
</div>
147+
148+
{frames[frameIndex] !== undefined && active !== undefined && (
149+
<FrameFields
150+
frame={frames[frameIndex][1]}
151+
onChange={frame =>
152+
update({ ...active, frames: { ...active.frames, [frames[frameIndex][0]]: frame } })
153+
}
154+
onRemove={() => {
155+
const next = { ...active.frames };
156+
delete next[frames[frameIndex][0]];
157+
update({ ...active, frames: next });
158+
setFrameIndex(0);
159+
}}
160+
/>
161+
)}
162+
</div>
80163
);
81164
}
82165

83-
function Frames({
84-
animation,
85-
serverVersion,
166+
function FrameFields({
167+
frame,
86168
onChange,
169+
onRemove,
87170
}: {
88-
animation: VisualAnimation;
89-
serverVersion: string | null;
90-
onChange: (animation: VisualAnimation) => void;
171+
frame: VisualItem;
172+
onChange: (frame: VisualItem) => void;
173+
onRemove: () => void;
91174
}) {
92-
const frames = Object.entries(animation.frames);
93-
94-
const setFrame = (key: string, frame: VisualItem): void =>
95-
onChange({ ...animation, frames: { ...animation.frames, [key]: frame } });
96-
97175
return (
98-
<div className="editor-field">
99-
{frames.map(([key, frame]) => (
100-
<div key={key} className="entry-card-header">
101-
<ItemIcon item={frame} serverVersion={serverVersion} className="size-6" />
102-
<input
103-
value={frame.material}
104-
onChange={event => setFrame(key, { ...frame, material: event.target.value.toUpperCase() })}
105-
aria-label={`${key} material`}
106-
className="inline-input"
107-
/>
108-
<input
109-
value={typeof frame.name === 'string' ? frame.name : ''}
110-
onChange={event => setFrame(key, { ...frame, name: event.target.value })}
111-
placeholder="name"
112-
aria-label={`${key} name`}
113-
className="inline-input"
114-
/>
115-
<button
116-
type="button"
117-
onClick={() => {
118-
const next = { ...animation.frames };
119-
delete next[key];
120-
onChange({ ...animation, frames: next });
121-
}}
122-
aria-label={`Remove ${key}`}
123-
className="tab-close"
124-
>
125-
x
126-
</button>
127-
</div>
128-
))}
129-
130-
<button
131-
type="button"
132-
onClick={() =>
133-
onChange({
134-
...animation,
135-
frames: {
136-
...animation.frames,
137-
[`frame${frames.length + 1}`]: { material: 'STONE', amount: 1, name: '&f' },
138-
},
139-
})
140-
}
141-
className="btn btn-secondary btn-sm"
142-
>
143-
Add frame
176+
<div className="editor-row">
177+
<input
178+
className="inline-input"
179+
value={frame.material}
180+
onChange={event => onChange({ ...frame, material: event.target.value.toUpperCase() })}
181+
aria-label="Frame material"
182+
/>
183+
<input
184+
className="inline-input"
185+
value={typeof frame.name === 'string' ? frame.name : ''}
186+
onChange={event => onChange({ ...frame, name: event.target.value })}
187+
placeholder="name"
188+
aria-label="Frame name"
189+
/>
190+
<button type="button" className="btn btn-danger btn-sm" onClick={onRemove}>
191+
Remove frame
144192
</button>
145193
</div>
146194
);
147195
}
148196

149-
/** Cycles the frames at the configured rate so the animation can be judged here. */
150-
function FramePreview({ animation, serverVersion }: { animation: VisualAnimation; serverVersion: string | null }) {
151-
const frames = Object.values(animation.frames);
152-
const [index, setIndex] = useState(0);
153-
154-
useEffect(() => {
155-
if (frames.length < 2) {
156-
return;
157-
}
158-
159-
// A Minecraft tick is 50ms.
160-
const period = Math.max(1, animation.interval) * 50;
161-
const timer = setInterval(() => setIndex(current => (current + 1) % frames.length), period);
162-
163-
return () => clearInterval(timer);
164-
}, [animation.interval, frames.length]);
165-
166-
const frame = frames[index % Math.max(1, frames.length)];
167-
168-
if (frame === undefined) {
169-
return null;
170-
}
197+
function lastFrame(frames: [string, VisualItem][]): VisualItem | undefined {
198+
const entry = frames[frames.length - 1];
171199

172-
return <ItemIcon item={frame} serverVersion={serverVersion} className="size-6" />;
200+
return entry === undefined ? undefined : { ...entry[1] };
173201
}
174202

175-
function nextKey(animations: Record<string, unknown>): string {
176-
let index = Object.keys(animations).length + 1;
203+
function addAnimation(menu: VisualJavaMenu, onChange: (menu: VisualJavaMenu) => void): void {
204+
let index = Object.keys(menu.animations).length + 1;
177205

178-
while (animations[`animation${index}`] !== undefined) {
206+
while (menu.animations[`animation${index}`] !== undefined) {
179207
index++;
180208
}
181209

182-
return `animation${index}`;
210+
onChange({
211+
...menu,
212+
animations: { ...menu.animations, [`animation${index}`]: { interval: DEFAULT_INTERVAL, frames: {} } },
213+
});
183214
}

0 commit comments

Comments
 (0)