feat: enhance bottom controls and UI components

- Updated BottomControlsIsland for improved layout and styling.
- Refactored LayersSheet to enhance usability and visual consistency.
- Modified ToolOverlay for better tool selection experience.
- Improved BrushControls with new ColorPicker and Slider components.
- Enhanced PanControls and TransformControls for better user interaction.
- Updated ZoomControls for consistent button sizes and styles.
- Introduced new styles for bottom controls in styles.ts and index.css.
- Added BottomControlColorPicker, BottomControlSelectMenu, and BottomControlSlider components for better modularity and reusability.
This commit is contained in:
syntaxbullet
2026-07-03 22:28:34 +02:00
parent 4590b47e19
commit e8073e6146
16 changed files with 409 additions and 155 deletions

View File

@@ -1,9 +1,13 @@
import { Eraser, PaintBrush } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { MaskViewMode } from "@editor/state";
import type { BrushSettings, ToolId } from "@editor/tools";
import { BottomControlColorPicker } from "./ColorPicker";
import { BottomControlDivider } from "./Divider";
import { bottomControlLabelClass } from "./styles";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider";
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
export type BrushControlsProps = {
tool: Extract<ToolId, "brush" | "eraser">;
@@ -13,71 +17,71 @@ export type BrushControlsProps = {
dispatch: AppStore["dispatch"];
};
const maskViewModeOptions = [
{ value: "composite", label: "Image" },
{ value: "blackWhite", label: "B/W" },
{ value: "alpha", label: "Alpha" },
{ value: "overlay", label: "Overlay" },
] satisfies readonly BottomControlSelectOption<MaskViewMode>[];
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className="px-2 font-medium text-white/85">{editingMask ? `Mask · ${tool === "eraser" ? "Hide" : "Reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}</span>
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()} title={editingMask ? `Mask ${tool === "eraser" ? "hide" : "reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}>
{tool === "eraser" ? <Eraser size={24} weight="regular" /> : <PaintBrush size={24} weight="regular" />}
</span>
<BottomControlDivider />
{tool === "brush" && !editingMask ? (
<label className="flex items-center gap-1.5">
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>Color</span>
<input
type="color"
className="h-7 w-8 cursor-pointer rounded border-0 bg-transparent p-0"
<BottomControlColorPicker
value={settings.color}
aria-label="Brush color"
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { color: event.target.value })}
onValueChange={(color) => dispatch(commandIds.toolSetBrushSettings, { color })}
/>
</label>
) : null}
<label className="flex items-center gap-1.5">
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>Size</span>
<input
type="range"
<BottomControlSlider
min={1}
max={200}
value={settings.size}
className="w-24 accent-white"
className="w-48"
aria-label={`${tool} size`}
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { size: Number(event.target.value) })}
onValueChange={(size) => dispatch(commandIds.toolSetBrushSettings, { size })}
/>
<span className="w-8 text-right text-white">{Math.round(settings.size)}</span>
<span className="w-12 text-right text-base text-white">{Math.round(settings.size)}</span>
</label>
<BottomControlDivider />
<label className="flex items-center gap-1.5">
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>Hard</span>
<input
type="range"
<BottomControlSlider
min={0}
max={100}
value={settings.hardness}
className="w-24 accent-white"
className="w-48"
aria-label={`${tool} hardness`}
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { hardness: Number(event.target.value) })}
onValueChange={(hardness) => dispatch(commandIds.toolSetBrushSettings, { hardness })}
/>
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
<span className="w-12 text-right text-base text-white">{Math.round(settings.hardness)}</span>
</label>
{editingMask ? (
<>
<BottomControlDivider />
<label className="flex items-center gap-1.5">
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>View</span>
<select
className="h-7 rounded-full border border-white/10 bg-black/70 px-2 text-white outline-none"
<BottomControlSelectMenu
value={maskViewMode}
options={maskViewModeOptions}
aria-label="Mask view mode"
onChange={(event) => dispatch(commandIds.toolSetMaskViewMode, { mode: event.target.value as MaskViewMode })}
>
<option value="composite">Image</option>
<option value="blackWhite">B/W</option>
<option value="alpha">Alpha</option>
<option value="overlay">Overlay</option>
</select>
onValueChange={(mode) => dispatch(commandIds.toolSetMaskViewMode, { mode })}
/>
</label>
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-3 py-1 font-medium text-black transition hover:bg-white/90"
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90"
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
>
Done

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
export type BottomControlColorPickerProps = {
value: string;
"aria-label": string;
onValueChange: (value: string) => void;
};
export function BottomControlColorPicker({ value, onValueChange, ...props }: BottomControlColorPickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commitDraft = () => {
const normalized = normalizeHexColor(draft);
if (!normalized) {
setDraft(value);
return;
}
setDraft(normalized);
if (normalized !== value) onValueChange(normalized);
};
return (
<div className="flex items-center gap-3">
<button
type="button"
className="grid size-10 place-items-center rounded-full border border-white/40 bg-transparent text-white transition hover:border-white focus:outline-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-white"
aria-label={props["aria-label"]}
title={value}
onClick={() => inputRef.current?.click()}
>
<span className="size-6 rounded-full border border-white/25" style={{ backgroundColor: value }} />
</button>
<input
ref={inputRef}
type="color"
className="sr-only"
value={value}
aria-label={props["aria-label"]}
onChange={(event) => onValueChange(event.target.value)}
/>
<input
className="h-10 w-24 bg-transparent px-2 font-mono text-base uppercase text-white outline-none transition placeholder:text-white/30 focus:text-white"
value={draft}
aria-label={`${props["aria-label"]} hex value`}
spellCheck={false}
onChange={(event) => setDraft(event.target.value)}
onBlur={commitDraft}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
commitDraft();
event.currentTarget.blur();
}
if (event.key === "Escape") {
setDraft(value);
event.currentTarget.blur();
}
}}
/>
</div>
);
}
function normalizeHexColor(value: string): string | undefined {
const trimmed = value.trim();
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
const [r, g, b] = hex;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `#${hex}`.toLowerCase();
return undefined;
}

