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