chore(dev): react-grab viewport sync helper for in-page debug toolbar
Mounts a dev-only client component that syncs the react-grab debug toolbar's pinned edge / collapsed state across viewport changes (so the toolbar doesn't drift off-screen when resizing or rotating). Render is gated by NODE_ENV === 'development' in src/app/layout.tsx; production builds tree-shake the import out via process.env replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
|
|||||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { classifyFormFactor } from '@/lib/form-factor';
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
|
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -66,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
type Edge = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
|
||||||
|
interface ToolbarState {
|
||||||
|
edge: Edge;
|
||||||
|
ratio: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
defaultAction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactGrabAPI {
|
||||||
|
setToolbarState: (state: Partial<ToolbarState>) => void;
|
||||||
|
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__REACT_GRAB__?: ReactGrabAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||||
|
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
|
||||||
|
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
|
||||||
|
|
||||||
|
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
|
||||||
|
edge: 'bottom',
|
||||||
|
ratio: 0.5,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOBILE_DEFAULT: Partial<ToolbarState> = {
|
||||||
|
edge: 'right',
|
||||||
|
ratio: 0.5,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReactGrabViewportSync() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
let pollId: number | undefined;
|
||||||
|
|
||||||
|
const wireUp = (api: ReactGrabAPI) => {
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
|
||||||
|
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
|
||||||
|
|
||||||
|
let suppressNextWrite = false;
|
||||||
|
const apply = () => {
|
||||||
|
const stored = localStorage.getItem(keyFor());
|
||||||
|
suppressNextWrite = true;
|
||||||
|
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
|
||||||
|
const unsubscribe = api.onToolbarStateChange((state) => {
|
||||||
|
if (suppressNextWrite) {
|
||||||
|
suppressNextWrite = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(keyFor(), JSON.stringify(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
mql.addEventListener('change', apply);
|
||||||
|
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryWire = () => {
|
||||||
|
const api = window.__REACT_GRAB__;
|
||||||
|
if (!api) return false;
|
||||||
|
wireUp(api);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tryWire()) {
|
||||||
|
pollId = window.setInterval(() => {
|
||||||
|
if (tryWire() && pollId !== undefined) {
|
||||||
|
window.clearInterval(pollId);
|
||||||
|
pollId = undefined;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (pollId !== undefined) {
|
||||||
|
window.clearInterval(pollId);
|
||||||
|
pollId = undefined;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollId !== undefined) window.clearInterval(pollId);
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user