From b0732c8af1aeba824521605768d37a841b1b3307 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 11:43:00 +0200 Subject: [PATCH] feat(view): add viewport activity island --- view/App.tsx | 11 ++++ view/BottomControlsIsland.tsx | 84 +++++++++++++++++++++++++++++++ view/useViewportActivityIsland.ts | 31 ++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 view/BottomControlsIsland.tsx create mode 100644 view/useViewportActivityIsland.ts diff --git a/view/App.tsx b/view/App.tsx index 9b0b651..0494d2c 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,8 +1,10 @@ import type { ImageStudioApp } from "@app/app"; +import { BottomControlsIsland } from "./BottomControlsIsland"; import { CanvasViewport } from "./CanvasViewport"; import { ToolOverlay } from "./ToolOverlay"; import { labelForTool } from "./toolLabels"; import { useAppState } from "./useAppState"; +import { useViewportActivityIsland } from "./useViewportActivityIsland"; import "./index.css"; export type AppProps = { @@ -12,6 +14,7 @@ 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); return (
@@ -29,6 +32,14 @@ export function App({ app }: AppProps) { dispatch={app.store.dispatch} /> +
+ +
); diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx new file mode 100644 index 0000000..e409690 --- /dev/null +++ b/view/BottomControlsIsland.tsx @@ -0,0 +1,84 @@ +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"; + +export type BottomControlsAction = "pan" | "zoom"; + +export type BottomControlsIslandProps = { + viewport: ViewportState; + visible: boolean; + action: BottomControlsAction; + dispatch: AppStore["dispatch"]; +}; + +export function BottomControlsIsland({ viewport, visible, action, dispatch }: BottomControlsIslandProps) { + const zoomPercent = Math.round(viewport.zoom * 100); + const x = Math.round(viewport.center.x); + const y = Math.round(viewport.center.y); + + return ( +
+ {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/useViewportActivityIsland.ts b/view/useViewportActivityIsland.ts new file mode 100644 index 0000000..9c3e3b3 --- /dev/null +++ b/view/useViewportActivityIsland.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react"; +import type { ViewportState } from "@editor/state"; +import type { BottomControlsAction } from "./BottomControlsIsland"; + +export type ViewportActivityIslandState = { + visible: boolean; + action: BottomControlsAction; +}; + +export function useViewportActivityIsland(viewport: ViewportState): ViewportActivityIslandState { + const [visible, setVisible] = useState(false); + const [action, setAction] = useState("zoom"); + const previousViewport = useRef({ zoom: viewport.zoom, center: viewport.center }); + + useEffect(() => { + const previous = previousViewport.current; + const zoomChanged = previous.zoom !== viewport.zoom; + const centerChanged = previous.center.x !== viewport.center.x || previous.center.y !== viewport.center.y; + + if (!zoomChanged && !centerChanged) return; + + previousViewport.current = { zoom: viewport.zoom, center: viewport.center }; + setAction(zoomChanged ? "zoom" : "pan"); + setVisible(true); + + const timeout = window.setTimeout(() => setVisible(false), 1200); + return () => window.clearTimeout(timeout); + }, [viewport.center, viewport.zoom]); + + return { visible, action }; +}