From e5a7030d9b1cf6407b30cd66f5986d80234d081a Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Thu, 8 Jan 2026 10:47:48 +0000 Subject: [PATCH] phase 2 complete --- DEVELOPMENT_PLAN.md | 12 +-- src/App.tsx | 19 +---- src/Index.tsx | 25 +++--- src/Layout.tsx | 10 ++- src/Settings.tsx | 6 +- src/components/canvas/MorphableShape.tsx | 23 +++++ src/components/canvas/ShapeCanvas.tsx | 37 ++++++++ src/components/ui/slider.tsx | 104 +++++++++++------------ src/components/ui/toggle.tsx | 75 ++++++++-------- src/hooks/useShapeState.ts | 53 ++++++++++++ 10 files changed, 232 insertions(+), 132 deletions(-) create mode 100644 src/components/canvas/MorphableShape.tsx create mode 100644 src/components/canvas/ShapeCanvas.tsx create mode 100644 src/hooks/useShapeState.ts diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index cbed17d..c51e620 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -104,12 +104,12 @@ src/ ### Tasks -- [ ] Create `useShapeState.ts` hook with default values -- [ ] Add `beforeunload` warning when state has changed -- [ ] Create `ShapeCanvas.tsx` - Konva Stage that fills container -- [ ] Create `MorphableShape.tsx` - renders a simple circle for now -- [ ] Make shape draggable, update state on drag end -- [ ] Display current x/y in sidebar (debug, remove later) +- [x] Create `useShapeState.ts` hook with default values +- [x] Add `beforeunload` warning when state has changed +- [x] Create `ShapeCanvas.tsx` - Konva Stage that fills container +- [x] Create `MorphableShape.tsx` - renders a simple circle for now +- [x] Make shape draggable, update state on drag end +- [x] Display current x/y in sidebar (debug, remove later) ### Deliverables diff --git a/src/App.tsx b/src/App.tsx index 8ea022e..ce92e6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,28 +1,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import Index from "@/Index"; import Settings from "./Settings"; -import Layout from "./Layout"; function App() { return ( - - - - } - /> - - - - } - /> + } /> + } /> ); diff --git a/src/Index.tsx b/src/Index.tsx index fa5ee68..34b515e 100644 --- a/src/Index.tsx +++ b/src/Index.tsx @@ -1,16 +1,18 @@ -import { Stage, Layer, Circle } from "react-konva"; import { useEffect, useState } from "react"; +import ShapeCanvas from "@/components/canvas/ShapeCanvas"; +import { useShapeState } from "@/hooks/useShapeState"; +import Layout from "./Layout"; function Index() { const [dimensions, setDimensions] = useState({ - width: window.innerWidth, + width: window.innerWidth - 320, height: window.innerHeight, }); useEffect(() => { const handleResize = () => { setDimensions({ - width: window.innerWidth, + width: window.innerWidth - 320, height: window.innerHeight, }); }; @@ -19,18 +21,15 @@ function Index() { return () => window.removeEventListener("resize", handleResize); }, []); - // canvas fills the available space (accounting for sidebar width of 320px) - const canvasWidth = dimensions.width - 320; - const canvasHeight = dimensions.height; + const centerX = dimensions.width / 2; + const centerY = dimensions.height / 2; + + const [state, setState] = useShapeState(centerX, centerY); return ( - <> - - - - - - + controls here}> + + ); } diff --git a/src/Layout.tsx b/src/Layout.tsx index 068bfc1..5fad579 100644 --- a/src/Layout.tsx +++ b/src/Layout.tsx @@ -3,7 +3,13 @@ import { Link, useLocation } from "react-router-dom"; import { Home, Settings } from "lucide-react"; import { Button } from "@/components/ui/button"; -export default function Layout({ children }: { children: React.ReactNode }) { +export default function Layout({ + children, + sidebarContent, +}: { + children: React.ReactNode; + sidebarContent?: React.ReactNode; +}) { const location = useLocation(); return ( @@ -13,7 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {

The Shape of Sound

