From 46aa1b8595befa6b7d97cc64393289f8853b40b8 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:04:43 +0200 Subject: [PATCH] feat(view): add contextual layer controls --- view/App.tsx | 12 +++- view/BottomControlsIsland.tsx | 72 +++++----------------- view/LayersSheet.tsx | 63 +++++++++++++++++++ view/bottom-controls/Divider.tsx | 5 ++ view/bottom-controls/PanControls.tsx | 24 ++++++++ view/bottom-controls/SelectionControls.tsx | 36 +++++++++++ view/bottom-controls/ZoomControls.tsx | 29 +++++++++ view/bottom-controls/styles.ts | 19 ++++++ view/selectionSummary.ts | 43 +++++++++++++ 9 files changed, 243 insertions(+), 60 deletions(-) create mode 100644 view/LayersSheet.tsx create mode 100644 view/bottom-controls/Divider.tsx create mode 100644 view/bottom-controls/PanControls.tsx create mode 100644 view/bottom-controls/SelectionControls.tsx create mode 100644 view/bottom-controls/ZoomControls.tsx create mode 100644 view/bottom-controls/styles.ts create mode 100644 view/selectionSummary.ts diff --git a/view/App.tsx b/view/App.tsx index 0494d2c..1c20104 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,8 +1,11 @@ +import { useState } from "react"; import type { ImageStudioApp } from "@app/app"; import { BottomControlsIsland } from "./BottomControlsIsland"; import { CanvasViewport } from "./CanvasViewport"; +import { LayersSheet } from "./LayersSheet"; import { ToolOverlay } from "./ToolOverlay"; import { labelForTool } from "./toolLabels"; +import { getSelectionSummary } from "./selectionSummary"; import { useAppState } from "./useAppState"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; import "./index.css"; @@ -15,9 +18,11 @@ 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 [layersOpen, setLayersOpen] = useState(false); + const selectionSummary = getSelectionSummary(state.document, state.editor.selection); return ( -
+

Image Studio

@@ -32,12 +37,15 @@ export function App({ app }: AppProps) { dispatch={app.store.dispatch} />
+ setLayersOpen(false)} />
setLayersOpen(true)} />
diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index e409690..baf42d5 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -1,7 +1,9 @@ -import { Minus, CornersOut, Hand, Plus } from "@phosphor-icons/react"; -import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; import type { ViewportState } from "@editor/state"; +import { PanControls } from "./bottom-controls/PanControls"; +import { SelectionControls } from "./bottom-controls/SelectionControls"; +import { ZoomControls } from "./bottom-controls/ZoomControls"; +import type { SelectionSummary } from "./selectionSummary"; export type BottomControlsAction = "pan" | "zoom"; @@ -9,10 +11,12 @@ export type BottomControlsIslandProps = { viewport: ViewportState; visible: boolean; action: BottomControlsAction; + selection: SelectionSummary; dispatch: AppStore["dispatch"]; + onOpenLayers: () => void; }; -export function BottomControlsIsland({ viewport, visible, action, dispatch }: BottomControlsIslandProps) { +export function BottomControlsIsland({ viewport, visible, action, selection, dispatch, onOpenLayers }: BottomControlsIslandProps) { const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -24,61 +28,13 @@ export function BottomControlsIsland({ viewport, visible, action, dispatch }: Bo visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0" }`} > - {action === "pan" ? : } + {selection.type !== "none" ? ( + + ) : action === "pan" ? ( + + ) : ( + + )} ); } - -function PanControls({ x, y }: { x: number; y: number }) { - return ( -
- - - - - X - {x} - - Y - {y} -
- ); -} - -function ZoomControls({ zoom, zoomPercent, dispatch }: { zoom: number; zoomPercent: number; dispatch: AppStore["dispatch"] }) { - return ( - <> - - {zoomPercent}% - - - - - ); -} - -function Divider() { - return
; -} - -function labelClass() { - return "text-white/60"; -} - -function valueClass() { - return "min-w-10 text-center text-white"; -} - -function iconSlotClass() { - return "grid size-7 place-items-center rounded-full text-white/80"; -} - -function buttonClass() { - return `${iconSlotClass()} transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none`; -} diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx new file mode 100644 index 0000000..ae9215a --- /dev/null +++ b/view/LayersSheet.tsx @@ -0,0 +1,63 @@ +import { Eye, EyeSlash, Lock, LockOpen, X } from "@phosphor-icons/react"; +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import type { SelectionState } from "@editor/state"; + +export type LayersSheetProps = { + document: ImageDocument; + selection: SelectionState; + open: boolean; + onClose: () => void; +}; + +export function LayersSheet({ document, selection, open, onClose }: LayersSheetProps) { + return ( + + ); +} + +function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) { + const selected = selectedLayerIds.includes(layer.id); + return ( +
+
+ {layer.visible ? : } + {layer.locked ? : } + {layer.name} +
+ {layer.type === "group" ? layer.children.map((child) => ) : null} +
+ ); +} + +function iconButtonClass() { + return "grid size-7 place-items-center rounded-full text-white/80 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none"; +} diff --git a/view/bottom-controls/Divider.tsx b/view/bottom-controls/Divider.tsx new file mode 100644 index 0000000..708535d --- /dev/null +++ b/view/bottom-controls/Divider.tsx @@ -0,0 +1,5 @@ +import { bottomControlDividerClass } from "./styles"; + +export function BottomControlDivider() { + return
; +} diff --git a/view/bottom-controls/PanControls.tsx b/view/bottom-controls/PanControls.tsx new file mode 100644 index 0000000..9f867c3 --- /dev/null +++ b/view/bottom-controls/PanControls.tsx @@ -0,0 +1,24 @@ +import { Hand } from "@phosphor-icons/react"; +import { BottomControlDivider } from "./Divider"; +import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles"; + +export type PanControlsProps = { + x: number; + y: number; +}; + +export function PanControls({ x, y }: PanControlsProps) { + return ( +
+ + + + + X + {x} + + Y + {y} +
+ ); +} diff --git a/view/bottom-controls/SelectionControls.tsx b/view/bottom-controls/SelectionControls.tsx new file mode 100644 index 0000000..a4a1372 --- /dev/null +++ b/view/bottom-controls/SelectionControls.tsx @@ -0,0 +1,36 @@ +import { Stack } from "@phosphor-icons/react"; +import type { SelectionSummary } from "../selectionSummary"; +import { BottomControlDivider } from "./Divider"; +import { bottomControlButtonClass } from "./styles"; + +export type SelectionControlsProps = { + selection: SelectionSummary; + onOpenLayers: () => void; +}; + +export function SelectionControls({ selection, onOpenLayers }: SelectionControlsProps) { + const label = selectionLabel(selection); + + return ( + <> + + + {label} + + ); +} + +function selectionLabel(selection: SelectionSummary) { + switch (selection.type) { + case "artboard": + return `${selection.name} ยท ${selection.layerCount} layers`; + case "layer": + return selection.name; + case "multi-layer": + return `${selection.count} layers selected`; + case "none": + return ""; + } +} diff --git a/view/bottom-controls/ZoomControls.tsx b/view/bottom-controls/ZoomControls.tsx new file mode 100644 index 0000000..7b4ddcc --- /dev/null +++ b/view/bottom-controls/ZoomControls.tsx @@ -0,0 +1,29 @@ +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"; + +export type ZoomControlsProps = { + zoom: number; + zoomPercent: number; + dispatch: AppStore["dispatch"]; +}; + +export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) { + return ( + <> + + {zoomPercent}% + + + + + ); +} diff --git a/view/bottom-controls/styles.ts b/view/bottom-controls/styles.ts new file mode 100644 index 0000000..8939bbf --- /dev/null +++ b/view/bottom-controls/styles.ts @@ -0,0 +1,19 @@ +export function bottomControlDividerClass() { + return "mx-1 h-4 w-px bg-white/15"; +} + +export function bottomControlLabelClass() { + return "text-white/60"; +} + +export function bottomControlValueClass() { + return "min-w-10 text-center text-white"; +} + +export function bottomControlIconSlotClass() { + return "grid size-7 place-items-center rounded-full text-white/80"; +} + +export function bottomControlButtonClass() { + return `${bottomControlIconSlotClass()} transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none`; +} diff --git a/view/selectionSummary.ts b/view/selectionSummary.ts new file mode 100644 index 0000000..6f18e20 --- /dev/null +++ b/view/selectionSummary.ts @@ -0,0 +1,43 @@ +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import type { SelectionState } from "@editor/state"; + +export type SelectionSummary = + | { type: "none" } + | { type: "artboard"; name: string; layerCount: number } + | { type: "layer"; name: string; layer: Layer } + | { type: "multi-layer"; count: number; layers: Layer[] }; + +export function getSelectionSummary(document: ImageDocument, selection: SelectionState): SelectionSummary { + const selectedLayers = selection.layerIds.flatMap((layerId) => { + const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + return layer ? [layer] : []; + }); + + if (selectedLayers.length === 1 && selectedLayers[0]) { + return { type: "layer", name: selectedLayers[0].name, layer: selectedLayers[0] }; + } + + if (selectedLayers.length > 1) { + return { type: "multi-layer", count: selectedLayers.length, layers: selectedLayers }; + } + + if (selection.artboardId) { + const artboard = document.artboards.find((candidate) => candidate.id === selection.artboardId); + if (artboard) return { type: "artboard", name: artboard.name, layerCount: artboard.layers.length }; + } + + return { type: "none" }; +} + +function findLayer(layers: Layer[], layerId: string): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const child = findLayer(layer.children, layerId); + if (child) return child; + } + } + + return undefined; +}