@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.
- package/dist/chunk-V47CGFWX.js +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.js +1 -1
- package/package.json +11 -2
- package/src/components/AddTile/AddTile.css.ts +78 -0
- package/src/components/AddTile/AddTile.tsx +37 -0
- package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
- package/src/components/BuildLabel/BuildLabel.tsx +37 -0
- package/src/components/BuildLabel/mount-build-label.ts +77 -0
- package/src/components/BuildLabel/resolve-build-version.ts +47 -0
- package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
- package/src/components/ColorPalette/ColorPalette.tsx +78 -0
- package/src/components/FrameStrip/FrameStrip.css.ts +132 -0
- package/src/components/FrameStrip/FrameStrip.tsx +118 -0
- package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
- package/src/components/PixelGrid/PixelGrid.tsx +48 -0
- package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
- package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +161 -0
- package/src/components/SpriteSizePicker/snap-size.ts +28 -0
- package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
- package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
- package/src/components/TimelineTrack/timeline-math.ts +43 -0
- package/src/components/ToolButton/ToolButton.tsx +92 -0
- package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
- package/src/components/ToolGroup/ToolGroup.tsx +29 -0
- package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
- package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
- package/src/components/index.ts +68 -0
- package/src/global/hyperglass-base.css.ts +9 -6
- package/src/styles.ts +12 -0
- package/src/theme.css.ts +15 -0
- package/vite/collect-zylem-versions.ts +82 -0
- package/vite/index.ts +30 -0
- 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
|
+
}
|