Skip to content

Commit 0fe7db7

Browse files
committed
fix(editor): open in visual mode, play animations and fill wide screens
1 parent 0bf7cc0 commit 0fe7db7

9 files changed

Lines changed: 265 additions & 62 deletions

File tree

resources/css/editor-extras.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,22 @@
225225
gap: 4px;
226226
align-items: center;
227227
}
228+
229+
/* The inherited rules size CodeMirror 5 (.CodeMirror); this editor is on
230+
CodeMirror 6, whose root is .cm-editor. Without a flex basis it sizes to its
231+
content and leaves the rest of a wide screen blank. */
232+
.yaml-editor,
233+
.editor-body > .cm-theme {
234+
flex: 1;
235+
min-width: 0;
236+
height: 100%;
237+
}
238+
239+
.yaml-editor .cm-editor {
240+
height: 100%;
241+
}
242+
243+
.yaml-editor .cm-scroller {
244+
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
245+
font-size: 14px;
246+
}

resources/js/components/DemoWorkspace.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export function DemoWorkspace() {
1818
const { store, dispatch } = useEditorStore();
1919
const notify = useToast();
2020
const [diagnostics, setDiagnostics] = useState<MenuDiagnostics | null>(null);
21-
const [visualMode, setVisualMode] = useState(false);
21+
// Menus open in the visual editor, as they did before the migration.
22+
const [visualMode, setVisualMode] = useState(true);
2223

2324
const current = activeTab(store);
2425

resources/js/components/EditorWorkspace.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export function EditorWorkspace({ sessionId, session, realtimeReady }: EditorWor
2626
const notify = useToast();
2727
const [diagnostics, setDiagnostics] = useState<MenuDiagnostics | null>(null);
2828
const [busy, setBusy] = useState(false);
29-
const [visualMode, setVisualMode] = useState(false);
29+
// Menus open in the visual editor, as they did before the migration.
30+
const [visualMode, setVisualMode] = useState(true);
3031
const [terminalOpen, setTerminalOpen] = useState(false);
3132
const [loadError, setLoadError] = useState<string | null>(null);
3233

resources/js/components/YamlEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function YamlEditor({ value, onChange }: YamlEditorProps) {
2424
onChange={onChange}
2525
indentWithTab={false}
2626
basicSetup={{ lineNumbers: true, foldGutter: true, autocompletion: false, highlightActiveLine: true }}
27-
className="h-full text-sm"
27+
className="yaml-editor"
2828
/>
2929
);
3030
}

resources/js/components/visual/AnimationEditor.tsx

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,37 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22
import { ItemIcon } from './ItemIcon';
33
import { MaterialSelector } from './MaterialSelector';
4+
import type { Playback } from './VisualEditor';
45
import type { VisualAnimation, VisualItem, VisualJavaMenu } from '../../editor/model';
56

67
interface AnimationEditorProps {
78
menu: VisualJavaMenu;
89
serverVersion: string | null;
910
onChange: (menu: VisualJavaMenu) => void;
11+
playback: Playback;
1012
}
1113

12-
/** Ticks per frame, as the plugin counts them. A tick is 50ms. */
14+
/** Ticks per frame, as the plugin counts them. */
1315
const DEFAULT_INTERVAL = 2;
14-
const TICK_MS = 50;
1516

1617
/**
1718
* The animation strip: one card per frame, played back at the configured rate
1819
* so the sequence can be judged without opening the game.
1920
*/
20-
export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEditorProps) {
21+
export function AnimationEditor({ menu, serverVersion, onChange, playback }: AnimationEditorProps) {
2122
const names = Object.keys(menu.animations);
2223
const [selected, setSelected] = useState<string | null>(names[0] ?? null);
23-
const [playing, setPlaying] = useState(false);
24-
const [frameIndex, setFrameIndex] = useState(0);
24+
const [picked, setPicked] = useState(0);
2525

2626
const active = selected === null ? undefined : menu.animations[selected];
2727
const frames = active === undefined ? [] : Object.entries(active.frames);
2828

29-
useEffect(() => {
30-
if (!playing || active === undefined || frames.length < 2) {
31-
return;
32-
}
33-
34-
const period = Math.max(1, active.interval) * TICK_MS;
35-
const timer = setInterval(() => setFrameIndex(current => (current + 1) % frames.length), period);
36-
37-
return () => clearInterval(timer);
38-
}, [playing, active, frames.length]);
29+
// While it plays, the strip follows the shared clock; otherwise it shows
30+
// whichever frame is being edited.
31+
const frameIndex =
32+
playback.playing && frames.length > 0
33+
? Math.floor(playback.tick / Math.max(1, active?.interval ?? DEFAULT_INTERVAL)) % frames.length
34+
: Math.min(picked, Math.max(0, frames.length - 1));
3935

4036
if (names.length === 0) {
4137
return (
@@ -73,7 +69,7 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
7369
value={selected ?? ''}
7470
onChange={event => {
7571
setSelected(event.target.value);
76-
setFrameIndex(0);
72+
setPicked(0);
7773
}}
7874
aria-label="Animation"
7975
>
@@ -84,10 +80,10 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
8480
))}
8581
</select>
8682

87-
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(true)} title="Play">
83+
<button type="button" className="btn btn-secondary btn-sm" onClick={playback.play} title="Play">
8884
▶️
8985
</button>
90-
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(false)} title="Stop">
86+
<button type="button" className="btn btn-secondary btn-sm" onClick={playback.stop} title="Stop">
9187
⏹️
9288
</button>
9389
<button
@@ -131,12 +127,12 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
131127
{frames.map(([key, frame], index) => (
132128
<div
133129
key={key}
134-
className={`timeline-frame${playing && index === frameIndex ? ' active' : ''}`}
130+
className={`timeline-frame${index === frameIndex ? ' active' : ''}`}
135131
title={`Frame ${index + 1}: ${frame.material}`}
136132
role="button"
137133
tabIndex={0}
138-
onClick={() => setFrameIndex(index)}
139-
onKeyDown={event => event.key === 'Enter' && setFrameIndex(index)}
134+
onClick={() => setPicked(index)}
135+
onKeyDown={event => event.key === 'Enter' && setPicked(index)}
140136
>
141137
<div className="timeline-frame-number">{index + 1}</div>
142138
<div className="timeline-frame-preview">
@@ -157,7 +153,7 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
157153
const next = { ...active.frames };
158154
delete next[frames[frameIndex][0]];
159155
update({ ...active, frames: next });
160-
setFrameIndex(0);
156+
setPicked(0);
161157
}}
162158
/>
163159
)}

