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:
@@ -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
|
||||
|
||||
82
view/bottom-controls/ColorPicker.tsx
Normal file
82
view/bottom-controls/ColorPicker.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
80
view/bottom-controls/SelectMenu.tsx
Normal file
80
view/bottom-controls/SelectMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
view/bottom-controls/Slider.tsx
Normal file
33
view/bottom-controls/Slider.tsx
Normal 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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user