phase 2 complete

This commit is contained in:
Oliver Bryan
2026-01-08 10:47:48 +00:00
parent 77a9b32841
commit e5a7030d9b
10 changed files with 232 additions and 132 deletions

View File

@@ -104,12 +104,12 @@ src/
### Tasks ### Tasks
- [ ] Create `useShapeState.ts` hook with default values - [x] Create `useShapeState.ts` hook with default values
- [ ] Add `beforeunload` warning when state has changed - [x] Add `beforeunload` warning when state has changed
- [ ] Create `ShapeCanvas.tsx` - Konva Stage that fills container - [x] Create `ShapeCanvas.tsx` - Konva Stage that fills container
- [ ] Create `MorphableShape.tsx` - renders a simple circle for now - [x] Create `MorphableShape.tsx` - renders a simple circle for now
- [ ] Make shape draggable, update state on drag end - [x] Make shape draggable, update state on drag end
- [ ] Display current x/y in sidebar (debug, remove later) - [x] Display current x/y in sidebar (debug, remove later)
### Deliverables ### Deliverables

View File

@@ -1,28 +1,13 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import Index from "@/Index"; import Index from "@/Index";
import Settings from "./Settings"; import Settings from "./Settings";
import Layout from "./Layout";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route <Route path="/" element={<Index />} />
path="/" <Route path="/settings" element={<Settings />} />
element={
<Layout>
<Index />
</Layout>
}
/>
<Route
path="/settings"
element={
<Layout>
<Settings />
</Layout>
}
/>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -1,16 +1,18 @@
import { Stage, Layer, Circle } from "react-konva";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ShapeCanvas from "@/components/canvas/ShapeCanvas";
import { useShapeState } from "@/hooks/useShapeState";
import Layout from "./Layout";
function Index() { function Index() {
const [dimensions, setDimensions] = useState({ const [dimensions, setDimensions] = useState({
width: window.innerWidth, width: window.innerWidth - 320,
height: window.innerHeight, height: window.innerHeight,
}); });
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
setDimensions({ setDimensions({
width: window.innerWidth, width: window.innerWidth - 320,
height: window.innerHeight, height: window.innerHeight,
}); });
}; };
@@ -19,18 +21,15 @@ function Index() {
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, []); }, []);
// canvas fills the available space (accounting for sidebar width of 320px) const centerX = dimensions.width / 2;
const canvasWidth = dimensions.width - 320; const centerY = dimensions.height / 2;
const canvasHeight = dimensions.height;
const [state, setState] = useShapeState(centerX, centerY);
return ( return (
<> <Layout sidebarContent={<>controls here</>}>
<Stage width={canvasWidth} height={canvasHeight} className=""> <ShapeCanvas state={state} onStateChange={setState} />
<Layer> </Layout>
<Circle x={canvasWidth / 2} y={canvasHeight / 2} radius={100} fill="#68d436" draggable />
</Layer>
</Stage>
</>
); );
} }

View File

@@ -3,7 +3,13 @@ import { Link, useLocation } from "react-router-dom";
import { Home, Settings } from "lucide-react"; import { Home, Settings } from "lucide-react";
import { Button } from "@/components/ui/button"; 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(); const location = useLocation();
return ( return (
@@ -13,7 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">The Shape of Sound</h1> <h1 className="text-2xl font-semibold">The Shape of Sound</h1>
</div> </div>
<div className="flex-1">{/* controls will go here in later phases */}</div> <div className="flex-1">{sidebarContent || null}</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ThemeToggle className="rounded-lg" /> <ThemeToggle className="rounded-lg" />

View File

@@ -1,7 +1,9 @@
import Layout from "@/Layout";
export default function Settings() { export default function Settings() {
return ( return (
<div className="w-full h-full flex flex-col items-center justify-start"> <Layout>
<p>Settings Page</p> <p>Settings Page</p>
</div> </Layout>
); );
} }

View File

@@ -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<DragEvent>) => {
onStateChange({
...state,
x: e.target.x(),
y: e.target.y(),
});
};
return (
<Circle x={state.x} y={state.y} radius={100} fill={state.color} draggable onDragMove={handleDrag} />
);
}

View File

@@ -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 (
<Stage width={dimensions.width} height={dimensions.height}>
<Layer>
<MorphableShape state={state} onStateChange={onStateChange} />
</Layer>
</Stage>
);
}

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Slider({ function Slider({
className, className,
@@ -12,14 +12,9 @@ function Slider({
...props ...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) { }: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo( const _values = React.useMemo(
() => () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
Array.isArray(value) [value, defaultValue, min, max],
? value );
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
@@ -30,32 +25,33 @@ function Slider({
max={max} max={max}
className={cn( className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col", "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className className,
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track <SliderPrimitive.Track
data-slot="slider-track" data-slot="slider-track"
className={cn( className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5" "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)} )}
> >
<SliderPrimitive.Range <SliderPrimitive.Range
data-slot="slider-range" data-slot="slider-range"
className={cn( className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full" "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)} )}
/> />
</SliderPrimitive.Track> </SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => ( {Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
data-slot="slider-thumb" data-slot="slider-thumb"
// biome-ignore lint/suspicious/noArrayIndexKey: <>
key={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" 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"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
) );
} }
export { Slider } export { Slider };

View File

@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import type * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle" import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const toggleVariants = cva( 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", "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",
@@ -25,23 +25,22 @@ const toggleVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Toggle({ function Toggle({
className, className,
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
VariantProps<typeof toggleVariants>) {
return ( return (
<TogglePrimitive.Root <TogglePrimitive.Root
data-slot="toggle" data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Toggle, toggleVariants } export { Toggle, toggleVariants };

View File

@@ -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<ShapeState>({
...DEFAULT_STATE,
x: centerX,
y: centerY,
});
const initialStateRef = useRef<ShapeState>({
...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;
}