Skip to content

Commit bff0785

Browse files
committed
style(editor): edit actions, conditions and materials in dialogs
1 parent bb320a8 commit bff0785

6 files changed

Lines changed: 469 additions & 156 deletions

File tree

resources/js/components/Modal.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useEffect, type ReactNode } from 'react';
2+
3+
interface ModalProps {
4+
title: string;
5+
children: ReactNode;
6+
footer?: ReactNode;
7+
wide?: boolean;
8+
onClose: () => void;
9+
}
10+
11+
/**
12+
* The editor's dialog shell. Escape and a click on the backdrop close it, the
13+
* way every dialog in the legacy editor behaved.
14+
*/
15+
export function Modal({ title, children, footer, wide, onClose }: ModalProps) {
16+
useEffect(() => {
17+
const escape = (event: KeyboardEvent): void => {
18+
if (event.key === 'Escape') {
19+
onClose();
20+
}
21+
};
22+
23+
document.addEventListener('keydown', escape);
24+
25+
return () => document.removeEventListener('keydown', escape);
26+
}, [onClose]);
27+
28+
return (
29+
<div
30+
className="modal-overlay"
31+
role="presentation"
32+
onClick={event => event.target === event.currentTarget && onClose()}
33+
>
34+
<div className="modal" role="dialog" aria-modal="true" aria-label={title} style={wide ? { minWidth: '640px' } : undefined}>
35+
<div className="modal-header">{title}</div>
36+
<div className="modal-body">{children}</div>
37+
{footer !== undefined && <div className="modal-footer">{footer}</div>}
38+
</div>
39+
</div>
40+
);
41+
}
Lines changed: 155 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,113 @@
11
import { useState } from 'react';
22
import { ACTION_TYPES, CLICK_TYPES, buildActionString, describeAction, parseAction } from '../../editor/actions';
3+
import { Modal } from '../Modal';
34

45
interface ActionEditorProps {
56
actions: string[];
67
onChange: (actions: string[]) => void;
78
}
89

9-
/** Click actions of one item, edited as a list rather than as raw strings. */
10+
/** Click actions of one item, edited in a dialog as the legacy editor did. */
1011
export function ActionEditor({ actions, onChange }: ActionEditorProps) {
1112
const [editing, setEditing] = useState<number | null>(null);
13+
const [dragged, setDragged] = useState<number | null>(null);
1214

13-
const replace = (index: number, action: string): void => {
14-
onChange(actions.map((existing, position) => (position === index ? action : existing)));
15-
setEditing(null);
16-
};
15+
const close = (): void => setEditing(null);
1716

1817
return (
19-
<section className="item-editor-section">
20-
<header className="section-heading">
18+
<>
19+
<div className="section-heading">
2120
<span>Actions</span>
2221
<button
2322
type="button"
23+
className="btn btn-secondary btn-sm"
2424
onClick={() => {
2525
onChange([...actions, '[LEFT_CLICK] CLOSE']);
2626
setEditing(actions.length);
2727
}}
28-
className="btn btn-secondary btn-sm"
2928
>
30-
Add
29+
+ Add
3130
</button>
32-
</header>
31+
</div>
3332

34-
{actions.length === 0 && <p className="empty-note">This item does nothing when clicked.</p>}
33+
<div className="actions-list">
34+
{actions.length === 0 && (
35+
<div className="actions-empty">
36+
<span>This item does nothing when clicked.</span>
37+
</div>
38+
)}
3539

36-
<ul>
3740
{actions.map((action, index) => {
3841
const parsed = parseAction(action);
3942

4043
return (
41-
<li key={`${index}-${action}`} className="entry-card">
42-
<div className="entry-card-header">
43-
<button
44-
type="button"
45-
onClick={() => setEditing(editing === index ? null : index)}
46-
className="inline-input" style={{ textAlign: 'left', cursor: 'pointer' }}
47-
>
48-
<span style={{ color: 'var(--accent-color)' }}>{CLICK_TYPES[parsed.clickType] ?? parsed.clickType}</span>
49-
<span> - </span>
50-
{describeAction(parsed)}
44+
<div
45+
key={`${index}-${action}`}
46+
className="action-item"
47+
draggable
48+
onDragStart={() => setDragged(index)}
49+
onDragEnd={() => setDragged(null)}
50+
onDragOver={event => event.preventDefault()}
51+
onDrop={() => {
52+
if (dragged !== null && dragged !== index) {
53+
onChange(move(actions, dragged, index));
54+
}
55+
setDragged(null);
56+
}}
57+
>
58+
<div className="action-item-drag">⋮⋮</div>
59+
60+
<div className="action-item-content">
61+
<div className="action-item-header">
62+
<span className="action-item-click-type">
63+
{CLICK_TYPES[parsed.clickType] ?? parsed.clickType}
64+
</span>
65+
<span className="action-item-type">{describeAction(parsed)}</span>
66+
</div>
67+
{parsed.params !== null && <div className="action-item-params">{parsed.params}</div>}
68+
</div>
69+
70+
<div className="action-item-actions">
71+
<button type="button" className="btn-icon" title="Edit" onClick={() => setEditing(index)}>
72+
✏️
5173
</button>
5274
<button
5375
type="button"
76+
className="btn-icon"
77+
title="Delete"
5478
onClick={() => onChange(actions.filter((_unused, position) => position !== index))}
55-
aria-label={`Remove action ${index + 1}`}
56-
className="tab-close"
5779
>
58-
x
80+
🗑️
5981
</button>
6082
</div>
61-
62-
{editing === index && <ActionForm action={action} onSave={value => replace(index, value)} />}
63-
</li>
83+
</div>
6484
);
6585
})}
66-
</ul>
67-
</section>
86+
</div>
87+
88+
{editing !== null && actions[editing] !== undefined && (
89+
<ActionDialog
90+
action={actions[editing]}
91+
onSave={value => {
92+
onChange(actions.map((existing, position) => (position === editing ? value : existing)));
93+
close();
94+
}}
95+
onClose={close}
96+
/>
97+
)}
98+
</>
6899
);
69100
}
70101

