feat(view): add contextual layer controls

This commit is contained in:
syntaxbullet
2026-07-03 16:04:43 +02:00
parent b0732c8af1
commit 46aa1b8595
9 changed files with 243 additions and 60 deletions

View File

@@ -1,8 +1,11 @@
import { useState } from "react";
import type { ImageStudioApp } from "@app/app"; import type { ImageStudioApp } from "@app/app";
import { BottomControlsIsland } from "./BottomControlsIsland"; import { BottomControlsIsland } from "./BottomControlsIsland";
import { CanvasViewport } from "./CanvasViewport"; import { CanvasViewport } from "./CanvasViewport";
import { LayersSheet } from "./LayersSheet";
import { ToolOverlay } from "./ToolOverlay"; import { ToolOverlay } from "./ToolOverlay";
import { labelForTool } from "./toolLabels"; import { labelForTool } from "./toolLabels";
import { getSelectionSummary } from "./selectionSummary";
import { useAppState } from "./useAppState"; import { useAppState } from "./useAppState";
import { useViewportActivityIsland } from "./useViewportActivityIsland"; import { useViewportActivityIsland } from "./useViewportActivityIsland";
import "./index.css"; import "./index.css";
@@ -15,9 +18,11 @@ export function App({ app }: AppProps) {
const state = useAppState(app.store); const state = useAppState(app.store);
const zoomPercent = Math.round(state.editor.viewport.zoom * 100); const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport); const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
const [layersOpen, setLayersOpen] = useState(false);
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
return ( return (
<main className="relative h-full bg-background text-foreground"> <main className="relative h-full overflow-hidden bg-background text-foreground">
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white"> <header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
<h1 className="text-sm font-medium">Image Studio</h1> <h1 className="text-sm font-medium">Image Studio</h1>
<div className="text-xs"> <div className="text-xs">
@@ -32,12 +37,15 @@ export function App({ app }: AppProps) {
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
/> />
</div> </div>
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} onClose={() => setLayersOpen(false)} />
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center"> <div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland <BottomControlsIsland
viewport={state.editor.viewport} viewport={state.editor.viewport}
visible={viewportActivityIsland.visible} visible={selectionSummary.type !== "none" || viewportActivityIsland.visible}
action={viewportActivityIsland.action} action={viewportActivityIsland.action}
selection={selectionSummary}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
onOpenLayers={() => setLayersOpen(true)}
/> />
</div> </div>
<CanvasViewport store={app.store} /> <CanvasViewport store={app.store} />

View File

@@ -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 { AppStore } from "@editor/store";
import type { ViewportState } from "@editor/state"; 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"; export type BottomControlsAction = "pan" | "zoom";
@@ -9,10 +11,12 @@ export type BottomControlsIslandProps = {
viewport: ViewportState; viewport: ViewportState;
visible: boolean; visible: boolean;
action: BottomControlsAction; action: BottomControlsAction;
selection: SelectionSummary;
dispatch: AppStore["dispatch"]; 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 zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x); const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y); 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" visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`} }`}
> >
{action === "pan" ? <PanControls x={x} y={y} /> : <ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} />} {selection.type !== "none" ? (
<SelectionControls selection={selection} onOpenLayers={onOpenLayers} />
) : action === "pan" ? (
<PanControls x={x} y={y} />
) : (
<ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} />
)}
</div> </div>
); );
} }
function PanControls({ x, y }: { x: number; y: number }) {
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className={iconSlotClass()}>
<Hand size={16} weight="regular" />
</span>
<Divider />
<span className={labelClass()}>X</span>
<span className={valueClass()}>{x}</span>
<Divider />
<span className={labelClass()}>Y</span>
<span className={valueClass()}>{y}</span>
</div>
);
}
function ZoomControls({ zoom, zoomPercent, dispatch }: { zoom: number; zoomPercent: number; dispatch: AppStore["dispatch"] }) {
return (
<>
<button type="button" className={buttonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}>
<Minus size={16} weight="regular" />
</button>
<span className="min-w-14 text-center tabular-nums text-white">{zoomPercent}%</span>
<button type="button" className={buttonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}>
<Plus size={16} weight="regular" />
</button>
<Divider />
<button type="button" className={buttonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => dispatch(commandIds.viewportFitArtboard, undefined)}>
<CornersOut size={16} weight="regular" />
</button>
</>
);
}
function Divider() {
return <div className="mx-1 h-4 w-px bg-white/15" />;
}
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`;
}

63
view/LayersSheet.tsx Normal file
View File

@@ -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 (
<aside
aria-hidden={!open}
className={`pointer-events-auto absolute right-3 top-12 z-20 w-72 rounded-xl border border-white/10 bg-black/75 text-xs text-white shadow-xl backdrop-blur transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
}`}
>
<header className="flex h-10 items-center justify-between border-b border-white/10 px-3">
<span className="font-medium">Layers</span>
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
<X size={16} weight="regular" />
</button>
</header>
<div className="max-h-96 overflow-auto p-2">
{document.artboards.map((artboard) => (
<section key={artboard.id} className="mb-2 last:mb-0">
<div className={`rounded-md px-2 py-1 text-white/60 ${selection.artboardId === artboard.id ? "bg-white/10 text-white" : ""}`}>
{artboard.name}
</div>
<div className="mt-1 space-y-1 pl-2">
{artboard.layers.length === 0 ? (
<div className="px-2 py-1 text-white/40">No layers</div>
) : (
artboard.layers.map((layer) => <LayerRow key={layer.id} layer={layer} depth={0} selectedLayerIds={selection.layerIds} />)
)}
</div>
</section>
))}
</div>
</aside>
);
}
function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) {
const selected = selectedLayerIds.includes(layer.id);
return (
<div>
<div className={`flex items-center gap-2 rounded-md px-2 py-1 ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`} style={{ paddingLeft: 8 + depth * 12 }}>
{layer.visible ? <Eye size={14} weight="regular" /> : <EyeSlash size={14} weight="regular" />}
{layer.locked ? <Lock size={14} weight="regular" /> : <LockOpen size={14} weight="regular" />}
<span className="min-w-0 flex-1 truncate">{layer.name}</span>
</div>
{layer.type === "group" ? layer.children.map((child) => <LayerRow key={child.id} layer={child} depth={depth + 1} selectedLayerIds={selectedLayerIds} />) : null}
</div>
);
}
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";
}

View File

@@ -0,0 +1,5 @@
import { bottomControlDividerClass } from "./styles";
export function BottomControlDivider() {
return <div className={bottomControlDividerClass()} />;
}

View File

@@ -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 (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className={bottomControlIconSlotClass()}>
<Hand size={16} weight="regular" />
</span>
<BottomControlDivider />
<span className={bottomControlLabelClass()}>X</span>
<span className={bottomControlValueClass()}>{x}</span>
<BottomControlDivider />
<span className={bottomControlLabelClass()}>Y</span>
<span className={bottomControlValueClass()}>{y}</span>
</div>
);
}

View File

@@ -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 (
<>
<button type="button" className={bottomControlButtonClass()} aria-label="Open layers" title="Open layers" onClick={onOpenLayers}>
<Stack size={16} weight="regular" />
</button>
<BottomControlDivider />
<span className="max-w-48 truncate px-1 text-white">{label}</span>
</>
);
}
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 "";
}
}

View File

@@ -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 (
<>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}>
<Minus size={16} weight="regular" />
</button>
<span className="min-w-14 text-center tabular-nums text-white">{zoomPercent}%</span>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}>
<Plus size={16} 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" />
</button>
</>
);
}

View File

@@ -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`;
}

43
view/selectionSummary.ts Normal file
View File

@@ -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;
}