View File

@@ -1,6 +1,6 @@
import { Hand } from "@phosphor-icons/react";
import { BottomControlDivider } from "./Divider";
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass, bottomControlValueClass } from "./styles";
export type PanControlsProps = {
x: number;
@@ -9,9 +9,9 @@ export type PanControlsProps = {
export function PanControls({ x, y }: PanControlsProps) {
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()}>
<Hand size={16} weight="regular" />
<Hand size={24} weight="regular" />
</span>
<BottomControlDivider />
<span className={bottomControlLabelClass()}>X</span>

View File

@@ -0,0 +1,80 @@
import { Check, CaretDown } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
export type BottomControlSelectOption<TValue extends string> = {
value: TValue;
label: string;
};
export type BottomControlSelectMenuProps<TValue extends string> = {
value: TValue;
options: readonly BottomControlSelectOption<TValue>[];
"aria-label": string;
onValueChange: (value: TValue) => void;
};
export function BottomControlSelectMenu<TValue extends string>({ value, options, onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selectedOption = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="inline-flex h-10 min-w-32 items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
aria-label={props["aria-label"]}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span className="min-w-0 flex-1 px-4 text-left">{selectedOption?.label}</span>
<span className="grid size-10 flex-none place-items-center text-white/60">
<CaretDown size={18} weight="bold" />
</span>
</button>
{open ? (
<div className="absolute bottom-full left-0 z-30 mb-3 min-w-full rounded-[1.5rem] p-1 text-white backdrop-blur-xl" role="listbox" aria-label={props["aria-label"]}>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
role="option"
aria-selected={selected}
onClick={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
<span className="min-w-0 flex-1 whitespace-nowrap">{option.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { CSSProperties } from "react";
export type BottomControlSliderProps = {
"aria-label": string;
min: number;
max: number;
value: number;
step?: number;
className?: string;
onValueChange: (value: number) => void;
};
type SliderStyle = CSSProperties & {
"--slider-progress": string;
};
export function BottomControlSlider({ min, max, value, step = 1, className = "", onValueChange, ...props }: BottomControlSliderProps) {
const progress = max === min ? 0 : ((value - min) / (max - min)) * 100;
return (
<input
{...props}
type="range"
min={min}
max={max}
step={step}
value={value}
className={`bottom-control-slider ${className}`}
style={{ "--slider-progress": `${Math.max(0, Math.min(100, progress))}%` } as SliderStyle}
onChange={(event) => onValueChange(Number(event.target.value))}
/>
);
}

View File

@@ -5,7 +5,7 @@ import type { Rect } from "@core/geometry";
import type { AppStore } from "@editor/store";
import type { TransformTarget } from "@editor/transform";
import { BottomControlDivider } from "./Divider";
import { bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass } from "./styles";
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
export type TransformControlsProps = {
bounds: Rect;
@@ -39,9 +39,9 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
};
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()}>
<BoundingBox size={16} weight="regular" />
<BoundingBox size={24} weight="regular" />
</span>
<BottomControlDivider />
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
@@ -67,7 +67,7 @@ function BoundsInput({
commitField: (field: BoundsField) => void;
}) {
return (
<label className="flex items-center gap-1">
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>{label}</span>
<input
className={bottomControlInputClass()}

View File

@@ -2,7 +2,7 @@ import { Minus, CornersOut, Plus } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { BottomControlDivider } from "./Divider";
import { bottomControlButtonClass } from "./styles";
import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles";
export type ZoomControlsProps = {
zoom: number;
@@ -12,18 +12,18 @@ export type ZoomControlsProps = {
export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) {
return (
<>
<div className={bottomControlMenuClass()}>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}>
<Minus size={16} weight="regular" />
<Minus size={24} weight="regular" />
</button>
<span className="min-w-14 text-center tabular-nums text-white">{zoomPercent}%</span>
<span className={bottomControlValueClass()}>{zoomPercent}%</span>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}>
<Plus size={16} weight="regular" />
<Plus size={24} weight="regular" />
</button>
<BottomControlDivider />
<button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => dispatch(commandIds.viewportFitArtboard, undefined)}>
<CornersOut size={16} weight="regular" />
<CornersOut size={24} weight="regular" />
</button>
</>
</div>
);
}

View File

@@ -1,21 +1,29 @@
export function bottomControlMenuClass() {
return "flex w-full items-center justify-center gap-4 tabular-nums";
}
export function bottomControlFieldClass() {
return "flex items-center gap-3";
}
export function bottomControlDividerClass() {
return "mx-1 h-4 w-px bg-white/15";
return "h-8 w-px bg-white/15";
}
export function bottomControlLabelClass() {
return "text-white/60";
return "text-sm text-white/60";
}
export function bottomControlValueClass() {
return "min-w-10 text-center text-white";
return "min-w-16 text-center text-base text-white";
}
export function bottomControlInputClass() {
return "h-7 w-14 px-1 text-center text-white outline-none transition placeholder:text-white/30 focus:text-white";
return "h-10 w-20 px-2 text-center text-base text-white outline-none transition placeholder:text-white/30 focus:text-white";
}
export function bottomControlIconSlotClass() {
return "grid size-7 place-items-center rounded-full text-white/80";
return "grid size-12 place-items-center rounded-full text-white/80";
}
export function bottomControlButtonClass() {