castle-web-cli 0.4.83 → 0.4.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +26 -0
- package/dist/agent.js +317 -74
- package/dist/ide.js +35 -13
- package/dist/native/loop.js +35 -0
- package/dist/native/openrouter.d.ts +7 -0
- package/dist/native/openrouter.js +25 -1
- package/dist/native/types.d.ts +2 -0
- package/dist/native/types.js +0 -38
- package/dist/openrouter-catalog.d.ts +28 -0
- package/dist/openrouter-catalog.js +299 -0
- package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
- package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { faEye, faEyeSlash, faLink, faStopwatch } from '@fortawesome/free-solid-svg-icons';
|
|
3
|
+
import { colorForKeyV2, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
|
|
4
|
+
import { Icon, IconButton, styles as ui } from '../engine/ui';
|
|
5
|
+
import { cellKind, forEachGridCell, setupSpriteCanvas } from './pxArtEditorModel';
|
|
6
|
+
import tl from './pxArtTimeline.module.css';
|
|
7
|
+
|
|
8
|
+
// Tags and per-frame duration are intentionally hidden for now — we plan to
|
|
9
|
+
// bring these UIs back later. Flip a flag to true to restore its rendering; the
|
|
10
|
+
// underlying SDK semantics and model actions (setFrameDuration, tag ops, …)
|
|
11
|
+
// stay fully wired either way, so this is a small, easily-revertible toggle.
|
|
12
|
+
const SHOW_TAGS = false;
|
|
13
|
+
const SHOW_PER_FRAME_DURATION = false;
|
|
14
|
+
|
|
15
|
+
// The onion-skin look-ahead/behind range input is hidden for now; onion renders
|
|
16
|
+
// a single neighbouring frame using ONION_RANGE. The capability and model action
|
|
17
|
+
// (setOnionRange) stay fully wired — flipping SHOW_ONION_RANGE back to true
|
|
18
|
+
// re-renders the count input, so this is a trivial, easily-revertible toggle.
|
|
19
|
+
const SHOW_ONION_RANGE = false;
|
|
20
|
+
const ONION_RANGE = 1;
|
|
21
|
+
|
|
22
|
+
// faClapperboard ships in Font Awesome 6, but this kit is pinned to FA5 (see
|
|
23
|
+
// package.json), which doesn't export it. Rather than silently swap in a
|
|
24
|
+
// different-meaning icon, inline the exact FA6 solid clapperboard glyph
|
|
25
|
+
// (viewBox 0 0 512 512) in the same shape <FaGlyph> consumes (an FA icon
|
|
26
|
+
// tuple: [w, h, ligatures, unicode, path]). Drop-in once the kit moves to FA6.
|
|
27
|
+
const faClapperboard = {
|
|
28
|
+
icon: [
|
|
29
|
+
512,
|
|
30
|
+
512,
|
|
31
|
+
[],
|
|
32
|
+
'',
|
|
33
|
+
'M448 32l-86.1 0-1 1-127 127 92.1 0 1-1L453.8 32.3c-1.9-.2-3.8-.3-5.8-.3zm64 128l0-64c0-15.1-5.3-29.1-14-40l-104 104L512 160zM294.1 32l-92.1 0-1 1L73.9 160l92.1 0 1-1 127-127zM64 32C28.7 32 0 60.7 0 96l0 64 6.1 0 1-1 127-127L64 32zM512 192L0 192 0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-224z',
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// User-resizable timeline height (persisted per-`.pxart`-editor). The panel is
|
|
38
|
+
// bottom-anchored in the editor's left column, so dragging the top handle up
|
|
39
|
+
// makes the timeline taller and the artboard shorter. Clamped so the timeline
|
|
40
|
+
// headers stay usable (min) and the artboard isn't crushed (max fraction).
|
|
41
|
+
const TIMELINE_HEIGHT_KEY = 'castle-pxart-timeline-height';
|
|
42
|
+
const MIN_TIMELINE_HEIGHT = 120;
|
|
43
|
+
const DEFAULT_TIMELINE_HEIGHT = 320;
|
|
44
|
+
const MAX_TIMELINE_FRACTION = 0.7;
|
|
45
|
+
|
|
46
|
+
function readStoredTimelineHeight() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = localStorage.getItem(TIMELINE_HEIGHT_KEY);
|
|
49
|
+
if (raw === null) return DEFAULT_TIMELINE_HEIGHT;
|
|
50
|
+
const parsed = Number.parseInt(raw, 10);
|
|
51
|
+
return Number.isFinite(parsed) ? parsed : DEFAULT_TIMELINE_HEIGHT;
|
|
52
|
+
} catch {
|
|
53
|
+
return DEFAULT_TIMELINE_HEIGHT;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Owns the timeline's drag-to-resize height. Mirrors the CLI dev shell's
|
|
58
|
+
// docked-panel resize model (pointer capture via window listeners, clamp on
|
|
59
|
+
// drag, persist on release, re-clamp on container resize) but on the vertical
|
|
60
|
+
// axis. Returns the section ref, the current height, a dragging flag, and the
|
|
61
|
+
// pointer-down starter to wire onto the top handle.
|
|
62
|
+
function useResizableTimelineHeight() {
|
|
63
|
+
const sectionRef = useRef(null);
|
|
64
|
+
const [height, setHeight] = useState(readStoredTimelineHeight);
|
|
65
|
+
const [dragging, setDragging] = useState(false);
|
|
66
|
+
|
|
67
|
+
const clamp = useCallback((next) => {
|
|
68
|
+
const parent = sectionRef.current?.parentElement;
|
|
69
|
+
const available = parent ? parent.clientHeight : Number.POSITIVE_INFINITY;
|
|
70
|
+
const max = Math.max(MIN_TIMELINE_HEIGHT, available * MAX_TIMELINE_FRACTION);
|
|
71
|
+
const value = Number.isFinite(next) ? next : MIN_TIMELINE_HEIGHT;
|
|
72
|
+
return Math.max(MIN_TIMELINE_HEIGHT, Math.min(value, max));
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
// Clamp against the live container on mount and whenever it resizes, so a
|
|
76
|
+
// stored height never exceeds the current max fraction.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const reclamp = () => setHeight((current) => clamp(current));
|
|
79
|
+
reclamp();
|
|
80
|
+
window.addEventListener('resize', reclamp);
|
|
81
|
+
return () => window.removeEventListener('resize', reclamp);
|
|
82
|
+
}, [clamp]);
|
|
83
|
+
|
|
84
|
+
// Drag session: listeners live on window (not the handle) so the pointer can
|
|
85
|
+
// leave the thin handle mid-drag. Re-registered only when `dragging` flips,
|
|
86
|
+
// so the cleanup always removes the exact handlers it added (no stale
|
|
87
|
+
// closures). The timeline's bottom edge is fixed, so height = bottom - y.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!dragging) return undefined;
|
|
90
|
+
const onMove = (event) => {
|
|
91
|
+
const section = sectionRef.current;
|
|
92
|
+
if (!section) return;
|
|
93
|
+
const bottom = section.getBoundingClientRect().bottom;
|
|
94
|
+
setHeight(clamp(bottom - event.clientY));
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
};
|
|
97
|
+
const stop = () => setDragging(false);
|
|
98
|
+
window.addEventListener('pointermove', onMove);
|
|
99
|
+
window.addEventListener('pointerup', stop);
|
|
100
|
+
window.addEventListener('pointercancel', stop);
|
|
101
|
+
document.body.style.cursor = 'row-resize';
|
|
102
|
+
document.body.style.userSelect = 'none';
|
|
103
|
+
return () => {
|
|
104
|
+
window.removeEventListener('pointermove', onMove);
|
|
105
|
+
window.removeEventListener('pointerup', stop);
|
|
106
|
+
window.removeEventListener('pointercancel', stop);
|
|
107
|
+
document.body.style.cursor = '';
|
|
108
|
+
document.body.style.userSelect = '';
|
|
109
|
+
};
|
|
110
|
+
}, [dragging, clamp]);
|
|
111
|
+
|
|
112
|
+
// Persist the final height once a drag settles (and once on mount, harmless).
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (dragging) return;
|
|
115
|
+
try {
|
|
116
|
+
localStorage.setItem(TIMELINE_HEIGHT_KEY, String(Math.round(height)));
|
|
117
|
+
} catch {
|
|
118
|
+
/* storage disabled -- height just won't persist */
|
|
119
|
+
}
|
|
120
|
+
}, [dragging, height]);
|
|
121
|
+
|
|
122
|
+
const startDrag = (event) => {
|
|
123
|
+
setDragging(true);
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return { sectionRef, height, dragging, startDrag };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The layers x frames timeline below the artboard. Layer ROWS (topmost layer at
|
|
131
|
+
// the top, Aseprite-style) by frame COLUMNS, with thumbnails per cell, layer +
|
|
132
|
+
// frame controls, linked-cell affordances, onion-skin toggles, playback, and a
|
|
133
|
+
// tags editor. All mutation flows through `actions` (wired in PxArtEditor).
|
|
134
|
+
export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
|
|
135
|
+
const { sectionRef, height, dragging, startDrag } = useResizableTimelineHeight();
|
|
136
|
+
return (
|
|
137
|
+
<section ref={sectionRef} className={tl.timeline} style={{ height: `${height}px` }}>
|
|
138
|
+
<div
|
|
139
|
+
className={dragging ? tl.resizeHandle + ' ' + tl.resizeHandleActive : tl.resizeHandle}
|
|
140
|
+
role="separator"
|
|
141
|
+
aria-orientation="horizontal"
|
|
142
|
+
aria-label="Resize timeline"
|
|
143
|
+
onPointerDown={startDrag}
|
|
144
|
+
/>
|
|
145
|
+
<TimelineBar
|
|
146
|
+
sprite={sprite}
|
|
147
|
+
selection={selection}
|
|
148
|
+
playing={playing}
|
|
149
|
+
onion={onion}
|
|
150
|
+
actions={actions}
|
|
151
|
+
/>
|
|
152
|
+
<div className={tl.scroll}>
|
|
153
|
+
<TimelineGrid sprite={sprite} selection={selection} actions={actions} />
|
|
154
|
+
</div>
|
|
155
|
+
{SHOW_TAGS ? <TagsPanel sprite={sprite} actions={actions} /> : null}
|
|
156
|
+
</section>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Slimmed toolbar. Left group: new layer, new frame. A spacer pushes the
|
|
161
|
+
// right-anchored controls together in order: global duration input, onion-skin
|
|
162
|
+
// toggle, then play/pause at the far right. Every other per-item action
|
|
163
|
+
// (duplicate/move/delete/rename/link/unlink/visibility) lives in the right-click
|
|
164
|
+
// context menus on the grid's layer rows, frame headers, and cells (see
|
|
165
|
+
// TimelineGrid + ContextMenu).
|
|
166
|
+
function TimelineBar({ sprite, selection, playing, onion, actions }) {
|
|
167
|
+
const { layerIndex, frameIndex } = selection;
|
|
168
|
+
// A single-frame sprite has no animation, so the three animation controls
|
|
169
|
+
// (global duration, onion skin, play/pause) are meaningless — gate them on
|
|
170
|
+
// having more than one frame.
|
|
171
|
+
const isAnimated = sprite.frames.length > 1;
|
|
172
|
+
// If frames were deleted down to one while a preview was playing, the
|
|
173
|
+
// play/pause button is now hidden, so stop playback to avoid being stuck in a
|
|
174
|
+
// (no-op) playing state with no visible control. togglePlay flips the flag, so
|
|
175
|
+
// calling it once while `playing` settles it to paused.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (playing && !isAnimated) actions.togglePlay();
|
|
178
|
+
}, [playing, isAnimated, actions]);
|
|
179
|
+
return (
|
|
180
|
+
<div className={tl.bar}>
|
|
181
|
+
<div className={tl.barGroup}>
|
|
182
|
+
<IconButton icon="layer-group" label="Layer" onClick={() => actions.addLayer(layerIndex)} />
|
|
183
|
+
<span className={tl.barLabel}>Layer</span>
|
|
184
|
+
</div>
|
|
185
|
+
<div className={tl.barGroup}>
|
|
186
|
+
<IconButton icon="film" label="Frame" onClick={() => actions.addFrame(frameIndex)} />
|
|
187
|
+
<span className={tl.barLabel}>Frame</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div className={tl.barSpacer} />
|
|
190
|
+
{isAnimated ? (
|
|
191
|
+
<>
|
|
192
|
+
<div className={tl.barGroup}>
|
|
193
|
+
<span className={tl.durationIcon} title="Default frame duration (ms) — applies to all frames">
|
|
194
|
+
<FaGlyph icon={faStopwatch} className={tl.durationGlyph} />
|
|
195
|
+
</span>
|
|
196
|
+
<GlobalDurationInput value={sprite.defaultDurationMs} onCommit={actions.setDefaultDuration} />
|
|
197
|
+
</div>
|
|
198
|
+
<OnionControls onion={onion} actions={actions} />
|
|
199
|
+
<div className={tl.barGroup}>
|
|
200
|
+
<IconButton
|
|
201
|
+
icon={playing ? 'stop' : 'play'}
|
|
202
|
+
label={playing ? 'Pause preview' : 'Play preview'}
|
|
203
|
+
active={playing}
|
|
204
|
+
onClick={actions.togglePlay}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</>
|
|
208
|
+
) : null}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The sprite's GLOBAL duration. Commits a finite, positive value on blur/Enter;
|
|
214
|
+
// an empty/invalid entry snaps back to the current value (see CommitInput).
|
|
215
|
+
function GlobalDurationInput({ value, onCommit }) {
|
|
216
|
+
return (
|
|
217
|
+
<CommitInput
|
|
218
|
+
text={String(value)}
|
|
219
|
+
className={tl.globalDuration}
|
|
220
|
+
title="Global frame duration (ms) — inherited by every frame without its own override"
|
|
221
|
+
onCommit={(draft) => {
|
|
222
|
+
const ms = Number(draft);
|
|
223
|
+
if (Number.isFinite(ms) && ms > 0) onCommit(ms);
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Onion skin as a single icon toggle (clapperboard), styled like the toolbar's
|
|
230
|
+
// other IconButtons with an active state when enabled. Enabling pins the range
|
|
231
|
+
// to ONION_RANGE so the (hidden) count is always 1; the range input is gated
|
|
232
|
+
// behind SHOW_ONION_RANGE and the setOnionRange action stays wired for an easy
|
|
233
|
+
// revert.
|
|
234
|
+
function OnionControls({ onion, actions }) {
|
|
235
|
+
const onionClass = onion.enabled
|
|
236
|
+
? ui.iconButton + ' ' + ui.button + ' ' + ui.buttonActive
|
|
237
|
+
: ui.iconButton + ' ' + ui.button;
|
|
238
|
+
const toggleOnion = () => {
|
|
239
|
+
const next = !onion.enabled;
|
|
240
|
+
actions.setOnionEnabled(next);
|
|
241
|
+
if (next) actions.setOnionRange(ONION_RANGE);
|
|
242
|
+
};
|
|
243
|
+
return (
|
|
244
|
+
<div className={tl.barGroup}>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={onionClass}
|
|
248
|
+
aria-label={onion.enabled ? 'Onion skin on' : 'Onion skin off'}
|
|
249
|
+
aria-pressed={onion.enabled}
|
|
250
|
+
title={onion.enabled ? 'Onion skin on' : 'Onion skin off'}
|
|
251
|
+
onClick={toggleOnion}>
|
|
252
|
+
<FaGlyph icon={faClapperboard} />
|
|
253
|
+
</button>
|
|
254
|
+
{SHOW_ONION_RANGE ? (
|
|
255
|
+
<input
|
|
256
|
+
className={tl.miniInput}
|
|
257
|
+
type="number"
|
|
258
|
+
min="1"
|
|
259
|
+
max="8"
|
|
260
|
+
value={onion.range}
|
|
261
|
+
disabled={!onion.enabled}
|
|
262
|
+
onChange={(event) => actions.setOnionRange(Number(event.target.value))}
|
|
263
|
+
/>
|
|
264
|
+
) : null}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function TimelineGrid({ sprite, selection, actions }) {
|
|
270
|
+
const frames = sprite.frames;
|
|
271
|
+
// Render rows top-down: the last layer (top of the composite stack) first.
|
|
272
|
+
const rows = sprite.layers.map((_, i) => i).reverse();
|
|
273
|
+
const [menu, setMenu] = useState(null);
|
|
274
|
+
const closeMenu = useCallback(() => setMenu(null), []);
|
|
275
|
+
|
|
276
|
+
// Each opener stops the browser's native menu, anchors at the cursor, and
|
|
277
|
+
// stashes just enough to rebuild items from the latest props at render time
|
|
278
|
+
// (so relocated actions always run against current sprite/selection state).
|
|
279
|
+
const openLayerMenu = (event, layerIndex) => {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
event.stopPropagation();
|
|
282
|
+
setMenu({ kind: 'layer', x: event.clientX, y: event.clientY, layerIndex });
|
|
283
|
+
};
|
|
284
|
+
const openFrameMenu = (event, frameIndex) => {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
event.stopPropagation();
|
|
287
|
+
setMenu({ kind: 'frame', x: event.clientX, y: event.clientY, frameIndex });
|
|
288
|
+
};
|
|
289
|
+
const openCellMenu = (event, layerIndex, frameIndex) => {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
event.stopPropagation();
|
|
292
|
+
// Link/Unlink are bound to the active cell, so select this one first; the
|
|
293
|
+
// menu re-renders with rebuilt actions that target (layerIndex, frameIndex).
|
|
294
|
+
actions.selectCell(layerIndex, frameIndex);
|
|
295
|
+
setMenu({ kind: 'cell', x: event.clientX, y: event.clientY, layerIndex, frameIndex });
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className={tl.grid}>
|
|
300
|
+
<div className={tl.row}>
|
|
301
|
+
<div className={tl.corner} />
|
|
302
|
+
{frames.map((frame, f) => (
|
|
303
|
+
<FrameHeader
|
|
304
|
+
key={f}
|
|
305
|
+
frame={frame}
|
|
306
|
+
index={f}
|
|
307
|
+
globalMs={sprite.defaultDurationMs}
|
|
308
|
+
active={f === selection.frameIndex}
|
|
309
|
+
onSelect={() => actions.selectCell(selection.layerIndex, f)}
|
|
310
|
+
onDuration={(ms) => actions.setFrameDuration(f, ms)}
|
|
311
|
+
onContextMenu={(event) => openFrameMenu(event, f)}
|
|
312
|
+
/>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
{rows.map((li) => (
|
|
316
|
+
<div className={tl.row} key={sprite.layers[li].id}>
|
|
317
|
+
<LayerHeader
|
|
318
|
+
layer={sprite.layers[li]}
|
|
319
|
+
index={li}
|
|
320
|
+
active={li === selection.layerIndex}
|
|
321
|
+
onSelect={() => actions.selectCell(li, selection.frameIndex)}
|
|
322
|
+
actions={actions}
|
|
323
|
+
onContextMenu={openLayerMenu}
|
|
324
|
+
/>
|
|
325
|
+
{frames.map((_, f) => (
|
|
326
|
+
<CellThumb
|
|
327
|
+
key={f}
|
|
328
|
+
sprite={sprite}
|
|
329
|
+
layerIndex={li}
|
|
330
|
+
frameIndex={f}
|
|
331
|
+
active={li === selection.layerIndex && f === selection.frameIndex}
|
|
332
|
+
onSelect={() => actions.selectCell(li, f)}
|
|
333
|
+
onContextMenu={(event) => openCellMenu(event, li, f)}
|
|
334
|
+
/>
|
|
335
|
+
))}
|
|
336
|
+
</div>
|
|
337
|
+
))}
|
|
338
|
+
{menu ? (
|
|
339
|
+
<ContextMenu
|
|
340
|
+
x={menu.x}
|
|
341
|
+
y={menu.y}
|
|
342
|
+
items={buildMenuItems(menu, sprite, actions)}
|
|
343
|
+
onClose={closeMenu}
|
|
344
|
+
/>
|
|
345
|
+
) : null}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cursor-anchored right-click menu. Reuses the scene editor's "Arrange" menu
|
|
351
|
+
// chrome (the shared `selArrangeMenu`/`selArrangeItem` classes from
|
|
352
|
+
// engine/ui.module.css) for visual consistency, switching to fixed positioning
|
|
353
|
+
// at the click point and closing on outside-click or Escape. Items are plain
|
|
354
|
+
// `{ key, label, disabled?, onClick }` descriptors built by buildMenuItems.
|
|
355
|
+
function ContextMenu({ x, y, items, onClose }) {
|
|
356
|
+
const ref = useRef(null);
|
|
357
|
+
const [pos, setPos] = useState({ left: x, top: y });
|
|
358
|
+
|
|
359
|
+
// Clamp into the viewport once measured so a menu opened near the bottom/right
|
|
360
|
+
// edge (the timeline is bottom-anchored) doesn't spill off-screen.
|
|
361
|
+
useLayoutEffect(() => {
|
|
362
|
+
const el = ref.current;
|
|
363
|
+
if (!el) return;
|
|
364
|
+
const rect = el.getBoundingClientRect();
|
|
365
|
+
const margin = 8;
|
|
366
|
+
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
367
|
+
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
368
|
+
setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
|
|
369
|
+
}, [x, y]);
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
const onPointerDown = (event) => {
|
|
373
|
+
if (!ref.current?.contains(event.target)) onClose();
|
|
374
|
+
};
|
|
375
|
+
const onKeyDown = (event) => {
|
|
376
|
+
if (event.key === 'Escape') onClose();
|
|
377
|
+
};
|
|
378
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
379
|
+
window.addEventListener('keydown', onKeyDown);
|
|
380
|
+
return () => {
|
|
381
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
382
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
383
|
+
};
|
|
384
|
+
}, [onClose]);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div
|
|
388
|
+
ref={ref}
|
|
389
|
+
className={ui.selArrangeMenu}
|
|
390
|
+
role="menu"
|
|
391
|
+
style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
|
|
392
|
+
onContextMenu={(event) => event.preventDefault()}>
|
|
393
|
+
{items.map((item) => (
|
|
394
|
+
<button
|
|
395
|
+
key={item.key}
|
|
396
|
+
type="button"
|
|
397
|
+
role="menuitem"
|
|
398
|
+
disabled={item.disabled}
|
|
399
|
+
className={item.disabled ? ui.selArrangeItem + ' ' + tl.menuItemDisabled : ui.selArrangeItem}
|
|
400
|
+
onClick={() => {
|
|
401
|
+
item.onClick();
|
|
402
|
+
onClose();
|
|
403
|
+
}}>
|
|
404
|
+
{item.label}
|
|
405
|
+
</button>
|
|
406
|
+
))}
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function buildMenuItems(menu, sprite, actions) {
|
|
412
|
+
if (menu.kind === 'layer') return layerMenuItems(menu, sprite, actions);
|
|
413
|
+
if (menu.kind === 'frame') return frameMenuItems(menu, sprite, actions);
|
|
414
|
+
return cellMenuItems(menu, sprite, actions);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function layerMenuItems(menu, sprite, actions) {
|
|
418
|
+
const { layerIndex } = menu;
|
|
419
|
+
return [
|
|
420
|
+
{ key: 'duplicate', label: 'Duplicate', onClick: () => actions.duplicateLayer(layerIndex) },
|
|
421
|
+
{
|
|
422
|
+
key: 'up',
|
|
423
|
+
label: 'Move up',
|
|
424
|
+
disabled: layerIndex >= sprite.layers.length - 1,
|
|
425
|
+
onClick: () => actions.moveLayer(layerIndex, 1),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
key: 'down',
|
|
429
|
+
label: 'Move down',
|
|
430
|
+
disabled: layerIndex <= 0,
|
|
431
|
+
onClick: () => actions.moveLayer(layerIndex, -1),
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
key: 'delete',
|
|
435
|
+
label: 'Delete',
|
|
436
|
+
disabled: sprite.layers.length <= 1,
|
|
437
|
+
onClick: () => actions.deleteLayer(layerIndex),
|
|
438
|
+
},
|
|
439
|
+
];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function frameMenuItems(menu, sprite, actions) {
|
|
443
|
+
const { frameIndex } = menu;
|
|
444
|
+
const overridden = Number.isFinite(sprite.frames[frameIndex]?.durationMs);
|
|
445
|
+
return [
|
|
446
|
+
{ key: 'duplicate', label: 'Duplicate', onClick: () => actions.duplicateFrame(frameIndex) },
|
|
447
|
+
{
|
|
448
|
+
key: 'left',
|
|
449
|
+
label: 'Move left',
|
|
450
|
+
disabled: frameIndex <= 0,
|
|
451
|
+
onClick: () => actions.moveFrame(frameIndex, -1),
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
key: 'right',
|
|
455
|
+
label: 'Move right',
|
|
456
|
+
disabled: frameIndex >= sprite.frames.length - 1,
|
|
457
|
+
onClick: () => actions.moveFrame(frameIndex, 1),
|
|
458
|
+
},
|
|
459
|
+
...(SHOW_PER_FRAME_DURATION
|
|
460
|
+
? [
|
|
461
|
+
{
|
|
462
|
+
key: 'reset-duration',
|
|
463
|
+
label: 'Reset duration to global',
|
|
464
|
+
disabled: !overridden,
|
|
465
|
+
onClick: () => actions.setFrameDuration(frameIndex, null),
|
|
466
|
+
},
|
|
467
|
+
]
|
|
468
|
+
: []),
|
|
469
|
+
{
|
|
470
|
+
key: 'delete',
|
|
471
|
+
label: 'Delete',
|
|
472
|
+
disabled: sprite.frames.length <= 1,
|
|
473
|
+
onClick: () => actions.deleteFrame(frameIndex),
|
|
474
|
+
},
|
|
475
|
+
];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Linking is a single bit: a cell is linked to the PREVIOUS frame (sharing its
|
|
479
|
+
// image, chain-aware) or standalone. So the menu offers ONE toggle — "Unlink"
|
|
480
|
+
// when linked, "Link to previous frame" otherwise. The link item is disabled on
|
|
481
|
+
// frame 0 (no previous) and when the previous frame is empty (nothing to share).
|
|
482
|
+
function cellMenuItems(menu, sprite, actions) {
|
|
483
|
+
const { layerIndex, frameIndex } = menu;
|
|
484
|
+
const layer = sprite.layers[layerIndex];
|
|
485
|
+
const isLinked = layer ? cellKind(layer, frameIndex) === 'link' : false;
|
|
486
|
+
if (isLinked) {
|
|
487
|
+
return [{ key: 'unlink', label: 'Unlink', onClick: () => actions.unlinkCell() }];
|
|
488
|
+
}
|
|
489
|
+
const canLink = !!layer && frameIndex > 0 && !!resolveCellGrid(layer, frameIndex - 1);
|
|
490
|
+
return [
|
|
491
|
+
{
|
|
492
|
+
key: 'link-prev',
|
|
493
|
+
label: 'Link to previous frame',
|
|
494
|
+
disabled: !canLink,
|
|
495
|
+
onClick: () => actions.linkCell(),
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function FrameHeader({ frame, index, globalMs, active, onSelect, onDuration, onContextMenu }) {
|
|
501
|
+
return (
|
|
502
|
+
<div
|
|
503
|
+
className={active ? tl.frameHead + ' ' + tl.frameHeadActive : tl.frameHead}
|
|
504
|
+
onContextMenu={onContextMenu}>
|
|
505
|
+
<button type="button" className={tl.frameNum} onClick={onSelect} style={btnReset}>
|
|
506
|
+
{index + 1}
|
|
507
|
+
</button>
|
|
508
|
+
{SHOW_PER_FRAME_DURATION ? (
|
|
509
|
+
<DurationInput value={frame.durationMs} globalMs={globalMs} onCommit={onDuration} />
|
|
510
|
+
) : null}
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const btnReset = { background: 'none', border: 0, color: 'inherit', cursor: 'pointer', padding: 0 };
|
|
516
|
+
|
|
517
|
+
// A free-form text input that holds a local draft while focused and commits it
|
|
518
|
+
// on blur/Enter, re-syncing to `text` when idle. The caller's `onCommit(draft)`
|
|
519
|
+
// interprets the raw string (set / clear / revert). Shared by the per-frame and
|
|
520
|
+
// global duration fields so their edit/commit behavior stays identical.
|
|
521
|
+
function CommitInput({ text, className, placeholder, title, onCommit }) {
|
|
522
|
+
const [draft, setDraft] = useState(text);
|
|
523
|
+
const editing = useRef(false);
|
|
524
|
+
if (!editing.current && draft !== text) setDraft(text);
|
|
525
|
+
function commit() {
|
|
526
|
+
editing.current = false;
|
|
527
|
+
onCommit(draft);
|
|
528
|
+
}
|
|
529
|
+
return (
|
|
530
|
+
<input
|
|
531
|
+
className={className}
|
|
532
|
+
value={draft}
|
|
533
|
+
placeholder={placeholder}
|
|
534
|
+
title={title}
|
|
535
|
+
inputMode="numeric"
|
|
536
|
+
onFocus={() => (editing.current = true)}
|
|
537
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
538
|
+
onBlur={commit}
|
|
539
|
+
onKeyDown={(event) => {
|
|
540
|
+
if (event.key === 'Enter') event.currentTarget.blur();
|
|
541
|
+
}}
|
|
542
|
+
/>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Per-frame duration. When the frame OVERRIDES the global it shows the explicit
|
|
547
|
+
// value (accented); when it INHERITS, the field is empty and shows the global
|
|
548
|
+
// value as a greyed placeholder. Typing a value sets an override; clearing the
|
|
549
|
+
// field (empty) reverts to inherit.
|
|
550
|
+
function DurationInput({ value, globalMs, onCommit }) {
|
|
551
|
+
const overridden = Number.isFinite(value);
|
|
552
|
+
return (
|
|
553
|
+
<CommitInput
|
|
554
|
+
text={overridden ? String(value) : ''}
|
|
555
|
+
className={overridden ? tl.durationInput + ' ' + tl.durationOverride : tl.durationInput}
|
|
556
|
+
placeholder={String(globalMs)}
|
|
557
|
+
title={
|
|
558
|
+
overridden
|
|
559
|
+
? 'Frame duration override (ms) — clear to inherit the global'
|
|
560
|
+
: `Inheriting global duration (${globalMs} ms)`
|
|
561
|
+
}
|
|
562
|
+
onCommit={(draft) => {
|
|
563
|
+
const trimmed = draft.trim();
|
|
564
|
+
if (trimmed === '') return onCommit(null);
|
|
565
|
+
const ms = Number(trimmed);
|
|
566
|
+
onCommit(Number.isFinite(ms) ? ms : null);
|
|
567
|
+
}}
|
|
568
|
+
/>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Inline Font Awesome glyph. engine/ui's <Icon> registry has no eye/eye-slash
|
|
573
|
+
// and lives outside this editor (which we shouldn't edit), so the layer
|
|
574
|
+
// visibility toggle renders the FA path directly here.
|
|
575
|
+
function FaGlyph({ icon, className = tl.visGlyph }) {
|
|
576
|
+
const [w, h, , , path] = icon.icon;
|
|
577
|
+
return (
|
|
578
|
+
<svg className={className} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
|
579
|
+
<path d={path} />
|
|
580
|
+
</svg>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu }) {
|
|
585
|
+
return (
|
|
586
|
+
<div
|
|
587
|
+
className={active ? tl.layerHead + ' ' + tl.layerHeadActive : tl.layerHead}
|
|
588
|
+
onClick={onSelect}
|
|
589
|
+
onContextMenu={(event) => onContextMenu(event, index)}>
|
|
590
|
+
<div className={tl.layerTopRow}>
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
className={layer.visible ? tl.visBtn : tl.visBtn + ' ' + tl.visOff}
|
|
594
|
+
title={layer.visible ? 'Hide layer' : 'Show layer'}
|
|
595
|
+
onClick={(event) => {
|
|
596
|
+
event.stopPropagation();
|
|
597
|
+
actions.patchLayer(index, { visible: !layer.visible });
|
|
598
|
+
}}>
|
|
599
|
+
<FaGlyph icon={layer.visible ? faEye : faEyeSlash} />
|
|
600
|
+
</button>
|
|
601
|
+
<span className={tl.name} title={layer.name}>
|
|
602
|
+
{layer.name}
|
|
603
|
+
</span>
|
|
604
|
+
</div>
|
|
605
|
+
<input
|
|
606
|
+
className={tl.opacity}
|
|
607
|
+
type="range"
|
|
608
|
+
min="0"
|
|
609
|
+
max="100"
|
|
610
|
+
value={Math.round(layer.opacity * 100)}
|
|
611
|
+
title={`Opacity ${Math.round(layer.opacity * 100)}%`}
|
|
612
|
+
onClick={(event) => event.stopPropagation()}
|
|
613
|
+
onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
|
|
614
|
+
/>
|
|
615
|
+
</div>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
|
|
620
|
+
const canvasRef = useRef(null);
|
|
621
|
+
const layer = sprite.layers[layerIndex];
|
|
622
|
+
const kind = cellKind(layer, frameIndex);
|
|
623
|
+
useEffect(() => {
|
|
624
|
+
if (canvasRef.current && kind !== 'empty') drawCellThumb(canvasRef.current, sprite, layerIndex, frameIndex);
|
|
625
|
+
});
|
|
626
|
+
return (
|
|
627
|
+
<button
|
|
628
|
+
type="button"
|
|
629
|
+
className={active ? tl.cell + ' ' + tl.cellActive : tl.cell}
|
|
630
|
+
title={`Layer ${layerIndex + 1}, frame ${frameIndex + 1} (${kind})`}
|
|
631
|
+
onClick={onSelect}
|
|
632
|
+
onContextMenu={onContextMenu}>
|
|
633
|
+
{kind === 'empty' ? (
|
|
634
|
+
<span className={tl.empty} />
|
|
635
|
+
) : (
|
|
636
|
+
<span className={kind === 'link' ? tl.thumb + ' ' + tl.linkRing : tl.thumb}>
|
|
637
|
+
<canvas ref={canvasRef} className={tl.thumbCanvas} />
|
|
638
|
+
</span>
|
|
639
|
+
)}
|
|
640
|
+
{kind === 'link' ? (
|
|
641
|
+
<span className={tl.linkBadge}>
|
|
642
|
+
<FaGlyph icon={faLink} />
|
|
643
|
+
</span>
|
|
644
|
+
) : null}
|
|
645
|
+
</button>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Paint a single (layer, frame) cell's resolved image onto a native-resolution
|
|
650
|
+
// canvas (1px/cell), following links. Used for timeline thumbnails. Offset-aware
|
|
651
|
+
// and clipping to match renderSpriteFrame: a cel resolves to { grid, x, y } (a
|
|
652
|
+
// link shares its source's image AND offset), and a source pixel (gx, gy) lands
|
|
653
|
+
// at canvas (gx + x, gy + y), skipped when outside [0, width) x [0, height).
|
|
654
|
+
// (Iterating the full grid via Infinity bounds, then clipping per pixel — the
|
|
655
|
+
// canvas window, not the source extent, is the clip.)
|
|
656
|
+
function drawCellThumb(canvas, sprite, layerIndex, frameIndex) {
|
|
657
|
+
const ctx = setupSpriteCanvas(canvas, sprite.resolution);
|
|
658
|
+
if (!ctx) return;
|
|
659
|
+
const resolved = resolveCell(sprite.layers[layerIndex], frameIndex);
|
|
660
|
+
if (!resolved) return;
|
|
661
|
+
const { width, height } = sprite.resolution;
|
|
662
|
+
const { grid, x: offsetX, y: offsetY } = resolved;
|
|
663
|
+
const lookup = paletteLookup(sprite.palette);
|
|
664
|
+
forEachGridCell(grid, Infinity, Infinity, (gx, gy, key) => {
|
|
665
|
+
const cx = gx + offsetX;
|
|
666
|
+
const cy = gy + offsetY;
|
|
667
|
+
if (cx < 0 || cx >= width || cy < 0 || cy >= height) return;
|
|
668
|
+
const color = colorForKeyV2(lookup, key);
|
|
669
|
+
if (color) {
|
|
670
|
+
ctx.fillStyle = color;
|
|
671
|
+
ctx.fillRect(cx, cy, 1, 1);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function TagsPanel({ sprite, actions }) {
|
|
677
|
+
return (
|
|
678
|
+
<div className={tl.tags}>
|
|
679
|
+
<div className={tl.tagsHead}>
|
|
680
|
+
<span className={tl.barLabel}>Tags</span>
|
|
681
|
+
<IconButton icon="plus" label="Add tag" onClick={actions.addTag} />
|
|
682
|
+
</div>
|
|
683
|
+
{sprite.tags.length === 0 ? <div className={tl.muted}>No tags.</div> : null}
|
|
684
|
+
{sprite.tags.map((tag, i) => (
|
|
685
|
+
<TagRow
|
|
686
|
+
key={i}
|
|
687
|
+
tag={tag}
|
|
688
|
+
index={i}
|
|
689
|
+
isDefault={sprite.defaultTag === tag.name}
|
|
690
|
+
actions={actions}
|
|
691
|
+
/>
|
|
692
|
+
))}
|
|
693
|
+
</div>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function TagRow({ tag, index, isDefault, actions }) {
|
|
698
|
+
return (
|
|
699
|
+
<div className={tl.tagRow}>
|
|
700
|
+
<button
|
|
701
|
+
type="button"
|
|
702
|
+
className={isDefault ? tl.tagDefault + ' ' + tl.tagDefaultOn : tl.tagDefault}
|
|
703
|
+
title={isDefault ? 'Default tag' : 'Make default tag'}
|
|
704
|
+
onClick={() => actions.setDefaultTag(isDefault ? '' : tag.name)}>
|
|
705
|
+
<Icon name="play" />
|
|
706
|
+
</button>
|
|
707
|
+
<input
|
|
708
|
+
className={tl.tagName}
|
|
709
|
+
value={tag.name}
|
|
710
|
+
onChange={(event) => actions.patchTag(index, { name: event.target.value })}
|
|
711
|
+
/>
|
|
712
|
+
<input
|
|
713
|
+
className={tl.tagNum}
|
|
714
|
+
type="number"
|
|
715
|
+
min="1"
|
|
716
|
+
value={tag.from + 1}
|
|
717
|
+
title="From frame"
|
|
718
|
+
onChange={(event) => actions.patchTag(index, { from: Number(event.target.value) - 1 })}
|
|
719
|
+
/>
|
|
720
|
+
<input
|
|
721
|
+
className={tl.tagNum}
|
|
722
|
+
type="number"
|
|
723
|
+
min="1"
|
|
724
|
+
value={tag.to + 1}
|
|
725
|
+
title="To frame"
|
|
726
|
+
onChange={(event) => actions.patchTag(index, { to: Number(event.target.value) - 1 })}
|
|
727
|
+
/>
|
|
728
|
+
<select
|
|
729
|
+
className={tl.tagSelect}
|
|
730
|
+
value={tag.direction}
|
|
731
|
+
onChange={(event) => actions.patchTag(index, { direction: event.target.value })}>
|
|
732
|
+
<option value="forward">forward</option>
|
|
733
|
+
<option value="reverse">reverse</option>
|
|
734
|
+
<option value="pingpong">pingpong</option>
|
|
735
|
+
</select>
|
|
736
|
+
<input
|
|
737
|
+
className={tl.tagNum}
|
|
738
|
+
type="number"
|
|
739
|
+
min="0"
|
|
740
|
+
value={tag.repeat}
|
|
741
|
+
title="Repeat (0 = loop)"
|
|
742
|
+
onChange={(event) => actions.patchTag(index, { repeat: Number(event.target.value) })}
|
|
743
|
+
/>
|
|
744
|
+
<IconButton
|
|
745
|
+
icon="trash"
|
|
746
|
+
label="Delete tag"
|
|
747
|
+
variant="danger"
|
|
748
|
+
onClick={() => actions.deleteTag(index)}
|
|
749
|
+
/>
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
}
|