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:
47
view/App.tsx
47
view/App.tsx
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DownloadSimple, FolderOpen, ImageSquare, Stack } from "@phosphor-icons/react";
|
||||
import { DownloadSimple, FolderOpen, Stack } from "@phosphor-icons/react";
|
||||
import type { ImageStudioApp } from "@app/app";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||
import { CanvasViewport } from "./CanvasViewport";
|
||||
import { LayersSheet } from "./LayersSheet";
|
||||
import { ToolOverlay } from "./ToolOverlay";
|
||||
import { labelForTool } from "./toolLabels";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||
import { useAppState } from "./useAppState";
|
||||
@@ -21,13 +20,11 @@ export type AppProps = {
|
||||
|
||||
export function App({ app }: AppProps) {
|
||||
const state = useAppState(app.store);
|
||||
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
|
||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||
const imageImport = useImageImport(app.store);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
||||
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
||||
const activeToolLabel = state.editor.maskEdit ? "Mask edit" : labelForTool(state.editor.tools.activeTool);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -53,7 +50,7 @@ export function App({ app }: AppProps) {
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === "l") {
|
||||
setLayersOpen(true);
|
||||
setLayersOpen((open) => !open);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -72,40 +69,18 @@ export function App({ app }: AppProps) {
|
||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
||||
|
||||
return (
|
||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
|
||||
{imageImport.input}
|
||||
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/85 px-2.5 text-white shadow-2xl shadow-black/35 backdrop-blur-xl">
|
||||
<div className="flex min-w-0 items-center gap-3 pl-1.5">
|
||||
<div className="grid size-7 place-items-center rounded-lg bg-white text-black shadow-sm">
|
||||
<ImageSquare size={18} weight="fill" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-4 tracking-wide">Image Studio</h1>
|
||||
<div className="truncate text-[11px] leading-4 text-white/45">{state.document.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-center gap-1.5 md:flex">
|
||||
<span className={topBarChipClass()}>{activeToolLabel}</span>
|
||||
<span className={topBarChipClass()}>{zoomPercent}%</span>
|
||||
<span className={topBarChipClass()}>
|
||||
{state.editor.viewport.size.w}×{state.editor.viewport.size.h}
|
||||
</span>
|
||||
{activeArtboard ? <span className="truncate rounded-full bg-sky-400/15 px-3 py-1 text-xs font-medium text-sky-100 ring-1 ring-sky-300/20">{activeArtboard.name}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
||||
<FolderOpen size={16} />
|
||||
<span className="hidden sm:inline">Import</span>
|
||||
<FolderOpen size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
|
||||
<DownloadSimple size={16} />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
<DownloadSimple size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => setLayersOpen((open) => !open)}>
|
||||
<Stack size={16} weight={layersOpen ? "fill" : "regular"} />
|
||||
<span className="hidden sm:inline">Layers</span>
|
||||
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -142,12 +117,8 @@ export function App({ app }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function topBarChipClass() {
|
||||
return "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs font-medium text-white/70";
|
||||
}
|
||||
|
||||
function topBarButtonClass(active = false) {
|
||||
const base = "inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
|
||||
const base = "inline-flex size-12 items-center justify-center rounded-full text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
|
||||
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!visible}
|
||||
className={`flex h-10 min-w-48 items-center justify-center gap-2 rounded-full border border-white/10 bg-black/70 px-2 py-1 text-xs text-white shadow-xl backdrop-blur transition-all duration-200 ${
|
||||
className={`flex h-20 min-w-96 items-center justify-center gap-4 rounded-full px-4 py-2 text-sm text-white backdrop-blur transition-all duration-200 ${
|
||||
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl
|
||||
return (
|
||||
<nav
|
||||
aria-label="Tools"
|
||||
className="pointer-events-auto flex flex-col gap-1 rounded-lg border border-white/10 bg-black/70 p-1 text-white shadow-xl backdrop-blur"
|
||||
className="pointer-events-auto flex w-20 flex-col items-center gap-3 rounded-full p-2 text-white backdrop-blur-xl"
|
||||
>
|
||||
{availableToolIds.map((tool) => {
|
||||
const active = isToolHighlighted(tool, activeTool, interactionMode);
|
||||
@@ -31,7 +31,7 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl
|
||||
className={buttonClass(active)}
|
||||
onClick={() => dispatch(commandIds.toolSetActive, { tool })}
|
||||
>
|
||||
<Icon size={22} weight={active ? "fill" : "regular"} />
|
||||
<Icon size={24} weight={active ? "fill" : "regular"} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -58,5 +58,6 @@ function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: In
|
||||
}
|
||||
|
||||
function buttonClass(active: boolean) {
|
||||
return `grid size-9 place-items-center rounded-md transition focus:outline-none focus-visible:outline-none ${active ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`;
|
||||
const base = "inline-flex size-12 items-center justify-center rounded-full text-xs font-medium transition focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
|
||||
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -15,3 +15,55 @@
|
||||
@apply m-0 min-w-[320px] bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.bottom-control-slider {
|
||||
@apply h-8 cursor-pointer appearance-none bg-transparent outline-none;
|
||||
}
|
||||
|
||||
.bottom-control-slider::-webkit-slider-runnable-track {
|
||||
height: 0.25rem;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(to right, rgb(255 255 255 / 0.9) var(--slider-progress), rgb(255 255 255 / 0.22) var(--slider-progress));
|
||||
}
|
||||
|
||||
.bottom-control-slider::-webkit-slider-thumb {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: -0.5rem;
|
||||
appearance: none;
|
||||
border: 1px solid rgb(255 255 255 / 0.9);
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.bottom-control-slider::-moz-range-track {
|
||||
height: 0.25rem;
|
||||
border-radius: 0;
|
||||
background: rgb(255 255 255 / 0.22);
|
||||
}
|
||||
|
||||
.bottom-control-slider::-moz-range-progress {
|
||||
height: 0.25rem;
|
||||
border-radius: 0;
|
||||
background: rgb(255 255 255 / 0.9);
|
||||
}
|
||||
|
||||
.bottom-control-slider::-moz-range-thumb {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.9);
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.bottom-control-slider:focus-visible::-webkit-slider-thumb {
|
||||
outline: 1px solid rgb(255 255 255);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bottom-control-slider:focus-visible::-moz-range-thumb {
|
||||
outline: 1px solid rgb(255 255 255);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user