@zylem/ui 0.8.8 → 0.8.9

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 (39) hide show
  1. package/dist/chunk-V47CGFWX.js +1 -0
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.js +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.js +1 -1
  7. package/package.json +11 -2
  8. package/src/components/AddTile/AddTile.css.ts +78 -0
  9. package/src/components/AddTile/AddTile.tsx +37 -0
  10. package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
  11. package/src/components/BuildLabel/BuildLabel.tsx +37 -0
  12. package/src/components/BuildLabel/mount-build-label.ts +77 -0
  13. package/src/components/BuildLabel/resolve-build-version.ts +47 -0
  14. package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
  15. package/src/components/ColorPalette/ColorPalette.tsx +78 -0
  16. package/src/components/FrameStrip/FrameStrip.css.ts +132 -0
  17. package/src/components/FrameStrip/FrameStrip.tsx +118 -0
  18. package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
  19. package/src/components/PixelGrid/PixelGrid.tsx +48 -0
  20. package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
  21. package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
  22. package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
  23. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +161 -0
  24. package/src/components/SpriteSizePicker/snap-size.ts +28 -0
  25. package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
  26. package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
  27. package/src/components/TimelineTrack/timeline-math.ts +43 -0
  28. package/src/components/ToolButton/ToolButton.tsx +92 -0
  29. package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
  30. package/src/components/ToolGroup/ToolGroup.tsx +29 -0
  31. package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
  32. package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
  33. package/src/components/index.ts +68 -0
  34. package/src/global/hyperglass-base.css.ts +9 -6
  35. package/src/styles.ts +12 -0
  36. package/src/theme.css.ts +15 -0
  37. package/vite/collect-zylem-versions.ts +82 -0
  38. package/vite/index.ts +30 -0
  39. package/dist/chunk-3TP2SNNW.js +0 -1
