Skip to content

Commit 6b25682

Browse files
committed
fix(editor): autoplay animations, keep their slots filled and let panels resize
1 parent 6e27bb0 commit 6b25682

6 files changed

Lines changed: 167 additions & 9 deletions

File tree

resources/js/components/MenuSidebar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useState } from 'react';
22
import { menuKey } from '../editor/menus';
33
import { ContextMenu } from './ContextMenu';
4+
import { ResizeHandle } from './ResizeHandle';
5+
import { usePanelWidth } from '../editor/panelWidth';
46
import type { MenuDescriptor } from '../types/editor';
57

68
interface MenuSidebarProps {
@@ -28,9 +30,10 @@ const SECTIONS: Section[] = [
2830
export function MenuSidebar({ menus, activeKey, onOpen, onCreate, onDelete }: MenuSidebarProps) {
2931
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
3032
const [menu, setMenu] = useState<{ menu: MenuDescriptor; x: number; y: number } | null>(null);
33+
const [width, setWidth] = usePanelWidth('sidebar', 280);
3134

3235
return (
33-
<aside className="ide-sidebar">
36+
<aside className="ide-sidebar" style={{ width: `${width}px` }}>
3437
<div className="sidebar-header">Server Menus</div>
3538

3639
<div className="sidebar-content">
@@ -92,6 +95,8 @@ export function MenuSidebar({ menus, activeKey, onOpen, onCreate, onDelete }: Me
9295
})}
9396
</div>
9497

98+
<ResizeHandle edge="right" width={width} min={200} max={500} onResize={setWidth} />
99+
95100
{menu !== null && (
96101
<ContextMenu
97102
x={menu.x}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
interface ResizeHandleProps {
4+
/** Which edge of the panel the handle sits on. */
5+
edge: 'left' | 'right';
6+
width: number;
7+
min: number;
8+
max: number;
9+
onResize: (width: number) => void;
10+
}
11+
12+
/**
13+
* Drags a panel wider or narrower.
14+
*
15+
* The pointer is captured for the duration, so the drag survives moving over
16+
* the editor, an iframe or outside the window.
17+
*/
18+
export function ResizeHandle({ edge, width, min, max, onResize }: ResizeHandleProps) {
19+
const [dragging, setDragging] = useState(false);
20+
const start = useRef({ x: 0, width: 0 });
21+
22+
const move = useCallback(
23+
(event: PointerEvent): void => {
24+
const delta = event.clientX - start.current.x;
25+
const next = start.current.width + (edge === 'right' ? delta : -delta);
26+
27+
onResize(Math.max(min, Math.min(max, next)));
28+
},
29+
[edge, max, min, onResize],
30+
);
31+
32+
useEffect(() => {
33+
if (!dragging) {
34+
return;
35+
}
36+
37+
const stop = (): void => setDragging(false);
38+
39+
document.addEventListener('pointermove', move);
40+
document.addEventListener('pointerup', stop);
41+
document.body.style.cursor = 'ew-resize';
42+
document.body.style.userSelect = 'none';
43+
44+
return () => {
45+
document.removeEventListener('pointermove', move);
46+
document.removeEventListener('pointerup', stop);
47+
document.body.style.cursor = '';
48+
document.body.style.userSelect = '';
49+
};
50+
}, [dragging, move]);
51+
52+
return (
53+
<div
54+
className={`panel-resize-handle panel-resize-${edge}`}
55+
role="separator"
56+
aria-orientation="vertical"
57+
aria-label="Resize panel"
58+
onPointerDown={event => {
59+
start.current = { x: event.clientX, width };
60+
setDragging(true);
61+
event.preventDefault();
62+
}}
63+
/>
64+
);
65+
}

resources/js/components/visual/VisualEditor.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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';
3+
import { canvasItems, slotAnimations, TICK_MS } from '../../editor/animation';
44
import { applyVisualEdit, readVisual } from '../../editor/yamlDocument';
55
import { ItemEditor } from './ItemEditor';
66
import { ItemPalette } from './ItemPalette';
77
import { MenuCanvas } from './MenuCanvas';
88
import { SlotContextMenu, type SlotMenuTarget } from './SlotContextMenu';
9+
import { ResizeHandle } from '../ResizeHandle';
10+
import { usePanelWidth } from '../../editor/panelWidth';
911
import { MenuSettings } from './MenuSettings';
1012
import { AnimationEditor } from './AnimationEditor';
1113
import { GlobalTimeline } from './GlobalTimeline';
@@ -30,7 +32,7 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
3032
// Copied items live for the session, so one can be pasted across menus.
3133
const [clipboard, setClipboard] = useState<VisualItem | null>(null);
3234
const [panel, setPanel] = useState<PanelKey>('palette');
33-
const playback = usePlayback();
35+
const [panelWidth, setPanelWidth] = usePanelWidth('visual-panel', 350);
3436

3537
const parsed = useMemo(() => {
3638
try {
@@ -40,6 +42,8 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
4042
}
4143
}, [source, platform]);
4244

45+
const playback = usePlayback(parsed.menu !== null && slotAnimations(parsed.menu).length > 0);
46+
4347
if (parsed.menu === null) {
4448
return (
4549
<div className="visual-editor-container">
@@ -99,7 +103,7 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
99103

100104
<MenuCanvas
101105
size={normalizeMenuSize(menu.size)}
102-
items={playback.playing ? itemsAtTick(menu, playback.tick) : menu.items}
106+
items={canvasItems(menu, playback.tick, playback.playing)}
103107
selectedSlot={selectedSlot}
104108
serverVersion={serverVersion}
105109
onSelect={slot => {
@@ -134,7 +138,9 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
134138
<GlobalTimeline menu={menu} serverVersion={serverVersion} playback={playback} />
135139
</div>
136140

137-
<div className="visual-editor-right-panel">
141+
<div className="visual-editor-right-panel" style={{ width: `${panelWidth}px` }}>
142+
<ResizeHandle edge="left" width={panelWidth} min={250} max={600} onResize={setPanelWidth} />
143+
138144
<div className="visual-editor-tabs">
139145
{PANELS.map(tab => (
140146
<button
@@ -236,10 +242,21 @@ export interface Playback {
236242
* Drives every animation of the menu off one tick counter, so two animations
237243
* with different intervals stay in step exactly as they do in game.
238244
*/
239-
function usePlayback(): Playback {
245+
function usePlayback(hasAnimations: boolean): Playback {
240246
const [playing, setPlaying] = useState(false);
241247
const [tick, setTick] = useState(0);
242248

249+
// The menu animates on its own once it loads, the way it does in game.
250+
useEffect(() => {
251+
if (!hasAnimations) {
252+
return;
253+
}
254+
255+
const start = setTimeout(() => setPlaying(true), 500);
256+
257+
return () => clearTimeout(start);
258+
}, [hasAnimations]);
259+
243260
useEffect(() => {
244261
if (!playing) {
245262
return;

resources/js/editor/__tests__/animation.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { frameAt, itemsAtTick, slotAnimations } from '../animation';
2+
import { canvasItems, frameAt, itemsAtTick, slotAnimations } from '../animation';
33
import type { VisualJavaMenu } from '../model';
44

55
function menuWith(animations: VisualJavaMenu['animations']): VisualJavaMenu {
@@ -106,3 +106,27 @@ describe('itemsAtTick', () => {
106106
expect(menu.items[13]).toBeUndefined();
107107
});
108108
});
109+
110+
describe('canvasItems', () => {
111+
const menu = menuWith({
112+
spin: {
113+
interval: 2,
114+
frames: {
115+
one: { material: 'RED_WOOL', amount: 1, slot: 13 },
116+
two: { material: 'BLUE_WOOL', amount: 1, slot: 13 },
117+
},
118+
},
119+
});
120+
121+
it('shows the live frame while playing', () => {
122+
expect(canvasItems(menu, 2, true)[13].material).toBe('BLUE_WOOL');
123+
});
124+
125+
it('keeps the first frame when stopped, so the slot never looks empty', () => {
126+
expect(canvasItems(menu, 2, false)[13].material).toBe('RED_WOOL');
127+
});
128+
129+
it('still shows the static items when stopped', () => {
130+
expect(canvasItems(menu, 0, false)[4].material).toBe('STONE');
131+
});
132+
});

resources/js/editor/animation.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ export function frameAt(animation: SlotAnimation, tick: number): VisualItem {
4141
}
4242

4343
/**
44-
* The menu's items with every animated slot replaced by its current frame, so
45-
* the canvas shows what the player would see while it plays.
44+
* The menu's items with every animated slot showing its current frame.
45+
*
46+
* An animated slot usually has no entry in `items`, so it must keep showing a
47+
* frame even when stopped, or the menu looks half empty the moment you pause.
4648
*/
4749
export function itemsAtTick(menu: VisualJavaMenu, tick: number): Record<number, VisualItem> {
4850
const items = { ...menu.items };
@@ -53,3 +55,10 @@ export function itemsAtTick(menu: VisualJavaMenu, tick: number): Record<number,
5355

5456
return items;
5557
}
58+
59+
/**
60+
* What the canvas shows: the live frame while playing, the first frame at rest.
61+
*/
62+
export function canvasItems(menu: VisualJavaMenu, tick: number, playing: boolean): Record<number, VisualItem> {
63+
return itemsAtTick(menu, playing ? tick : 0);
64+
}

resources/js/editor/panelWidth.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback, useState } from 'react';
2+
3+
const STORAGE_PREFIX = 'bluemenu.panel.';
4+
5+
/**
6+
* A panel width the viewer can drag, remembered for their browser.
7+
*
8+
* Storage can be unavailable (a private window, blocked site data), so both
9+
* reading and writing fall back to the default rather than throwing.
10+
*/
11+
export function usePanelWidth(name: string, fallback: number): [number, (width: number) => void] {
12+
const [width, setWidth] = useState(() => read(name, fallback));
13+
14+
const update = useCallback(
15+
(next: number): void => {
16+
setWidth(next);
17+
18+
try {
19+
localStorage.setItem(STORAGE_PREFIX + name, String(Math.round(next)));
20+
} catch {
21+
// The width still applies for this session.
22+
}
23+
},
24+
[name],
25+
);
26+
27+
return [width, update];
28+
}
29+
30+
function read(name: string, fallback: number): number {
31+
try {
32+
const stored = Number(localStorage.getItem(STORAGE_PREFIX + name));
33+
34+
return Number.isFinite(stored) && stored > 0 ? stored : fallback;
35+
} catch {
36+
return fallback;
37+
}
38+
}

0 commit comments

Comments
 (0)