71-
function ActionForm({ action, onSave }: { action: string; onSave: (action: string) => void }) {
102+
function ActionDialog({
103+
action,
104+
onSave,
105+
onClose,
106+
}: {
107+
action: string;
108+
onSave: (action: string) => void;
109+
onClose: () => void;
110+
}) {
72111
const parsed = parseAction(action);
73112
const [clickType, setClickType] = useState(parsed.clickType);
74113
const [type, setType] = useState(parsed.type);
@@ -77,60 +116,98 @@ function ActionForm({ action, onSave }: { action: string; onSave: (action: strin
77116
const definition = ACTION_TYPES[type];
78117

79118
return (
80-
<div className="editor-field">
81-
<label>
119+
<Modal
120+
title="Action"
121+
onClose={onClose}
122+
footer={
123+
<>
124+
<button type="button" className="btn btn-secondary" onClick={onClose}>
125+
Cancel
126+
</button>
127+
<button
128+
type="button"
129+
className="btn btn-primary"
130+
onClick={() => onSave(buildActionString(clickType, type, params === '' ? null : params))}
131+
>
132+
Save
133+
</button>
134+
</>
135+
}
136+
>
137+
<label className="modal-label" htmlFor="action-click">
82138
Click
83-
<select
84-
value={clickType}
85-
onChange={event => setClickType(event.target.value)}
86-
className="inline-input"
87-
>
88-
{Object.entries(CLICK_TYPES).map(([value, label]) => (
89-
<option key={value} value={value}>
90-
{label}
91-
</option>
92-
))}
93-
</select>
94139
</label>
140+
<select
141+
id="action-click"
142+
className="modal-select"
143+
value={clickType}
144+
onChange={event => setClickType(event.target.value)}
145+
>
146+
{Object.entries(CLICK_TYPES).map(([value, label]) => (
147+
<option key={value} value={value}>
148+
{label}
149+
</option>
150+
))}
151+
</select>
95152

96-
<label>
153+
<label className="modal-label" htmlFor="action-type">
97154
Action
98-
<select
99-
value={type}
100-
onChange={event => {
101-
setType(event.target.value);
102-
setParams('');
103-
}}
104-
className="inline-input"
105-
>
106-
{Object.entries(ACTION_TYPES).map(([value, entry]) => (
107-
<option key={value} value={value}>
108-
{entry.name}
109-
</option>
110-
))}
111-
</select>
112155
</label>
156+
<select
157+
id="action-type"
158+
className="modal-select"
159+
value={type}
160+
onChange={event => {
161+
setType(event.target.value);
162+
setParams('');
163+
}}
164+
>
165+
{Object.entries(ACTION_TYPES).map(([value, entry]) => (
166+
<option key={value} value={value}>
167+
{entry.name}
168+
</option>
169+
))}
170+
</select>
171+
172+
{definition !== undefined && <p className="empty-note">{definition.description}</p>}
113173

114174
{definition?.hasParams && (
115-
<label>
116-
{definition.fields.map(field => field.label).join(' ; ')}
117-
<input
118-
value={params}
119-
onChange={event => setParams(event.target.value)}
120-
placeholder={definition.fields.map(field => field.placeholder).join(';')}
121-
className="inline-input"
122-
/>
123-
<span className="field-hint">{definition.fields[0]?.hint}</span>
124-
</label>
175+
<>
176+
<label className="modal-label" htmlFor="action-params">
177+
{definition.fields.map(field => field.label).join(' ; ')}
178+
</label>
179+
{definition.fields[0]?.type === 'textarea' ? (
180+
<textarea
181+
id="action-params"
182+
className="modal-input"
183+
rows={3}
184+
value={params}
185+
placeholder={definition.fields[0]?.placeholder}
186+
onChange={event => setParams(event.target.value)}
187+
/>
188+
) : (
189+
<input
190+
id="action-params"
191+
className="modal-input"
192+
value={params}
193+
placeholder={definition.fields.map(field => field.placeholder).join(';')}
194+
onChange={event => setParams(event.target.value)}
195+
/>
196+
)}
197+
<p className="empty-note">{definition.fields[0]?.hint}</p>
198+
</>
125199
)}
126-
127-
<button
128-
type="button"
129-
onClick={() => onSave(buildActionString(clickType, type, params === '' ? null : params))}
130-
className="btn btn-primary btn-sm"
131-
>
132-
Apply
133-
</button>
134-
</div>
200+
</Modal>
135201
);
136202
}
203+
204+
function move(actions: string[], from: number, to: number): string[] {
205+
const next = [...actions];
206+
const [moved] = next.splice(from, 1);
207+
208+
if (moved !== undefined) {
209+
next.splice(to, 0, moved);
210+
}
211+
212+
return next;
213+
}

resources/js/components/visual/AnimationEditor.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState } from 'react';
22
import { ItemIcon } from './ItemIcon';
3+
import { MaterialSelector } from './MaterialSelector';
34
import type { VisualAnimation, VisualItem, VisualJavaMenu } from '../../editor/model';
45

56
interface AnimationEditorProps {
@@ -148,6 +149,7 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
148149
{frames[frameIndex] !== undefined && active !== undefined && (
149150
<FrameFields
150151
frame={frames[frameIndex][1]}
152+
serverVersion={serverVersion}
151153
onChange={frame =>
152154
update({ ...active, frames: { ...active.frames, [frames[frameIndex][0]]: frame } })
153155
}
@@ -165,20 +167,21 @@ export function AnimationEditor({ menu, serverVersion, onChange }: AnimationEdit
165167

166168
function FrameFields({
167169
frame,
170+
serverVersion,
168171
onChange,
169172
onRemove,
170173
}: {
171174
frame: VisualItem;
175+
serverVersion: string | null;
172176
onChange: (frame: VisualItem) => void;
173177
onRemove: () => void;
174178
}) {
175179
return (
176180
<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"
181+
<MaterialSelector
182+
material={frame.material}
183+
serverVersion={serverVersion}
184+
onChange={material => onChange({ ...frame, material })}
182185
/>
183186
<input
184187
className="inline-input"

0 commit comments

Comments
 (0)