@@ -0,0 +1,118 @@
1
+ import ChevronLeft from 'lucide-solid/icons/chevron-left';
2
+ import ChevronRight from 'lucide-solid/icons/chevron-right';
3
+ import Copy from 'lucide-solid/icons/copy';
4
+ import Trash2 from 'lucide-solid/icons/trash-2';
5
+ import { type JSX, Show, splitProps } from 'solid-js';
6
+ import { Button } from '../Button/Button';
7
+ import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
8
+
9
+ export interface FrameStripProps extends JSX.HTMLAttributes<HTMLDivElement> {
10
+ /** Caption, e.g. `Timeline`. */
11
+ label?: string;
12
+ /** Name of the animation the frames belong to, shown after the caption. */
13
+ title?: string;
14
+ /** `FrameThumb`s followed by an `AddTile variant="tall"`. */
15
+ children: JSX.Element;
16
+ }
17
+
18
+ /** Captioned, horizontally scrolling row of animation frames. */
19
+ export function FrameStrip(props: FrameStripProps) {
20
+ const [local, rest] = splitProps(props, ['label', 'title', 'children', 'class']);
21
+ return (
22
+ <div class={local.class ? `zylem-frame-strip ${local.class}` : 'zylem-frame-strip'} {...rest}>
23
+ <div class="zylem-frame-strip-header">
24
+ <span class="zylem-frame-strip-label">{local.label ?? 'Timeline'}</span>
25
+ <Show when={local.title}>
26
+ <span class="zylem-frame-strip-title">{local.title}</span>
27
+ </Show>
28
+ </div>
29
+ <div class="zylem-frame-strip-row zylem-scroll" role="listbox" aria-label={local.label ?? 'Frames'}>
30
+ {local.children}
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export interface FrameThumbProps {
37
+ /** One-based frame number shown in the corner badge. */
38
+ index: number;
39
+ selected?: boolean;
40
+ onClick?: (event: MouseEvent) => void;
41
+ /** Thumbnail content (a canvas or img); empty frames show the checker. */
42
+ children?: JSX.Element;
43
+ label?: string;
44
+ class?: string;
45
+ }
46
+
47
+ /** Square frame thumbnail on the checker with a frame-number badge. */
48
+ export function FrameThumb(props: FrameThumbProps) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ role="option"
53
+ class={props.class ? `zylem-frame-thumb ${props.class}` : 'zylem-frame-thumb'}
54
+ data-selected={props.selected ? '' : undefined}
55
+ aria-selected={props.selected ? 'true' : 'false'}
56
+ aria-label={props.label ?? `Frame ${props.index}`}
57
+ onClick={props.onClick}
58
+ >
59
+ <span class="zylem-frame-thumb-surface">{props.children}</span>
60
+ <span class="zylem-frame-thumb-index" aria-hidden="true">
61
+ {props.index}
62
+ </span>
63
+ </button>
64
+ );
65
+ }
66
+
67
+ export interface FrameActionsProps {
68
+ onDuplicate?: () => void;
69
+ onDelete?: () => void;
70
+ onPrev?: () => void;
71
+ onNext?: () => void;
72
+ canDuplicate?: boolean;
73
+ canDelete?: boolean;
74
+ canPrev?: boolean;
75
+ canNext?: boolean;
76
+ class?: string;
77
+ }
78
+
79
+ /**
80
+ * Frame commands under the strip: Duplicate and Delete, then step arrows
81
+ * whose chevrons are centered in square toolbar buttons.
82
+ */
83
+ export function FrameActions(props: FrameActionsProps) {
84
+ return (
85
+ <div class={props.class ? `zylem-frame-actions ${props.class}` : 'zylem-frame-actions'}>
86
+ <Button
87
+ variant="primary"
88
+ size="sm"
89
+ disabled={props.canDuplicate === false}
90
+ onClick={() => props.onDuplicate?.()}
91
+ >
92
+ <Copy class="zylem-icon" aria-hidden="true" />
93
+ Duplicate
94
+ </Button>
95
+ <Button size="sm" disabled={props.canDelete === false} onClick={() => props.onDelete?.()}>
96
+ <Trash2 class="zylem-icon" aria-hidden="true" />
97
+ Delete
98
+ </Button>
99
+ <span class="zylem-frame-actions-spacer" />
100
+ <ToolbarButton
101
+ label="Previous frame"
102
+ disabled={props.canPrev === false}
103
+ onClick={() => props.onPrev?.()}
104
+ class="zylem-frame-actions-step"
105
+ >
106
+ <ChevronLeft class="zylem-icon" />
107
+ </ToolbarButton>
108
+ <ToolbarButton
109
+ label="Next frame"
110
+ disabled={props.canNext === false}
111
+ onClick={() => props.onNext?.()}
112
+ class="zylem-frame-actions-step"
113
+ >
114
+ <ChevronRight class="zylem-icon" />
115
+ </ToolbarButton>
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,30 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ /**
5
+ * Checker pattern shared by every "transparent" surface in the art tools:
6
+ * the pixel grid, the transparent palette swatch, and empty frame thumbs.
7
+ * Cell size comes from `--zylem-pixel-grid-cell` so hosts can scale it.
8
+ */
9
+ export const checkerBackground = `repeating-conic-gradient(${vars.art.checkerLight} 0 25%, ${vars.art.checkerDark} 0 50%) 0 0 / calc(var(--zylem-pixel-grid-cell, 8px) * 2) calc(var(--zylem-pixel-grid-cell, 8px) * 2)`;
10
+
11
+ globalStyle('.zylem-pixel-grid', {
12
+ boxSizing: 'content-box',
13
+ position: 'relative',
14
+ flex: 'none',
15
+ background: checkerBackground,
16
+ border: `1px solid ${vars.art.canvasBorder}`,
17
+ imageRendering: 'pixelated',
18
+ overflow: 'hidden',
19
+ });
20
+
21
+ // Overlays fill the surface; a consumer canvas keeps its own pixel size and
22
+ // stretches to the zoomed box without smoothing.
23
+ globalStyle('.zylem-pixel-grid > canvas, .zylem-pixel-grid > img, .zylem-pixel-grid > svg', {
24
+ position: 'absolute',
25
+ inset: 0,
26
+ width: '100%',
27
+ height: '100%',
28
+ display: 'block',
29
+ imageRendering: 'pixelated',
30
+ });
@@ -0,0 +1,48 @@
1
+ import { type JSX, splitProps } from 'solid-js';
2
+
3
+ export interface PixelGridProps extends JSX.HTMLAttributes<HTMLDivElement> {
4
+ /** Sprite width in source pixels. */
5
+ width: number;
6
+ /** Sprite height in source pixels. */
7
+ height: number;
8
+ /** Screen pixels per source pixel. */
9
+ zoom?: number;
10
+ /** Checker cell size in screen pixels. */
11
+ checkerCell?: number;
12
+ /** Overlay content (a canvas, selection chrome); fills the surface. */
13
+ children?: JSX.Element;
14
+ }
15
+
16
+ /**
17
+ * Transparent-pixel checkerboard for the sprite editor. Sized to the sprite
18
+ * at the given zoom with a thin blue edge, so a consumer's canvas can sit on
19
+ * top with a transparent background and read against the checker.
20
+ */
21
+ export function PixelGrid(props: PixelGridProps) {
22
+ const [local, rest] = splitProps(props, [
23
+ 'width',
24
+ 'height',
25
+ 'zoom',
26
+ 'checkerCell',
27
+ 'children',
28
+ 'class',
29
+ 'style',
30
+ ]);
31
+ const zoom = () => local.zoom ?? 1;
32
+ const inlineStyle = (): JSX.CSSProperties => ({
33
+ width: `${local.width * zoom()}px`,
34
+ height: `${local.height * zoom()}px`,
35
+ '--zylem-pixel-grid-cell': `${local.checkerCell ?? 8}px`,
36
+ ...(typeof local.style === 'object' ? local.style : {}),
37
+ });
38
+
39
+ return (
40
+ <div
41
+ class={local.class ? `zylem-pixel-grid ${local.class}` : 'zylem-pixel-grid'}
42
+ style={inlineStyle()}
43
+ {...rest}
44
+ >
45
+ {local.children}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,53 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-playback', {
5
+ display: 'flex',
6
+ alignItems: 'center',
7
+ gap: vars.spacing.md,
8
+ minWidth: 0,
9
+ fontFamily: vars.typography.fontFamily,
10
+ });
11
+
12
+ globalStyle('.zylem-playback-transport', {
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ gap: vars.spacing.xs,
16
+ });
17
+
18
+ globalStyle('.zylem-playback-btn', {
19
+ width: '34px',
20
+ padding: 0,
21
+ });
22
+
23
+ // Play is the primary control: a touch wider so the strip reads as a
24
+ // transport rather than three identical tool buttons.
25
+ globalStyle('.zylem-playback-btn--play', {
26
+ width: '44px',
27
+ });
28
+
29
+ globalStyle('.zylem-playback-btn .zylem-icon', {
30
+ fill: 'currentColor',
31
+ fillOpacity: 0.85,
32
+ });
33
+
34
+ globalStyle('.zylem-playback-loop .zylem-icon', {
35
+ width: '14px',
36
+ height: '14px',
37
+ stroke: 'currentColor',
38
+ });
39
+
40
+ globalStyle('.zylem-playback-extra', {
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ gap: vars.spacing.sm,
44
+ minWidth: 0,
45
+ flex: '1 1 auto',
46
+ color: vars.colors.textSecondary,
47
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
48
+ });
49
+
50
+ // Compact fields inside the transport (FPS, duration).
51
+ globalStyle('.zylem-playback-extra .zylem-field-root', {
52
+ width: '72px',
53
+ });
@@ -0,0 +1,80 @@
1
+ import FastForward from 'lucide-solid/icons/fast-forward';
2
+ import Pause from 'lucide-solid/icons/pause';
3
+ import Play from 'lucide-solid/icons/play';
4
+ import Repeat from 'lucide-solid/icons/repeat';
5
+ import Rewind from 'lucide-solid/icons/rewind';
6
+ import { type JSX, Show } from 'solid-js';
7
+ import { Button } from '../Button/Button';
8
+ import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
9
+
10
+ export interface PlaybackControlsProps {
11
+ playing: boolean;
12
+ onTogglePlay: () => void;
13
+ onRewind?: () => void;
14
+ onFastForward?: () => void;
15
+ /** When defined, a Loop toggle follows the transport buttons. */
16
+ loop?: boolean;
17
+ onToggleLoop?: () => void;
18
+ disabled?: boolean;
19
+ /** Trailing controls: an FPS field, a time readout, a duration field. */
20
+ children?: JSX.Element;
21
+ class?: string;
22
+ }
23
+
24
+ /**
25
+ * Transport strip: rewind, play/pause, fast-forward as glass toolbar
26
+ * buttons, an optional Loop toggle, then whatever the host wants alongside.
27
+ */
28
+ export function PlaybackControls(props: PlaybackControlsProps) {
29
+ return (
30
+ <div
31
+ class={props.class ? `zylem-playback ${props.class}` : 'zylem-playback'}
32
+ role="group"
33
+ aria-label="Playback"
34
+ >
35
+ <div class="zylem-playback-transport">
36
+ <ToolbarButton
37
+ label="Rewind"
38
+ disabled={props.disabled || !props.onRewind}
39
+ onClick={() => props.onRewind?.()}
40
+ class="zylem-playback-btn"
41
+ >
42
+ <Rewind class="zylem-icon" />
43
+ </ToolbarButton>
44
+ <ToolbarButton
45
+ label={props.playing ? 'Pause' : 'Play'}
46
+ selected={props.playing}
47
+ disabled={props.disabled}
48
+ onClick={() => props.onTogglePlay()}
49
+ class="zylem-playback-btn zylem-playback-btn--play"
50
+ >
51
+ {props.playing ? <Pause class="zylem-icon" /> : <Play class="zylem-icon" />}
52
+ </ToolbarButton>
53
+ <ToolbarButton
54
+ label="Fast forward"
55
+ disabled={props.disabled || !props.onFastForward}
56
+ onClick={() => props.onFastForward?.()}
57
+ class="zylem-playback-btn"
58
+ >
59
+ <FastForward class="zylem-icon" />
60
+ </ToolbarButton>
61
+ </div>
62
+ <Show when={props.loop !== undefined}>
63
+ <Button
64
+ size="sm"
65
+ variant={props.loop ? 'active' : 'default'}
66
+ aria-pressed={props.loop ? 'true' : 'false'}
67
+ disabled={props.disabled}
68
+ onClick={() => props.onToggleLoop?.()}
69
+ class="zylem-playback-loop"
70
+ >
71
+ <Repeat class="zylem-icon" aria-hidden="true" />
72
+ Loop
73
+ </Button>
74
+ </Show>
75
+ <Show when={props.children}>
76
+ <div class="zylem-playback-extra">{props.children}</div>
77
+ </Show>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,103 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ /**
5
+ * Sprite size picker: preset row, a 1:1 stage showing the real pixel
6
+ * footprint with edge/corner drag handles, and the optional W/H fields.
7
+ */
8
+ globalStyle('.zylem-size-picker', {
9
+ display: 'flex',
10
+ flexDirection: 'column',
11
+ gap: vars.spacing.md,
12
+ fontFamily: vars.typography.fontFamily,
13
+ });
14
+
15
+ globalStyle('.zylem-size-picker-presets', {
16
+ display: 'flex',
17
+ flexWrap: 'wrap',
18
+ gap: vars.spacing.sm,
19
+ });
20
+
21
+ globalStyle('.zylem-size-picker-presets .zylem-button', {
22
+ minWidth: '40px',
23
+ fontVariantNumeric: 'tabular-nums',
24
+ });
25
+
26
+ globalStyle('.zylem-size-picker-stage', {
27
+ boxSizing: 'border-box',
28
+ position: 'relative',
29
+ flex: 'none',
30
+ padding: '8px',
31
+ borderRadius: vars.radii.control,
32
+ border: '1px solid rgba(97, 166, 232, 0.28)',
33
+ background: vars.material.fieldGlass,
34
+ boxShadow: vars.effects.shadowInset,
35
+ overflow: 'hidden',
36
+ });
37
+
38
+ // Sized to the sprite at 1:1; the grid's own 1px border sits just outside.
39
+ globalStyle('.zylem-size-picker-frame', {
40
+ position: 'relative',
41
+ boxSizing: 'content-box',
42
+ });
43
+
44
+ // Handles: slim glass bars along the right and bottom edges plus a corner
45
+ // jewel. Hit areas straddle the edge so small sprites are still grabbable.
46
+ globalStyle('.zylem-size-picker-handle', {
47
+ position: 'absolute',
48
+ boxSizing: 'border-box',
49
+ borderRadius: '999px',
50
+ border: '1px solid rgba(178, 219, 255, 0.85)',
51
+ background:
52
+ 'linear-gradient(180deg, rgba(240, 250, 255, 0.96), rgba(140, 190, 235, 0.92))',
53
+ boxShadow: `inset 0 1px 0 rgba(255,255,255,0.75), 0 1px 4px rgba(0,0,0,0.5), ${vars.effects.glowPrimary}`,
54
+ touchAction: 'none',
55
+ transition: `box-shadow ${vars.motion.fast} ${vars.motion.easeOut}, transform ${vars.motion.fast} ${vars.motion.easeOut}`,
56
+ });
57
+
58
+ globalStyle('.zylem-size-picker-handle:hover, .zylem-size-picker-handle[data-dragging]', {
59
+ boxShadow: `inset 0 1px 0 rgba(255,255,255,0.75), 0 1px 4px rgba(0,0,0,0.5), ${vars.effects.focusRing}`,
60
+ });
61
+
62
+ globalStyle('.zylem-size-picker-handle[data-edge="right"]', {
63
+ top: '50%',
64
+ right: '-6px',
65
+ width: '8px',
66
+ height: 'min(28px, 60%)',
67
+ transform: 'translateY(-50%)',
68
+ cursor: 'ew-resize',
69
+ });
70
+
71
+ globalStyle('.zylem-size-picker-handle[data-edge="bottom"]', {
72
+ left: '50%',
73
+ bottom: '-6px',
74
+ width: 'min(28px, 60%)',
75
+ height: '8px',
76
+ transform: 'translateX(-50%)',
77
+ cursor: 'ns-resize',
78
+ });
79
+
80
+ globalStyle('.zylem-size-picker-handle[data-edge="corner"]', {
81
+ right: '-7px',
82
+ bottom: '-7px',
83
+ width: '14px',
84
+ height: '14px',
85
+ cursor: 'nwse-resize',
86
+ });
87
+
88
+ globalStyle('.zylem-size-picker-readout', {
89
+ position: 'absolute',
90
+ right: vars.spacing.sm,
91
+ bottom: vars.spacing.xs,
92
+ fontSize: `calc(${vars.typography.fontSize} - 2px)`,
93
+ fontVariantNumeric: 'tabular-nums',
94
+ color: vars.colors.primary,
95
+ textShadow: '0 1px 1px rgba(0,0,0,0.6)',
96
+ pointerEvents: 'none',
97
+ });
98
+
99
+ globalStyle('.zylem-size-picker-fields', {
100
+ display: 'grid',
101
+ gridTemplateColumns: '1fr 1fr',
102
+ gap: vars.spacing.md,
103
+ });
@@ -0,0 +1,161 @@
1
+ import { For, Show, onCleanup } from 'solid-js';
2
+ import { Button } from '../Button/Button';
3
+ import { NumberField } from '../NumberField/NumberField';
4
+ import { PixelGrid } from '../PixelGrid/PixelGrid';
5
+ import { SPRITE_SIZE_PRESETS, type SpriteSize, snapSize } from './snap-size';
6
+
7
+ export interface SpriteSizePickerProps {
8
+ width: number;
9
+ height: number;
10
+ onChange: (size: SpriteSize) => void;
11
+ /** Square presets; each sets both dimensions. */
12
+ presets?: readonly number[];
13
+ /** Smallest dimension reachable by dragging or typing. */
14
+ min?: number;
15
+ /** Largest dimension; also sets the size of the 1:1 preview stage. */
16
+ max?: number;
17
+ /** Drag increment in source pixels. */
18
+ step?: number;
19
+ /** Render Width / Height number fields under the preview. */
20
+ showFields?: boolean;
21
+ class?: string;
22
+ }
23
+
24
+ type Edge = 'right' | 'bottom' | 'corner';
25
+
26
+ /** Extra room around the stage so the largest size still shows its handles. */
27
+ const STAGE_GUTTER = 24;
28
+
29
+ /**
30
+ * Sprite canvas size chooser: power-of-two presets, a 1:1 preview of the
31
+ * actual pixel footprint with drag handles that grow it in `step` increments,
32
+ * and optional numeric fields for exact values.
33
+ */
34
+ export function SpriteSizePicker(props: SpriteSizePickerProps) {
35
+ const min = () => props.min ?? 2;
36
+ const max = () => props.max ?? 256;
37
+ const step = () => props.step ?? 2;
38
+ const presets = () => props.presets ?? SPRITE_SIZE_PRESETS;
39
+ const snap = (value: number) => snapSize(value, { min: min(), max: max(), step: step() });
40
+
41
+ const emit = (width: number, height: number) => {
42
+ if (width !== props.width || height !== props.height) {
43
+ props.onChange({ width, height });
44
+ }
45
+ };
46
+
47
+ let release: (() => void) | undefined;
48
+ onCleanup(() => release?.());
49
+
50
+ const startDrag = (edge: Edge) => (event: PointerEvent) => {
51
+ if (event.button !== 0) return;
52
+ const handle = event.currentTarget as HTMLElement;
53
+ const originX = event.clientX;
54
+ const originY = event.clientY;
55
+ const startWidth = props.width;
56
+ const startHeight = props.height;
57
+ // Capture keeps the drag alive when the pointer outruns the handle; a
58
+ // synthetic event has no active pointer to capture, so tolerate that.
59
+ try {
60
+ handle.setPointerCapture(event.pointerId);
61
+ } catch {
62
+ /* no active pointer */
63
+ }
64
+ handle.dataset.dragging = '';
65
+
66
+ const onMove = (move: PointerEvent) => {
67
+ const dx = move.clientX - originX;
68
+ const dy = move.clientY - originY;
69
+ const width = edge === 'bottom' ? props.width : snap(startWidth + dx);
70
+ const height = edge === 'right' ? props.height : snap(startHeight + dy);
71
+ emit(width, height);
72
+ };
73
+ const onUp = () => release?.();
74
+ release = () => {
75
+ handle.removeEventListener('pointermove', onMove);
76
+ handle.removeEventListener('pointerup', onUp);
77
+ handle.removeEventListener('pointercancel', onUp);
78
+ delete handle.dataset.dragging;
79
+ if (handle.hasPointerCapture(event.pointerId)) {
80
+ handle.releasePointerCapture(event.pointerId);
81
+ }
82
+ release = undefined;
83
+ };
84
+ handle.addEventListener('pointermove', onMove);
85
+ handle.addEventListener('pointerup', onUp);
86
+ handle.addEventListener('pointercancel', onUp);
87
+ event.preventDefault();
88
+ };
89
+
90
+ const stageSize = () => `${max() + STAGE_GUTTER}px`;
91
+
92
+ return (
93
+ <div class={props.class ? `zylem-size-picker ${props.class}` : 'zylem-size-picker'}>
94
+ <div class="zylem-size-picker-presets" role="group" aria-label="Size presets">
95
+ <For each={presets()}>
96
+ {(preset) => (
97
+ <Button
98
+ size="sm"
99
+ variant={props.width === preset && props.height === preset ? 'primary' : 'default'}
100
+ aria-pressed={props.width === preset && props.height === preset}
101
+ onClick={() => emit(preset, preset)}
102
+ >
103
+ {preset}
104
+ </Button>
105
+ )}
106
+ </For>
107
+ </div>
108
+
109
+ <div class="zylem-size-picker-stage" style={{ width: stageSize(), height: stageSize() }}>
110
+ <div
111
+ class="zylem-size-picker-frame"
112
+ style={{ width: `${props.width}px`, height: `${props.height}px` }}
113
+ >
114
+ <PixelGrid width={props.width} height={props.height} checkerCell={4} />
115
+ <div
116
+ class="zylem-size-picker-handle"
117
+ data-edge="right"
118
+ aria-hidden="true"
119
+ onPointerDown={startDrag('right')}
120
+ />
121
+ <div
122
+ class="zylem-size-picker-handle"
123
+ data-edge="bottom"
124
+ aria-hidden="true"
125
+ onPointerDown={startDrag('bottom')}
126
+ />
127
+ <div
128
+ class="zylem-size-picker-handle"
129
+ data-edge="corner"
130
+ aria-hidden="true"
131
+ onPointerDown={startDrag('corner')}
132
+ />
133
+ </div>
134
+ <output class="zylem-size-picker-readout" aria-live="polite">
135
+ {props.width} × {props.height} px
136
+ </output>
137
+ </div>
138
+
139
+ <Show when={props.showFields ?? true}>
140
+ <div class="zylem-size-picker-fields">
141
+ <NumberField
142
+ label="Width"
143
+ value={props.width}
144
+ minValue={min()}
145
+ maxValue={max()}
146
+ step={step()}
147
+ onChange={(width) => emit(snap(width), props.height)}
148
+ />
149
+ <NumberField
150
+ label="Height"
151
+ value={props.height}
152
+ minValue={min()}
153
+ maxValue={max()}
154
+ step={step()}
155
+ onChange={(height) => emit(props.width, snap(height))}
156
+ />
157
+ </div>
158
+ </Show>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,28 @@
1
+ export interface SnapOptions {
2
+ min: number;
3
+ max: number;
4
+ step: number;
5
+ }
6
+
7
+ /** Sprite dimensions in source pixels. */
8
+ export interface SpriteSize {
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ /** Powers of two from 8 through 256; the default preset row. */
14
+ export const SPRITE_SIZE_PRESETS: readonly number[] = [8, 16, 32, 64, 128, 256];
15
+
16
+ /**
17
+ * Round a dragged dimension to the nearest step and keep it inside the
18
+ * allowed range. Non-finite input (a NaN from an empty field) lands on `min`.
19
+ */
20
+ export function snapSize(value: number, options: SnapOptions): number {
21
+ const { min, max, step } = options;
22
+ if (!Number.isFinite(value)) {
23
+ return min;
24
+ }
25
+ const unit = step > 0 ? step : 1;
26
+ const snapped = Math.round(value / unit) * unit;
27
+ return Math.min(max, Math.max(min, snapped));
28
+ }