Add desk scale zoom controls and non-overlapping mobile grid layout

This commit is contained in:
airent106 committed 2026-10-13 00:47:35 -06:00
1 parent 7d9ce809a8
commit 157fc83a47
4 files changed
+185 -68

No files matched your search

+108 -59
View File
@@ -36,7 +36,8 @@ export const App: React.FC = () => {
enableMotion: true,
penMode: false,
soundEnabled: false,
activeFilter: 'all'
activeFilter: 'all',
zoom: typeof window !== 'undefined' && window.innerWidth < 768 ? 0.65 : 1.0
});
const [cards, setCards] = useState<CardItem[]>([]);
@@ -111,47 +112,68 @@ export const App: React.FC = () => {
});
};
// Re-shuffle cards
// Re-shuffle cards (stratified positioning so cards never clump or run over each other)
const handleShuffle = useCallback(() => {
soundEngine.shuffle(settings.soundEnabled);
const w = window.innerWidth;
const h = window.innerHeight;
const zoom = settings.zoom || 1.0;
const w = window.innerWidth / zoom;
const h = window.innerHeight / zoom;
const isMobile = window.innerWidth < 768;
const paddingX = isMobile ? 18 : 60;
const paddingY = isMobile ? 70 : 85;
const availableW = Math.max(300, w - paddingX * 2);
const availableH = Math.max(400, h - paddingY * 2);
const cols = Math.max(2, Math.ceil(Math.sqrt(cards.length * 1.5)));
const rows = Math.ceil(cards.length / cols);
const cellW = availableW / cols;
const cellH = availableH / rows;
setCards((prev) => {
const shuffledOrder = prev.map((_, i) => i).sort(() => Math.random() - 0.5);
return prev.map((card, i) => {
const padding = 100;
const targetX = padding + Math.random() * (w - card.width - padding * 2);
const targetY = padding + Math.random() * (h - card.height - padding * 2);
const randRot = (Math.random() - 0.5) * 24;
const slot = shuffledOrder[i];
const col = slot % cols;
const row = Math.floor(slot / cols);
const jitterX = (cellW - card.width) * 0.4;
const jitterY = (cellH - card.height) * 0.4;
const targetX = paddingX + col * cellW + (cellW - card.width) / 2 + (Math.random() - 0.5) * jitterX;
const targetY = paddingY + row * cellH + (cellH - card.height) / 2 + (Math.random() - 0.5) * jitterY;
const randRot = (Math.random() - 0.5) * (isMobile ? 10 : 20);
return {
...card,
x: targetX,
y: targetY,
x: Math.max(paddingX, Math.min(w - card.width - paddingX, targetX)),
y: Math.max(paddingY, Math.min(h - card.height - paddingY, targetY)),
rotation: randRot,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
vx: (Math.random() - 0.5) * 3,
vy: (Math.random() - 0.5) * 3,
zIndex: i + 1
};
});
});
}, [settings.soundEnabled]);
}, [cards.length, settings.zoom, settings.soundEnabled]);
// Arrange in Grid mode
// Arrange in Grid mode (clean non-overlapping Pinterest/moodboard tiles)
const arrangeGrid = useCallback(() => {
soundEngine.rustle(settings.soundEnabled);
const w = window.innerWidth;
const h = window.innerHeight;
const paddingX = 120;
const paddingY = 110;
const availableW = w - paddingX * 2;
const availableH = h - paddingY * 2;
const zoom = settings.zoom || 1.0;
const w = window.innerWidth / zoom;
const cols = Math.ceil(Math.sqrt(cards.length * 1.4));
const rows = Math.ceil(cards.length / cols);
const isMobile = window.innerWidth < 768;
const paddingX = isMobile ? 16 : 60;
const paddingY = isMobile ? 65 : 85;
const availableW = Math.max(300, w - paddingX * 2);
const cellW = availableW / cols;
const cellH = availableH / rows;
// Compute uniform column widths, row heights and positions
const idealCardW = isMobile ? Math.min(160, availableW / 2 - 12) : 210;
const cols = Math.max(2, Math.floor(availableW / (idealCardW + 16)));
const gap = isMobile ? 12 : 20;
const cellW = Math.floor((availableW - (cols - 1) * gap) / cols);
const rowH = isMobile ? 185 : 215;
setCards((prev) =>
prev.map((card, idx) => {
@@ -160,24 +182,26 @@ export const App: React.FC = () => {
return {
...card,
x: paddingX + col * cellW + (cellW - card.width) / 2,
y: paddingY + row * cellH + (cellH - card.height) / 2,
rotation: (Math.random() - 0.5) * 4, // Subtle organic alignment
x: Math.round(paddingX + col * (cellW + gap)),
y: Math.round(paddingY + row * (rowH + gap)),
width: cellW,
height: Math.min(card.height, rowH),
rotation: (Math.random() - 0.5) * 2, // Subtle organic 1-degree tilt
vx: 0,
vy: 0
};
})
);
}, [cards.length, settings.soundEnabled]);
}, [settings.zoom, settings.soundEnabled]);
// Trigger grid when layoutMode changes
// Trigger re-layout when layoutMode or zoom changes
useEffect(() => {
if (settings.layoutMode === 'grid') {
arrangeGrid();
} else {
handleShuffle();
}
}, [settings.layoutMode]);
}, [settings.layoutMode, settings.zoom]);
// Physics animation loop on requestAnimationFrame
useEffect(() => {
@@ -201,9 +225,10 @@ export const App: React.FC = () => {
let newVx = card.vx * 0.92; // Friction damping
let newVy = card.vy * 0.92;
const zoom = settings.zoom || 1.0;
// Screen edge bounce
const maxX = window.innerWidth - card.width - 20;
const maxY = window.innerHeight - card.height - 20;
const maxX = (window.innerWidth / zoom) - card.width - 20;
const maxY = (window.innerHeight / zoom) - card.height - 20;
if (newX < 20) {
newX = 20;
@@ -238,13 +263,14 @@ export const App: React.FC = () => {
animId = requestAnimationFrame(physicsLoop);
return () => cancelAnimationFrame(animId);
}, [settings.enableMotion]);
}, [settings.enableMotion, settings.zoom]);
// Pointer Down on a Card
const handleCardPointerDown = (e: React.PointerEvent, card: CardItem) => {
if (settings.penMode) return;
e.stopPropagation();
const zoom = settings.zoom || 1.0;
const newZ = maxZIndex + 1;
setMaxZIndex(newZ);
@@ -252,12 +278,15 @@ export const App: React.FC = () => {
dragTypeRef.current = 'card';
setDraggingCardId(card.id);
const px = e.clientX / zoom;
const py = e.clientY / zoom;
dragOffsetRef.current = {
x: e.clientX - card.x,
y: e.clientY - card.y
x: px - card.x,
y: py - card.y
};
pointerHistoryRef.current = [{ x: e.clientX, y: e.clientY, time: Date.now() }];
pointerHistoryRef.current = [{ x: px, y: py, time: Date.now() }];
soundEngine.rustle(settings.soundEnabled, 1.0);
@@ -271,18 +300,22 @@ export const App: React.FC = () => {
if (settings.penMode) return;
e.stopPropagation();
const zoom = settings.zoom || 1.0;
const newZ = maxZIndex + 1;
setMaxZIndex(newZ);
draggingIdRef.current = prop.id;
dragTypeRef.current = 'prop';
const px = e.clientX / zoom;
const py = e.clientY / zoom;
dragOffsetRef.current = {
x: e.clientX - prop.x,
y: e.clientY - prop.y
x: px - prop.x,
y: py - prop.y
};
pointerHistoryRef.current = [{ x: e.clientX, y: e.clientY, time: Date.now() }];
pointerHistoryRef.current = [{ x: px, y: py, time: Date.now() }];
soundEngine.rustle(settings.soundEnabled, 0.5);
@@ -295,8 +328,9 @@ export const App: React.FC = () => {
const handleGlobalPointerMove = (e: React.PointerEvent) => {
if (!draggingIdRef.current) return;
const currentX = e.clientX;
const currentY = e.clientY;
const zoom = settings.zoom || 1.0;
const currentX = e.clientX / zoom;
const currentY = e.clientY / zoom;
const targetX = currentX - dragOffsetRef.current.x;
const targetY = currentY - dragOffsetRef.current.y;
@@ -538,8 +572,15 @@ export const App: React.FC = () => {
totalCards={visibleCards.length}
/>
{/* 3. Cards Layer */}
<div className="absolute inset-0 overflow-hidden">
{/* 3. Scalable Desk Workspace (Cards, Props & Pen) */}
<div
className="absolute top-0 left-0 overflow-hidden will-change-transform origin-top-left transition-transform duration-150"
style={{
width: `${(100 / settings.zoom).toFixed(2)}vw`,
height: `${(100 / settings.zoom).toFixed(2)}vh`,
transform: `scale(${settings.zoom})`
}}
>
{visibleCards.map((card) => (
<PaperCard
key={card.id}
@@ -563,24 +604,24 @@ export const App: React.FC = () => {
}}
/>
))}
</div>
{/* 4. Stationery Props Layer (Pencils, Pens, Washi rolls, Paperclips) */}
{settings.showProps && (
<DeskPropsLayer
props={propsList}
lightAngle={settings.lightAngle}
onPointerDown={handlePropPointerDown}
{/* 4. Stationery Props Layer */}
{settings.showProps && (
<DeskPropsLayer
props={propsList}
lightAngle={settings.lightAngle}
onPointerDown={handlePropPointerDown}
/>
)}
{/* 5. Pen / Freehand Ink Layer */}
<PenLayer
active={settings.penMode}
strokes={strokes}
onAddStroke={(s) => setStrokes((prev) => [...prev, s])}
onClear={() => setStrokes([])}
/>
)}
{/* 5. Pen / Freehand Ink Layer */}
<PenLayer
active={settings.penMode}
strokes={strokes}
onAddStroke={(s) => setStrokes((prev) => [...prev, s])}
onClear={() => setStrokes([])}
/>
</div>
{/* 6. Bottom Shortcut Bar / Mobile Thumb Dock */}
<BottomBar
@@ -593,8 +634,16 @@ export const App: React.FC = () => {
}
onTogglePen={() => setSettings((prev) => ({ ...prev, penMode: !prev.penMode }))}
onAddCard={() => setIsAddOpen(true)}
onCycleZoom={() =>
setSettings((prev) => {
const z = prev.zoom;
const nextZoom = z <= 0.55 ? 0.75 : z <= 0.8 ? 1.0 : 0.5;
return { ...prev, zoom: nextZoom };
})
}
penActive={settings.penMode}
isGrid={settings.layoutMode === 'grid'}
zoomLevel={settings.zoom}
/>
{/* 7. Modals */}
+22 -9
View File
@@ -1,13 +1,15 @@
import React from 'react';
import { Shuffle, Grid, LayoutGrid, PenTool, Plus } from 'lucide-react';
import { Shuffle, Grid, LayoutGrid, PenTool, Plus, ZoomIn } from 'lucide-react';
interface BottomBarProps {
onShuffle?: () => void;
onToggleLayout?: () => void;
onTogglePen?: () => void;
onAddCard?: () => void;
onCycleZoom?: () => void;
penActive?: boolean;
isGrid?: boolean;
zoomLevel?: number;
}
export const BottomBar: React.FC<BottomBarProps> = ({
@@ -15,8 +17,10 @@ export const BottomBar: React.FC<BottomBarProps> = ({
onToggleLayout,
onTogglePen,
onAddCard,
onCycleZoom,
penActive = false,
isGrid = false
isGrid = false,
zoomLevel = 1.0
}) => {
return (
<>
@@ -43,13 +47,13 @@ export const BottomBar: React.FC<BottomBarProps> = ({
</footer>
{/* 2. Mobile: Thumb-friendly floating dock */}
<footer className="fixed bottom-3 left-1/2 -translate-x-1/2 z-40 px-3 py-1.5 bg-[#1C1A17]/95 text-neutral-300 rounded-full shadow-2xl border border-neutral-700/80 backdrop-blur-md flex md:hidden items-center gap-3 select-none">
<footer className="fixed bottom-3 left-1/2 -translate-x-1/2 z-40 px-3 py-1.5 bg-[#1C1A17]/95 text-neutral-300 rounded-full shadow-2xl border border-neutral-700/80 backdrop-blur-md flex md:hidden items-center gap-2 select-none">
<button
onClick={onShuffle}
className="p-2 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition"
title="Shuffle Desk"
>
<Shuffle size={16} />
<Shuffle size={15} />
</button>
<button
@@ -57,15 +61,24 @@ export const BottomBar: React.FC<BottomBarProps> = ({
className="p-2 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition"
title="Toggle Grid / Scatter"
>
{isGrid ? <LayoutGrid size={16} className="text-amber-400" /> : <Grid size={16} />}
{isGrid ? <LayoutGrid size={15} className="text-amber-400" /> : <Grid size={15} />}
</button>
<button
onClick={onCycleZoom}
className="px-2 py-1.5 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition font-mono text-[11px] flex items-center gap-1 border border-neutral-700"
title="Scale Desk (Tap to cycle zoom)"
>
<ZoomIn size={13} className="text-amber-400" />
<span>{Math.round(zoomLevel * 100)}%</span>
</button>
<button
onClick={onAddCard}
className="p-2.5 rounded-full bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition shadow-md"
title="Add Note"
className="p-2 rounded-full bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition shadow-md"
title="Add Note or Photo"
>
<Plus size={18} />
<Plus size={17} />
</button>
<button
@@ -75,7 +88,7 @@ export const BottomBar: React.FC<BottomBarProps> = ({
}`}
title="Draw / Pen"
>
<PenTool size={16} />
<PenTool size={15} />
</button>
</footer>
</>
+54
View File
@@ -175,6 +175,24 @@ export const TopBar: React.FC<TopBarProps> = ({
</span>
</div>
{/* Page Scale / Zoom */}
<div className="flex items-center gap-1 bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
<span className="text-[10px] text-neutral-400 px-1 font-mono">Zoom:</span>
{[0.5, 0.75, 1.0].map((z) => (
<button
key={z}
onClick={() => onUpdateSettings({ zoom: z })}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition ${
Math.abs(settings.zoom - z) < 0.05
? 'bg-neutral-600 text-amber-300 font-bold'
: 'text-neutral-400 hover:text-neutral-200'
}`}
>
{Math.round(z * 100)}%
</button>
))}
</div>
{/* Checkboxes: Props, Motion, Pen */}
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
<input
@@ -310,6 +328,42 @@ export const TopBar: React.FC<TopBarProps> = ({
</div>
</div>
{/* Page Scale / Card Zoom */}
<div>
<div className="flex justify-between items-center text-neutral-400 mb-1.5 font-semibold">
<span>Card Scale & Zoom</span>
<span className="font-mono text-amber-300 font-bold">{Math.round(settings.zoom * 100)}%</span>
</div>
<div className="grid grid-cols-3 gap-1.5 mb-2">
{[
{ label: '50% (Tiny)', value: 0.5 },
{ label: '70% (Balanced)', value: 0.7 },
{ label: '100% (Full)', value: 1.0 }
].map((p) => (
<button
key={p.value}
onClick={() => onUpdateSettings({ zoom: p.value })}
className={`py-1.5 rounded border text-center transition font-mono ${
Math.abs(settings.zoom - p.value) < 0.05
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
: 'bg-neutral-800 text-neutral-400 border-neutral-700'
}`}
>
{p.label}
</button>
))}
</div>
<input
type="range"
min="0.4"
max="1.2"
step="0.05"
value={settings.zoom}
onChange={(e) => onUpdateSettings({ zoom: Number(e.target.value) })}
className="w-full accent-amber-500 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
{/* Sliders: Density, Grain, Light */}
<div className="space-y-3 pt-1">
<div>
+1
View File
@@ -88,4 +88,5 @@ export interface DeskSettings {
penMode: boolean;
soundEnabled: boolean;
activeFilter: 'all' | CardType;
zoom: number; // 0.4 - 1.5 zoom level
}