resources/js/components/visual/GlobalTimeline.tsx

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,22 @@
1-
import { useEffect, useState } from 'react';
1+
import { frameAt, slotAnimations } from '../../editor/animation';
22
import { ItemIcon } from './ItemIcon';
3+
import type { Playback } from './VisualEditor';
34
import type { VisualJavaMenu } from '../../editor/model';
45

56
interface GlobalTimelineProps {
67
menu: VisualJavaMenu;
78
serverVersion: string | null;
9+
playback: Playback;
810
}
911

10-
/** A Minecraft tick. */
11-
const TICK_MS = 50;
12-
1312
/**
1413
* Every animation of the menu on one grid, played together.
1514
*
1615
* Animations that share a slot or run at different intervals only reveal how
1716
* they look side by side, which a single slot timeline cannot show.
1817
*/
19-
export function GlobalTimeline({ menu, serverVersion }: GlobalTimelineProps) {
20-
const [playing, setPlaying] = useState(false);
21-
const [tick, setTick] = useState(0);
22-
23-
const rows = Object.entries(menu.animations);
24-
25-
useEffect(() => {
26-
if (!playing) {
27-
return;
28-
}
29-
30-
const timer = setInterval(() => setTick(current => current + 1), TICK_MS);
31-
32-
return () => clearInterval(timer);
33-
}, [playing]);
18+
export function GlobalTimeline({ menu, serverVersion, playback }: GlobalTimelineProps) {
19+
const rows = slotAnimations(menu);
3420

3521
if (rows.length === 0) {
3622
return null;
@@ -41,20 +27,20 @@ export function GlobalTimeline({ menu, serverVersion }: GlobalTimelineProps) {
4127
<div className="global-timeline-header">
4228
<div className="global-timeline-header-left">
4329
<span className="global-timeline-title">🎬 Global Animation Timeline</span>
44-
<span className="global-timeline-status">{playing ? 'Playing' : 'Stopped'}</span>
30+
<span className="global-timeline-status">{playback.playing ? 'Playing' : 'Stopped'}</span>
4531
</div>
4632

4733
<div className="global-timeline-controls">
48-
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(true)} title="Play">
34+
<button type="button" className="btn btn-secondary btn-sm" onClick={playback.play} title="Play">
4935
▶️ Play
5036
</button>
51-
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(false)} title="Stop">
37+
<button type="button" className="btn btn-secondary btn-sm" onClick={playback.stop} title="Stop">
5238
⏹️ Stop
5339
</button>
5440
<button
5541
type="button"
5642
className="btn btn-secondary btn-sm"
57-
onClick={() => setTick(0)}
43+
onClick={playback.reset}
5844
title="Back to the first frame"
5945
>
6046
🔄 Reset
@@ -63,25 +49,25 @@ export function GlobalTimeline({ menu, serverVersion }: GlobalTimelineProps) {
6349
</div>
6450

6551
<div className="global-timeline-rows">
66-
{rows.map(([name, animation]) => {
67-
const frames = Object.entries(animation.frames);
68-
const interval = Math.max(1, animation.interval);
69-
const current = frames.length === 0 ? -1 : Math.floor(tick / interval) % frames.length;
52+
{rows.map(animation => {
53+
const current = frameAt(animation, playback.tick);
7054

7155
return (
72-
<div key={name} className="global-timeline-row">
56+
<div key={animation.key} className="global-timeline-row">
7357
<div className="global-timeline-row-header">
74-
<span className="global-timeline-slot-label">{name}</span>
58+
<span className="global-timeline-slot-label">
59+
{animation.key} · slot {animation.slot}
60+
</span>
7561
<span className="global-timeline-info">
76-
{frames.length} frames · every {interval} ticks
62+
{animation.frames.length} frames · every {animation.interval} ticks
7763
</span>
7864
</div>
7965

8066
<div className="global-timeline-frames-container">
81-
{frames.map(([key, frame], index) => (
67+
{animation.frames.map((frame, index) => (
8268
<div
83-
key={key}
84-
className={`global-timeline-frame${playing && index === current ? ' active' : ''}`}
69+
key={`${animation.key}-${index}`}
70+
className={`global-timeline-frame${playback.playing && frame === current ? ' active' : ''}`}
8571
title={`${frame.material} (frame ${index + 1})`}
8672
>
8773
<ItemIcon item={frame} serverVersion={serverVersion} variant="canvas" />

resources/js/components/visual/VisualEditor.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react';
22
import { normalizeMenuSize, VALID_CHEST_SIZES } from '../../editor/config';
3+
import { itemsAtTick, TICK_MS } from '../../editor/animation';
34
import { applyVisualEdit, readVisual } from '../../editor/yamlDocument';
45
import { ItemEditor } from './ItemEditor';
56
import { ItemPalette } from './ItemPalette';
@@ -29,6 +30,7 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
2930
// Copied items live for the session, so one can be pasted across menus.
3031
const [clipboard, setClipboard] = useState<VisualItem | null>(null);
3132
const [panel, setPanel] = useState<PanelKey>('palette');
33+
const playback = usePlayback();
3234

3335
const parsed = useMemo(() => {
3436
try {
@@ -97,7 +99,7 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
9799

98100
<MenuCanvas
99101
size={normalizeMenuSize(menu.size)}
100-
items={menu.items}
102+
items={playback.playing ? itemsAtTick(menu, playback.tick) : menu.items}
101103
selectedSlot={selectedSlot}
102104
serverVersion={serverVersion}
103105
onSelect={slot => {
@@ -127,9 +129,9 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
127129
}}
128130
/>
129131

130-
<AnimationEditor menu={menu} serverVersion={serverVersion} onChange={commit} />
132+
<AnimationEditor menu={menu} serverVersion={serverVersion} onChange={commit} playback={playback} />
131133

132-
<GlobalTimeline menu={menu} serverVersion={serverVersion} />
134+
<GlobalTimeline menu={menu} serverVersion={serverVersion} playback={playback} />
133135
</div>
134136

135137
<div className="visual-editor-right-panel">
@@ -222,6 +224,41 @@ const PANELS: { key: PanelKey; label: string }[] = [
222224
{ key: 'item', label: 'Item Editor' },
223225
];
224226

227+
export interface Playback {
228+
playing: boolean;
229+
tick: number;
230+
play: () => void;
231+
stop: () => void;
232+
reset: () => void;
233+
}
234+
235+
/**
236+
* Drives every animation of the menu off one tick counter, so two animations
237+
* with different intervals stay in step exactly as they do in game.
238+
*/
239+
function usePlayback(): Playback {
240+
const [playing, setPlaying] = useState(false);
241+
const [tick, setTick] = useState(0);
242+
243+
useEffect(() => {
244+
if (!playing) {
245+
return;
246+
}
247+
248+
const timer = setInterval(() => setTick(current => current + 1), TICK_MS);
249+
250+
return () => clearInterval(timer);
251+
}, [playing]);
252+
253+
return {
254+
playing,
255+
tick,
256+
play: () => setPlaying(true),
257+
stop: () => setPlaying(false),
258+
reset: () => setTick(0),
259+
};
260+
}
261+
225262
interface SlotShortcuts {
226263
slot: number | null;
227264
item: VisualItem | undefined;

0 commit comments

Comments
 (0)