Better mobile layout
All checks were successful
Build and Deploy / build-deploy (push) Successful in 31s

This commit is contained in:
2026-02-15 11:45:15 +00:00
parent 86793f6321
commit 074dd57185
11 changed files with 280 additions and 59 deletions

View File

@@ -1,7 +1,6 @@
import { Provider } from '@react-spectrum/s2';
import { lazy, Suspense, useDeferredValue, useRef, useState } from 'react';
import type { EffectCanvasHandle } from './components/canvas/effect';
import { Controls } from './components/controls';
import { ImageLoader } from './components/image-loader';
import { Layout } from './components/layout';
import { useZoomAnimation } from './hooks/use-zoom-animation';
@@ -59,6 +58,14 @@ const App = () => {
effectCanvasRef.current?.download();
};
const handleClear = () => {
setImage(null);
setImageUrl(null);
setCurve(null);
setDrosteParams(DEFAULT_DROSTE_PARAMS);
setActiveTab('mask');
};
const renderCanvas = () => {
if (!image || !imageUrl) {
return <ImageLoader onImageLoad={handleImageLoad} />;
@@ -92,17 +99,14 @@ const App = () => {
<Provider colorScheme="dark" locale="en-GB">
<Layout
canvas={renderCanvas()}
controls={
<Controls
activeTab={activeTab}
onTabChange={setActiveTab}
hasImage={!!image}
curve={curve}
onDownload={handleDownload}
drosteParams={drosteParams}
onDrosteParamsChange={handleDrosteParamsChange}
/>
}
activeTab={activeTab}
onTabChange={setActiveTab}
hasImage={!!image}
curve={curve}
onDownload={handleDownload}
onClear={handleClear}
drosteParams={drosteParams}
onDrosteParamsChange={handleDrosteParamsChange}
/>
</Provider>
);

View File

@@ -27,3 +27,15 @@
.sliderWrapper {
flex: 1;
}
.actionsWrapper {
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 767px) {
.actionsWrapper {
display: none;
}
}

View File

@@ -8,6 +8,7 @@ type Props = {
drosteParams: DrosteParams;
onDrosteParamsChange: (params: Partial<DrosteParams>) => void;
onDownload: () => void;
onClear: () => void;
};
const getSpiralDescription = (spiral: number) => {
@@ -16,7 +17,12 @@ const getSpiralDescription = (spiral: number) => {
return 'No spiral (regular Droste)';
};
export const EffectTabPanel = ({ drosteParams, onDrosteParamsChange, onDownload }: Props) => (
export const EffectTabPanel = ({
drosteParams,
onDrosteParamsChange,
onDownload,
onClear,
}: Props) => (
<div className={styles.container}>
<div className={styles.section}>
<Slider
@@ -78,8 +84,13 @@ export const EffectTabPanel = ({ drosteParams, onDrosteParamsChange, onDownload
)}
</div>
<Button variant="accent" onPress={onDownload}>
Download PNG
</Button>
<div className={styles.actionsWrapper}>
<Button variant="secondary" onPress={onClear}>
Clear
</Button>
<Button variant="accent" onPress={onDownload}>
Download PNG
</Button>
</div>
</div>
);

View File

@@ -3,3 +3,13 @@
display: flex;
flex-direction: column;
}
.tabList {
display: block;
}
@media (max-width: 767px) {
.tabList {
display: none;
}
}

View File

@@ -10,6 +10,7 @@ type Props = {
hasImage: boolean;
curve: BezierCurve | null;
onDownload: () => void;
onClear: () => void;
drosteParams: DrosteParams;
onDrosteParamsChange: (params: Partial<DrosteParams>) => void;
};
@@ -27,6 +28,7 @@ export const Controls = ({
hasImage,
curve,
onDownload,
onClear,
drosteParams,
onDrosteParamsChange,
}: Props) => {
@@ -39,20 +41,27 @@ export const Controls = ({
selectedKey={activeTab}
onSelectionChange={(key) => onTabChange(key as ActiveTab)}
>
<TabList>
<Tab id="mask">Mask</Tab>
<Tab id="effect" isDisabled={blockerReason !== null}>
Effect
</Tab>
</TabList>
<div className={styles.tabList}>
<TabList>
<Tab id="mask">Mask</Tab>
<Tab id="effect" isDisabled={blockerReason !== null}>
Effect
</Tab>
</TabList>
</div>
<TabPanel id="mask">
<MaskTabPanel blockerReason={blockerReason} onTabChange={onTabChange} />
<MaskTabPanel
blockerReason={blockerReason}
onTabChange={onTabChange}
onClear={onClear}
/>
</TabPanel>
<TabPanel id="effect">
<EffectTabPanel
drosteParams={drosteParams}
onDrosteParamsChange={onDrosteParamsChange}
onDownload={onDownload}
onClear={onClear}
/>
</TabPanel>
</Tabs>

View File

@@ -12,9 +12,15 @@
margin: 0;
}
.nextButtonWrapper {
.actionsWrapper {
margin-top: 24px;
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 767px) {
.actionsWrapper {
display: none;
}
}

View File

@@ -7,6 +7,7 @@ type BlockerReason = 'no-image' | 'no-curve' | 'curve-not-closed' | null;
type Props = {
blockerReason: BlockerReason;
onTabChange: (tab: ActiveTab) => void;
onClear: () => void;
};
const blockerContent: Record<Exclude<BlockerReason, null>, { heading: string; content: string }> = {
@@ -24,7 +25,7 @@ const blockerContent: Record<Exclude<BlockerReason, null>, { heading: string; co
},
};
export const MaskTabPanel = ({ blockerReason, onTabChange }: Props) => (
export const MaskTabPanel = ({ blockerReason, onTabChange, onClear }: Props) => (
<div className={styles.container}>
<p className={styles.hint}>
Load an image to get started. Draw a closed shape around the area where the effect will
@@ -39,7 +40,10 @@ export const MaskTabPanel = ({ blockerReason, onTabChange }: Props) => (
double-click a corner point to turn it into a curve point. Right-click to delete points.
</p>
<p className={styles.hint}>When you're done, click the Effect tab to preview and export.</p>
<div className={styles.nextButtonWrapper}>
<div className={styles.actionsWrapper}>
<Button variant="secondary" onPress={onClear}>
Clear
</Button>
<Button
variant="accent"
isDisabled={blockerReason !== null}

View File

@@ -22,22 +22,66 @@
flex-direction: column;
}
.sidebarHeader {
display: none;
}
.sidebarContent {
flex: 1;
overflow-y: auto;
}
.mobileTabMenu {
display: none;
}
.mobileActions {
display: none;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
border-radius: 4px;
}
.closeButton:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Mobile layout */
@media (max-width: 767px) {
.layout {
flex-direction: column;
position: relative;
overflow: hidden;
}
.mobileTabMenu {
display: block;
flex-shrink: 0;
}
.main {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
}
.mobileActions {
display: flex;
flex-shrink: 0;
gap: 12px;
padding: 12px 16px;
background: #2a2a2a;
border-top: 1px solid #3a3a3a;
justify-content: center;
}
.sidebar {
@@ -48,7 +92,6 @@
max-width: 320px;
height: 100%;
border-left: 1px solid #3a3a3a;
border-right: none;
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.3);
z-index: 200;
transform: translateX(0);
@@ -56,31 +99,22 @@
overflow: visible;
}
.sidebarContent {
height: 100%;
}
.sidebar[data-collapsed="true"] {
transform: translateX(100%);
}
.mobileToggle {
.sidebarHeader {
display: flex;
position: absolute;
top: 16px;
left: -48px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #6366f1;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
justify-content: flex-end;
height: 56px;
padding: 0 12px;
border-bottom: 1px solid #3a3a3a;
flex-shrink: 0;
}
}
@media (min-width: 768px) {
.mobileToggle {
display: none;
.sidebarContent {
flex: 1;
overflow-y: auto;
}
}

View File

@@ -1,31 +1,100 @@
import { ActionButton } from '@react-spectrum/s2';
import { Button } from '@react-spectrum/s2';
import CloseIcon from '@react-spectrum/s2/icons/Close';
import MenuHamburgerIcon from '@react-spectrum/s2/icons/MenuHamburger';
import { type ReactNode, useState } from 'react';
import type { ActiveTab, BezierCurve, DrosteParams } from '../../types';
import { Controls } from '../controls';
import { TabMenu } from './tab-menu';
import styles from './index.module.css';
type Props = {
canvas: ReactNode;
controls: ReactNode;
activeTab: ActiveTab;
onTabChange: (tab: ActiveTab) => void;
hasImage: boolean;
curve: BezierCurve | null;
onDownload: () => void;
onClear: () => void;
drosteParams: DrosteParams;
onDrosteParamsChange: (params: Partial<DrosteParams>) => void;
};
export const Layout = ({ canvas, controls }: Props) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const getBlockerReason = (hasImage: boolean, curve: BezierCurve | null) => {
if (!hasImage) return 'no-image' as const;
if (!curve || curve.anchors.length === 0) return 'no-curve' as const;
if (!curve.closed) return 'curve-not-closed' as const;
return null;
};
export const Layout = ({
canvas,
activeTab,
onTabChange,
hasImage,
curve,
onDownload,
onClear,
drosteParams,
onDrosteParamsChange,
}: Props) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const blockerReason = getBlockerReason(hasImage, curve);
const primaryAction = activeTab === 'mask' ? (
<Button
variant="accent"
isDisabled={blockerReason !== null}
onPress={() => onTabChange('effect')}
>
Next
</Button>
) : (
<Button variant="accent" onPress={onDownload}>
Download
</Button>
);
return (
<div className={styles.layout}>
<div className={styles.mobileTabMenu}>
<TabMenu
activeTab={activeTab}
onTabChange={onTabChange}
blockerReason={blockerReason}
onOpenMenu={() => setSidebarOpen(true)}
/>
</div>
<main className={styles.main}>{canvas}</main>
<div className={styles.mobileActions}>
<Button variant="secondary" onPress={onClear}>
Clear
</Button>
{primaryAction}
</div>
<aside className={styles.sidebar} data-collapsed={!sidebarOpen}>
<div className={styles.mobileToggle}>
<ActionButton
isQuiet
onPress={() => setSidebarOpen(!sidebarOpen)}
aria-label={sidebarOpen ? 'Hide controls' : 'Show controls'}
<div className={styles.sidebarHeader}>
<button
className={styles.closeButton}
onClick={() => setSidebarOpen(false)}
aria-label="Close menu"
>
{sidebarOpen ? <CloseIcon /> : <MenuHamburgerIcon />}
</ActionButton>
<CloseIcon />
</button>
</div>
<div className={styles.sidebarContent}>
<Controls
activeTab={activeTab}
onTabChange={onTabChange}
hasImage={hasImage}
curve={curve}
onDownload={onDownload}
onClear={onClear}
drosteParams={drosteParams}
onDrosteParamsChange={onDrosteParamsChange}
/>
</div>
<div className={styles.sidebarContent}>{controls}</div>
</aside>
</div>
);

View File

@@ -0,0 +1,25 @@
.tabMenu {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 56px;
background: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.menuButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
border-radius: 4px;
}
.menuButton:hover {
background: rgba(255, 255, 255, 0.1);
}

View File

@@ -0,0 +1,37 @@
import { Tab, TabList, Tabs } from '@react-spectrum/s2';
import MenuHamburgerIcon from '@react-spectrum/s2/icons/MenuHamburger';
import type { ActiveTab } from '../../types';
import styles from './tab-menu.module.css';
type BlockerReason = 'no-image' | 'no-curve' | 'curve-not-closed' | null;
type Props = {
activeTab: ActiveTab;
onTabChange: (tab: ActiveTab) => void;
blockerReason: BlockerReason;
onOpenMenu: () => void;
};
export const TabMenu = ({ activeTab, onTabChange, blockerReason, onOpenMenu }: Props) => (
<div className={styles.tabMenu}>
<Tabs
aria-label="Editor mode"
selectedKey={activeTab}
onSelectionChange={(key) => onTabChange(key as ActiveTab)}
>
<TabList>
<Tab id="mask">Mask</Tab>
<Tab id="effect" isDisabled={blockerReason !== null}>
Effect
</Tab>
</TabList>
</Tabs>
<button
className={styles.menuButton}
onClick={onOpenMenu}
aria-label="Show controls"
>
<MenuHamburgerIcon />
</button>
</div>
);