feat(view): add contextual layer controls
This commit is contained in:
12
view/App.tsx
12
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 (
|
||||
<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">
|
||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
||||
<div className="text-xs">
|
||||
@@ -32,12 +37,15 @@ export function App({ app }: AppProps) {
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
</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">
|
||||
<BottomControlsIsland
|
||||
viewport={state.editor.viewport}
|
||||
visible={viewportActivityIsland.visible}
|
||||
visible={selectionSummary.type !== "none" || viewportActivityIsland.visible}
|
||||
action={viewportActivityIsland.action}
|
||||
selection={selectionSummary}
|
||||
dispatch={app.store.dispatch}
|
||||
onOpenLayers={() => setLayersOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<CanvasViewport store={app.store} />
|
||||
|
||||
@@ -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" ? <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>
|
||||
);
|
||||
}
|
||||
|
||||
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
63
view/LayersSheet.tsx
Normal 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";
|
||||
}
|
||||
5
view/bottom-controls/Divider.tsx
Normal file
5
view/bottom-controls/Divider.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bottomControlDividerClass } from "./styles";
|
||||
|
||||
export function BottomControlDivider() {
|
||||
return <div className={bottomControlDividerClass()} />;
|
||||
}
|
||||
24
view/bottom-controls/PanControls.tsx
Normal file
24
view/bottom-controls/PanControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
view/bottom-controls/SelectionControls.tsx
Normal file
36
view/bottom-controls/SelectionControls.tsx
Normal 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 "";
|
||||
}
|
||||
}
|
||||
29
view/bottom-controls/ZoomControls.tsx
Normal file
29
view/bottom-controls/ZoomControls.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
view/bottom-controls/styles.ts
Normal file
19
view/bottom-controls/styles.ts
Normal 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
43
view/selectionSummary.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user