-
{/* controls will go here in later phases */}
+
{sidebarContent || null}
diff --git a/src/Settings.tsx b/src/Settings.tsx index 955efef..9d91fa4 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,7 +1,9 @@ +import Layout from "@/Layout"; + export default function Settings() { return ( -
+

Settings Page

-
+ ); } diff --git a/src/components/canvas/MorphableShape.tsx b/src/components/canvas/MorphableShape.tsx new file mode 100644 index 0000000..6599a2e --- /dev/null +++ b/src/components/canvas/MorphableShape.tsx @@ -0,0 +1,23 @@ +import { Circle } from "react-konva"; +import type { KonvaEventObject } from "konva/lib/Node"; +import type { ShapeState } from "@/types/shape"; + +export default function MorphableShape({ + state, + onStateChange, +}: { + state: ShapeState; + onStateChange: (state: ShapeState) => void; +}) { + const handleDrag = (e: KonvaEventObject) => { + onStateChange({ + ...state, + x: e.target.x(), + y: e.target.y(), + }); + }; + + return ( + + ); +} diff --git a/src/components/canvas/ShapeCanvas.tsx b/src/components/canvas/ShapeCanvas.tsx new file mode 100644 index 0000000..482dc89 --- /dev/null +++ b/src/components/canvas/ShapeCanvas.tsx @@ -0,0 +1,37 @@ +import { Stage, Layer } from "react-konva"; +import { useEffect, useState } from "react"; +import MorphableShape from "./MorphableShape"; +import type { ShapeState } from "@/types/shape"; + +export default function ShapeCanvas({ + state, + onStateChange, +}: { + state: ShapeState; + onStateChange: (state: ShapeState) => void; +}) { + const [dimensions, setDimensions] = useState({ + width: window.innerWidth - 320, // account for sidebar + height: window.innerHeight, + }); + + useEffect(() => { + const handleResize = () => { + setDimensions({ + width: window.innerWidth - 320, + height: window.innerHeight, + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return ( + + + + + + ); +} diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 5f9617f..0478ae0 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -1,61 +1,57 @@ -import * as React from "react" -import * as SliderPrimitive from "@radix-ui/react-slider" +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Slider({ - className, - defaultValue, - value, - min = 0, - max = 100, - ...props + className, + defaultValue, + value, + min = 0, + max = 100, + ...props }: React.ComponentProps) { - const _values = React.useMemo( - () => - Array.isArray(value) - ? value - : Array.isArray(defaultValue) - ? defaultValue - : [min, max], - [value, defaultValue, min, max] - ) + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); - return ( - - - - - {Array.from({ length: _values.length }, (_, index) => ( - - ))} - - ) + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + key={index} + className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" + /> + ))} + + ); } -export { Slider } +export { Slider }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index 94ec8f5..3dd50f2 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -1,47 +1,46 @@ -"use client" +"use client"; -import * as React from "react" -import * as TogglePrimitive from "@radix-ui/react-toggle" -import { cva, type VariantProps } from "class-variance-authority" +import type * as React from "react"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-9 px-2 min-w-9", - sm: "h-8 px-1.5 min-w-8", - lg: "h-10 px-2.5 min-w-10", - }, + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) +); function Toggle({ - className, - variant, - size, - ...props -}: React.ComponentProps & - VariantProps) { - return ( - - ) + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); } -export { Toggle, toggleVariants } +export { Toggle, toggleVariants }; diff --git a/src/hooks/useShapeState.ts b/src/hooks/useShapeState.ts new file mode 100644 index 0000000..bf62df4 --- /dev/null +++ b/src/hooks/useShapeState.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from "react"; +import type { ShapeState } from "@/types/shape"; + +const DEFAULT_STATE: ShapeState = { + x: 0, + y: 0, + preset: "circle", + roundness: 100, // full circle + size: 50, // medium + wobble: 20, // subtle + wobbleSpeed: 50, // medium + grain: 0, // none + color: "#FF0000", // red (C) + octave: 4, // middle octave +}; + +export function useShapeState(centerX: number, centerY: number) { + const [state, setState] = useState({ + ...DEFAULT_STATE, + x: centerX, + y: centerY, + }); + + const initialStateRef = useRef({ + ...DEFAULT_STATE, + x: centerX, + y: centerY, + }); + + // update center position when canvas resizes + useEffect(() => { + setState((prev) => ({ + ...prev, + x: centerX, + y: centerY, + })); + }, [centerX, centerY]); + + // beforeunload warning + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + const hasChanged = JSON.stringify(state) !== JSON.stringify(initialStateRef.current); + if (hasChanged) { + e.preventDefault(); + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [state]); + + return [state, setState] as const; +}