73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
|
|
import { useEffect, useState } from 'react';
|
||
|
|
|
||
|
|
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
|
||
|
|
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
|
||
|
|
* CSS and this hook must agree on where mobile chrome kicks in. */
|
||
|
|
export const MOBILE_BREAKPOINT_PX = 768;
|
||
|
|
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
|
||
|
|
|
||
|
|
function computeIsMobile(): boolean {
|
||
|
|
if (typeof window === 'undefined') return false;
|
||
|
|
if (typeof window.matchMedia === 'function') {
|
||
|
|
try {
|
||
|
|
return window.matchMedia(MOBILE_QUERY).matches;
|
||
|
|
} catch {
|
||
|
|
// Fall through to the width check below (e.g. some older/embedded
|
||
|
|
// WebViews expose a matchMedia that throws instead of omitting it).
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reports whether the viewport is at or below the mobile editor breakpoint.
|
||
|
|
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
|
||
|
|
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
|
||
|
|
* above the breakpoint, so this is the single source of truth both React
|
||
|
|
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
|
||
|
|
*
|
||
|
|
* Falls back to a `resize` listener + `window.innerWidth` when
|
||
|
|
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
|
||
|
|
* hook degrades gracefully rather than throwing.
|
||
|
|
*/
|
||
|
|
export function useIsMobile(): boolean {
|
||
|
|
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (typeof window === 'undefined') return;
|
||
|
|
|
||
|
|
if (typeof window.matchMedia === 'function') {
|
||
|
|
let mql: MediaQueryList | null = null;
|
||
|
|
try {
|
||
|
|
mql = window.matchMedia(MOBILE_QUERY);
|
||
|
|
} catch {
|
||
|
|
mql = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (mql) {
|
||
|
|
const handleChange = () => setIsMobile(mql!.matches);
|
||
|
|
handleChange();
|
||
|
|
|
||
|
|
if (typeof mql.addEventListener === 'function') {
|
||
|
|
mql.addEventListener('change', handleChange);
|
||
|
|
return () => mql!.removeEventListener('change', handleChange);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Safari < 14 only supports the deprecated addListener/removeListener pair.
|
||
|
|
const legacyMql = mql as MediaQueryList & {
|
||
|
|
addListener?: (listener: () => void) => void;
|
||
|
|
removeListener?: (listener: () => void) => void;
|
||
|
|
};
|
||
|
|
legacyMql.addListener?.(handleChange);
|
||
|
|
return () => legacyMql.removeListener?.(handleChange);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
|
||
|
|
window.addEventListener('resize', handleResize);
|
||
|
|
return () => window.removeEventListener('resize', handleResize);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return isMobile;
|
||
|
|
}
|