castle-web-cli 0.4.84 → 0.4.86

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.
Files changed (90) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
  9. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  10. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  11. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  12. package/kits/basic-2d/engine/collider.js +60 -6
  13. package/kits/basic-2d/engine/scene.js +28 -2
  14. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  15. package/kits/basic-2d/engine/ui.jsx +2 -0
  16. package/kits/basic-2d/main.jsx +3 -2
  17. package/kits/physics-2d/.prettierrc +8 -0
  18. package/kits/physics-2d/CLAUDE.md +329 -0
  19. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  20. package/kits/physics-2d/behaviors/Collider.jsx +213 -0
  21. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  22. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  23. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  24. package/kits/physics-2d/behaviors/tint.js +47 -0
  25. package/kits/physics-2d/blueprints/ball.scene +14 -0
  26. package/kits/physics-2d/blueprints/block.scene +12 -0
  27. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  28. package/kits/physics-2d/blueprints/crate.scene +14 -0
  29. package/kits/physics-2d/blueprints/goal.scene +12 -0
  30. package/kits/physics-2d/castle.json +13 -0
  31. package/kits/physics-2d/docs/pxart-format.md +377 -0
  32. package/kits/physics-2d/drawings/block.pxart +25 -0
  33. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  34. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  35. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  36. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  37. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
  39. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  40. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  41. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  42. package/kits/physics-2d/editors/editorHistory.js +157 -0
  43. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  44. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  45. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  46. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  47. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  48. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  49. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  51. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  52. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  53. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  54. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  55. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  56. package/kits/physics-2d/engine/assets.js +15 -0
  57. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  58. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  59. package/kits/physics-2d/engine/blueprint.js +521 -0
  60. package/kits/physics-2d/engine/collider.js +200 -0
  61. package/kits/physics-2d/engine/files.js +117 -0
  62. package/kits/physics-2d/engine/liveReload.js +88 -0
  63. package/kits/physics-2d/engine/pxart.js +1032 -0
  64. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  65. package/kits/physics-2d/engine/scene.js +685 -0
  66. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  67. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  68. package/kits/physics-2d/engine/ui.jsx +688 -0
  69. package/kits/physics-2d/engine/ui.module.css +2287 -0
  70. package/kits/physics-2d/eslint.config.js +71 -0
  71. package/kits/physics-2d/index.html +24 -0
  72. package/kits/physics-2d/main.jsx +24 -0
  73. package/kits/physics-2d/package-lock.json +2706 -0
  74. package/kits/physics-2d/package.json +42 -0
  75. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  76. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  77. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  78. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  79. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  80. package/kits/physics-2d/physics/controls.js +79 -0
  81. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  82. package/kits/physics-2d/physics/index.js +26 -0
  83. package/kits/physics-2d/physics/matterBridge.js +126 -0
  84. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  85. package/kits/physics-2d/scenes/main.scene +12 -0
  86. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  87. package/kits/physics-2d/scripts/draw.mjs +121 -0
  88. package/kits/physics-2d/systems/physics.js +8 -0
  89. package/kits/physics-2d/vite.config.js +1 -0
  90. 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
+ }