Skip to content

Commit bede7fc

Browse files
committed
feat(editor): add the settings form and the global animation timeline
1 parent bff0785 commit bede7fc

6 files changed

Lines changed: 544 additions & 1 deletion

File tree

resources/css/editor-extras.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,9 @@
210210
justify-content: space-between;
211211
gap: 12px;
212212
}
213+
214+
/* A checkbox and its label need a gap the generic row does not give them. */
215+
.setting-group .editor-row input[type='checkbox'] {
216+
width: auto;
217+
margin-right: 8px;
218+
}

resources/js/components/WorkspaceLayout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { OutputPanel } from './OutputPanel';
66
import { StatusBar } from './StatusBar';
77
import { VisualEditor } from './visual/VisualEditor';
88
import { FormBuilder } from './visual/FormBuilder';
9+
import { ConfigEditor } from './visual/ConfigEditor';
910
import { YamlEditor } from './YamlEditor';
1011
import type { MenuDiagnostics } from '../editor/validator';
1112
import type { MenuDescriptor } from '../types/editor';
@@ -107,7 +108,7 @@ export function WorkspaceLayout({
107108
type="button"
108109
className="btn btn-secondary"
109110
onClick={onToggleVisual}
110-
disabled={current === null || platform === 'CONFIG'}
111+
disabled={current === null}
111112
title={visualMode ? 'Switch to YAML mode' : 'Switch to Visual mode'}
112113
>
113114
{visualMode ? 'YAML' : 'Visual'}
@@ -150,6 +151,11 @@ export function WorkspaceLayout({
150151
serverVersion={serverVersion}
151152
onChange={content => dispatch({ type: 'tab/edited', key: current.key, content })}
152153
/>
154+
) : visualMode && platform === 'CONFIG' ? (
155+
<ConfigEditor
156+
source={current.content}
157+
onChange={content => dispatch({ type: 'tab/edited', key: current.key, content })}
158+
/>
153159
) : visualMode && platform === 'BEDROCK' ? (
154160
<FormBuilder
155161
source={current.content}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { useMemo } from 'react';
2+
import * as YAML from 'yaml';
3+
import { CONFIG_SECTIONS, type ConfigField } from '../../editor/configSchema';
4+
5+
interface ConfigEditorProps {
6+
source: string;
7+
onChange: (source: string) => void;
8+
}
9+
10+
/**
11+
* settings.yml as a form.
12+
*
13+
* Every change is written into the parsed document, so the long comment blocks
14+
* the plugin ships the file with are still there afterwards.
15+
*/
16+
export function ConfigEditor({ source, onChange }: ConfigEditorProps) {
17+
const document = useMemo(() => {
18+
try {
19+
return YAML.parseDocument(source);
20+
} catch {
21+
return null;
22+
}
23+
}, [source]);
24+
25+
if (document === null || document.errors.length > 0) {
26+
return (
27+
<div className="item-editor-content">
28+
<p>This file does not parse as YAML.</p>
29+
<p>Fix it in the text editor before using the form.</p>
30+
</div>
31+
);
32+
}
33+
34+
const write = (path: string[], value: unknown): void => {
35+
const next = YAML.parseDocument(source);
36+
37+
if (value === null || value === '') {
38+
next.deleteIn(path);
39+
} else {
40+
next.setIn(path, value);
41+
}
42+
43+
onChange(String(next));
44+
};
45+
46+
const read = (field: ConfigField): unknown => document.getIn(field.path);
47+
48+
return (
49+
<div className="config-visual-editor">
50+
<div className="config-visual-header">
51+
<div>
52+
<div className="config-visual-title">Plugin Configuration</div>
53+
<div className="config-visual-subtitle">
54+
Edit settings.yml visually. Comments are shown under each field.
55+
</div>
56+
</div>
57+
<div className="config-visual-badge">settings.yml</div>
58+
</div>
59+
60+
<div className="config-visual-content">
61+
{CONFIG_SECTIONS.map(section => (
62+
<div key={section.id} className="settings-section">
63+
<div className="config-section-title">{section.title}</div>
64+
<div className="config-section-description">{section.description}</div>
65+
66+
{section.fields.map(field => (
67+
<div key={field.path.join('.')} className="setting-group">
68+
<div className="config-field-label">{field.label}</div>
69+
<div className="config-field-description">{field.description}</div>
70+
<Control field={field} value={read(field)} onChange={value => write(field.path, value)} />
71+
</div>
72+
))}
73+
</div>
74+
))}
75+
</div>
76+
</div>
77+
);
78+
}
79+
80+
function Control({
81+
field,
82+
value,
83+
onChange,
84+
}: {
85+
field: ConfigField;
86+
value: unknown;
87+
onChange: (value: unknown) => void;
88+
}) {
89+
switch (field.type) {
90+
case 'toggle':
91+
return (
92+
<label className="editor-row">
93+
<input type="checkbox" checked={value === true} onChange={event => onChange(event.target.checked)} />
94+
<span>{value === true ? 'Enabled' : 'Disabled'}</span>
95+
</label>
96+
);
97+
98+
case 'select':
99+
return (
100+
<select
101+
className="inline-input"
102+
value={String(value ?? '')}
103+
onChange={event => onChange(event.target.value)}
104+
aria-label={field.label}
105+
>
106+
{(field.options ?? []).map(option => (
107+
<option key={option.value} value={option.value}>
108+
{option.label}
109+
</option>
110+
))}
111+
</select>
112+
);
113+
114+
case 'number':
115+
return (
116+
<input
117+
type="number"
118+
className="inline-input"
119+
min={field.min}
120+
max={field.max}
121+
value={typeof value === 'number' ? value : ''}
122+
onChange={event => onChange(event.target.value === '' ? null : Number(event.target.value))}
123+
aria-label={field.label}
124+
/>
125+
);
126+
127+
case 'list':
128+
return (
129+
<textarea
130+
className="inline-input"
131+
rows={Math.min(10, Math.max(3, toLines(value).length + 1))}
132+
value={toLines(value).join('\n')}
133+
onChange={event => onChange(fromLines(event.target.value))}
134+
aria-label={field.label}
135+
/>
136+
);
137+
138+
default:
139+
return (
140+
<input
141+
className="inline-input"
142+
value={typeof value === 'string' ? value : ''}
143+
placeholder={field.placeholder}
144+
onChange={event => onChange(event.target.value)}
145+
aria-label={field.label}
146+
/>
147+
);
148+
}
149+
}
150+
151+
function toLines(value: unknown): string[] {
152+
if (Array.isArray(value)) {
153+
return value.map(String);
154+
}
155+
156+
if (value !== null && typeof value === 'object' && 'toJSON' in value) {
157+
const plain = (value as { toJSON: () => unknown }).toJSON();
158+
159+
return Array.isArray(plain) ? plain.map(String) : [];
160+
}
161+
162+
return [];
163+
}
164+
165+
function fromLines(text: string): string[] | null {
166+
const lines = text.split('\n').filter(line => line.trim() !== '');
167+
168+
return lines.length === 0 ? null : lines;
169+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useEffect, useState } from 'react';
2+
import { ItemIcon } from './ItemIcon';
3+
import type { VisualJavaMenu } from '../../editor/model';
4+
5+
interface GlobalTimelineProps {
6+
menu: VisualJavaMenu;
7+
serverVersion: string | null;
8+
}
9+
10+
/** A Minecraft tick. */
11+
const TICK_MS = 50;
12+
13+
/**
14+
* Every animation of the menu on one grid, played together.
15+
*
16+
* Animations that share a slot or run at different intervals only reveal how
17+
* they look side by side, which a single slot timeline cannot show.
18+
*/
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]);
34+
35+
if (rows.length === 0) {
36+
return null;
37+
}
38+
39+
return (
40+
<div className="global-animation-timeline-container">
41+
<div className="global-timeline-header">
42+
<div className="global-timeline-header-left">
43+
<span className="global-timeline-title">🎬 Global Animation Timeline</span>
44+
<span className="global-timeline-status">{playing ? 'Playing' : 'Stopped'}</span>
45+
</div>
46+
47+
<div className="global-timeline-controls">
48+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(true)} title="Play">
49+
▶️ Play
50+
</button>
51+
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPlaying(false)} title="Stop">
52+
⏹️ Stop
53+
</button>
54+
<button
55+
type="button"
56+
className="btn btn-secondary btn-sm"
57+
onClick={() => setTick(0)}
58+
title="Back to the first frame"
59+
>
60+
🔄 Reset
61+
</button>
62+
</div>
63+
</div>
64+
65+
<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;
70+
71+
return (
72+
<div key={name} className="global-timeline-row">
73+
<div className="global-timeline-row-header">
74+
<span className="global-timeline-slot-label">{name}</span>
75+
<span className="global-timeline-info">
76+
{frames.length} frames · every {interval} ticks
77+
</span>
78+
</div>
79+
80+
<div className="global-timeline-frames-container">
81+
{frames.map(([key, frame], index) => (
82+
<div
83+
key={key}
84+
className={`global-timeline-frame${playing && index === current ? ' active' : ''}`}
85+
title={`${frame.material} (frame ${index + 1})`}
86+
>
87+
<ItemIcon item={frame} serverVersion={serverVersion} variant="canvas" />
88+
</div>
89+
))}
90+
</div>
91+
</div>
92+
);
93+
})}
94+
</div>
95+
</div>
96+
);
97+
}

resources/js/components/visual/VisualEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { MenuCanvas } from './MenuCanvas';
77
import { SlotContextMenu, type SlotMenuTarget } from './SlotContextMenu';
88
import { MenuSettings } from './MenuSettings';
99
import { AnimationEditor } from './AnimationEditor';
10+
import { GlobalTimeline } from './GlobalTimeline';
1011
import type { VisualItem, VisualJavaMenu } from '../../editor/model';
1112

1213
interface VisualEditorProps {
@@ -127,6 +128,8 @@ export function VisualEditor({ source, platform, serverVersion, onChange }: Visu
127128
/>
128129

129130
<AnimationEditor menu={menu} serverVersion={serverVersion} onChange={commit} />
131+
132+
<GlobalTimeline menu={menu} serverVersion={serverVersion} />
130133
</div>
131134

132135
<div className="visual-editor-right-panel">

0 commit comments

Comments
 (0)