Add desk scale zoom controls and non-overlapping mobile grid layout
This commit is contained in:
1 parent
7d9ce809a8
commit
157fc83a47
4 files changed
+185
-68
No files matched your search
+108
-59
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -88,4 +88,5 @@ export interface DeskSettings {
|
||||
penMode: boolean;
|
||||
soundEnabled: boolean;
|
||||
activeFilter: 'all' | CardType;
|
||||
zoom: number; // 0.4 - 1.5 zoom level
|
||||
}
|
||||
Reference in new issue
Block a user