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,5 +1,5 @@
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
@@ -30,48 +30,47 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
return (
<aside
aria-hidden={!open}
className={`pointer-events-auto absolute right-4 top-16 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
<header className="flex h-14 items-center justify-between border-b border-white/10 bg-white/[0.03] px-4">
<div>
<div className="font-medium tracking-wide">Layers</div>
<div className="text-xs text-white/40">Press L to open</div>
<header className="flex h-20 items-center">
<div className="flex w-full items-center gap-2">
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span>
</button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span>
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
<FolderPlus size={24} />
</button>
</div>
</header>
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
<button type="button" className={toolbarButtonClass()} onClick={() => addArtboard(document, dispatch)}>
<Plus size={16} /> Artboard
<div className="mb-4 grid grid-cols-5 gap-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} />
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<Plus size={16} /> Layer
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
<Stack size={24} weight="fill" />
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
<FolderPlus size={16} /> Group
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
<ArrowUp size={24} />
</button>
<button type="button" className={toolbarButtonClass()} disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={16} /> Group selected
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
<ArrowDown size={24} />
</button>
<button type="button" className={toolbarButtonClass()} disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
Ungroup
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
Up
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
Down
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
<Trash size={16} /> Delete
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
<Trash size={24} />
</button>
</div>
<div className="max-h-[32rem] overflow-auto p-3">
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => (
<section key={artboard.id} className="mb-4 last:mb-0">
<section key={artboard.id} className="mb-5 last:mb-0">
<div
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-400/15 text-sky-100 ring-1 ring-sky-300/20" : "text-white/65 hover:bg-white/[0.06] hover:text-white"}`}
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
onDragOver={(event) => {
if (draggedLayerId.current) event.preventDefault();
}}
@@ -81,11 +80,11 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
draggedLayerId.current = undefined;
}}
>
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.visible ? "Hide artboard" : "Show artboard"} onClick={() => dispatch(commandIds.documentSetArtboardVisible, { id: artboard.id, visible: !artboard.visible })}>
{artboard.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
<button type="button" className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"} aria-label={artboard.visible ? "Hide artboard" : "Show artboard"} onClick={() => dispatch(commandIds.documentSetArtboardVisible, { id: artboard.id, visible: !artboard.visible })}>
{artboard.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
</button>
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.locked ? "Unlock artboard" : "Lock artboard"} onClick={() => dispatch(commandIds.documentSetArtboardLocked, { id: artboard.id, locked: !artboard.locked })}>
{artboard.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
<button type="button" className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"} aria-label={artboard.locked ? "Unlock artboard" : "Lock artboard"} onClick={() => dispatch(commandIds.documentSetArtboardLocked, { id: artboard.id, locked: !artboard.locked })}>
{artboard.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button>
{editingTitle?.type === "artboard" && editingTitle.id === artboard.id ? (
<RenameInput
@@ -109,7 +108,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
)}
<button
type="button"
className="text-white/55 transition hover:text-white"
className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"}
aria-label={`Export ${artboard.name} as PNG`}
title="Export PNG"
onClick={(event) => {
@@ -117,13 +116,13 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
void downloadArtboardPng(artboard, document.assets);
}}
>
<DownloadSimple size={17} weight="regular" />
<DownloadSimple size={24} weight="regular" />
</button>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
</div>
<div className="mt-2 space-y-1.5 pl-3">
<div className="mt-2 space-y-2 pl-5">
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
@@ -188,7 +187,7 @@ function LayerRow({
<div>
<div
draggable
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${editingMask ? "bg-sky-300 text-black shadow-sm" : selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
className={`group flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${editingMask ? "bg-sky-300 text-black" : selected ? "bg-white text-black" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
@@ -209,7 +208,7 @@ function LayerRow({
}}
>
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
{layer.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
</button>
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
<RenameInput
@@ -232,30 +231,30 @@ function LayerRow({
</button>
)}
{layer.clippingMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-2 py-0.5 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-2 py-0.5 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(document, layerInfo, dispatch)}
>
Add mask
</button>
) : null}
<button type="button" className={editingMask || selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
{layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button>
</div>
{layer.clippingMask ? (
<div className="mt-1 flex items-center gap-2 rounded-xl border border-sky-300/10 bg-sky-400/[0.04] px-3 py-1.5 text-xs text-sky-100/70" style={{ marginLeft: rowPadding + 24 }}>
<Stack size={14} weight="fill" />
<span className="min-w-0 flex-1 truncate">{maskLayer ? `Layer mask · ${maskLayer.name}` : "Layer mask missing"}</span>
<div className="mt-2 flex min-h-14 items-center gap-3 rounded-full py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
<Stack size={24} weight="fill" />
<span className="min-w-0 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
{maskLayer ? (
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
@@ -267,7 +266,7 @@ function LayerRow({
) : null}
<button
type="button"
className="rounded-full px-2 py-0.5 text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
className="h-9 rounded-full px-4 text-sm font-medium text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
Remove
@@ -304,7 +303,7 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
return (
<input
autoFocus
className="min-w-0 flex-1 rounded-md bg-white px-2 py-1 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
@@ -366,11 +365,12 @@ function collectDocumentMaskLayerIds(document: ImageDocument): Set<string> {
return ids;
}
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>) {
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>): Set<string> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<string>): number {
@@ -464,13 +464,30 @@ function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: Ap
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
if (!siblings) return;
const currentIndex = siblings.findIndex((layer) => layer.id === info.layer.id);
if (currentIndex < 0) return;
const maskLayerIds = collectMaskLayerIds(siblings, new Set<string>());
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
const maskIndex = layer.clippingMask ? siblings.findIndex((candidate) => candidate.id === layer.clippingMask?.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
});
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
const currentBlock = blocks[currentBlockIndex];
const targetBlock = blocks[currentBlockIndex + direction];
if (!currentBlock || !targetBlock) return;
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: currentIndex + direction,
toIndex: insertionIndex - removedBeforeInsertion,
});
}
@@ -488,5 +505,9 @@ function createGroup(name: string): Layer {
}
function toolbarButtonClass() {
return "inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 text-white/75 transition hover:border-white/20 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
}
function labeledToolbarButtonClass() {
return "inline-flex h-12 flex-1 items-center rounded-full pr-4 text-sm font-medium text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
}