Better mobile layout
All checks were successful
Build and Deploy / build-deploy (push) Successful in 31s
All checks were successful
Build and Deploy / build-deploy (push) Successful in 31s
This commit is contained in:
28
src/App.tsx
28
src/App.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -27,3 +27,15 @@
|
||||
.sliderWrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actionsWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.actionsWrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tabList {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
25
src/components/layout/tab-menu.module.css
Normal file
25
src/components/layout/tab-menu.module.css
Normal 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);
|
||||
}
|
||||
37
src/components/layout/tab-menu.tsx
Normal file
37
src/components/layout/tab-menu.tsx
Normal 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>
|
||||
);
|
||||
Reference in New Issue
Block a user