@snowcone-app/ui 0.1.42 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface RenderGuardOptions {
|
|
6
|
+
/** Name of the component being monitored */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Maximum renders per second before warning (default: 3) */
|
|
9
|
+
maxRendersPerSecond?: number;
|
|
10
|
+
/** Whether to enable the guard (default: only in development) */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* useRenderGuard - Development tool to detect excessive re-renders
|
|
16
|
+
*
|
|
17
|
+
* This hook monitors component render frequency and warns when a component
|
|
18
|
+
* is re-rendering too frequently, which can cause performance issues
|
|
19
|
+
* especially during canvas drag operations.
|
|
20
|
+
*
|
|
21
|
+
* **Why this exists:**
|
|
22
|
+
* Canvas drag operations are extremely sensitive to React re-renders.
|
|
23
|
+
* Even a single unnecessary re-render during drag can cause visible lag.
|
|
24
|
+
* This hook helps identify which components are re-rendering excessively.
|
|
25
|
+
*
|
|
26
|
+
* **Usage:**
|
|
27
|
+
* ```tsx
|
|
28
|
+
* function MyCanvasComponent() {
|
|
29
|
+
* useRenderGuard({ name: 'MyCanvasComponent', maxRendersPerSecond: 2 });
|
|
30
|
+
* // ... rest of component
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* **Console output:**
|
|
35
|
+
* ```
|
|
36
|
+
* [RenderGuard:MyCanvasComponent] 5 renders/sec - check for re-render leaks
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @param options - Configuration options
|
|
40
|
+
*/
|
|
41
|
+
export function useRenderGuard({
|
|
42
|
+
name,
|
|
43
|
+
maxRendersPerSecond = 3,
|
|
44
|
+
enabled = process.env.NODE_ENV === 'development',
|
|
45
|
+
}: RenderGuardOptions): void {
|
|
46
|
+
// Skip entirely in production
|
|
47
|
+
if (!enabled) return;
|
|
48
|
+
|
|
49
|
+
// Track render timestamps in a ref (persists across renders)
|
|
50
|
+
const timestampsRef = useRef<number[]>([]);
|
|
51
|
+
|
|
52
|
+
const now = performance.now();
|
|
53
|
+
|
|
54
|
+
// Filter out timestamps older than 1 second
|
|
55
|
+
timestampsRef.current = timestampsRef.current.filter(t => t > now - 1000);
|
|
56
|
+
|
|
57
|
+
// Add current timestamp
|
|
58
|
+
timestampsRef.current.push(now);
|
|
59
|
+
|
|
60
|
+
const rendersInLastSecond = timestampsRef.current.length;
|
|
61
|
+
|
|
62
|
+
// Warn if exceeding threshold
|
|
63
|
+
if (rendersInLastSecond > maxRendersPerSecond) {
|
|
64
|
+
console.warn(
|
|
65
|
+
`[RenderGuard:${name}] ${rendersInLastSecond} renders/sec (max: ${maxRendersPerSecond}) - check for re-render leaks\n` +
|
|
66
|
+
` Common causes:\n` +
|
|
67
|
+
` - Context subscriptions to frequently-changing values (e.g., mockupResults)\n` +
|
|
68
|
+
` - Callbacks with dependencies that change every render\n` +
|
|
69
|
+
` - Props that are new objects/arrays every render\n` +
|
|
70
|
+
` - Parent component re-rendering unnecessarily`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* useRenderCount - Simple render counter for debugging
|
|
77
|
+
*
|
|
78
|
+
* Returns the current render count for a component.
|
|
79
|
+
* Useful for adding to console.log statements to track renders.
|
|
80
|
+
*
|
|
81
|
+
* **Usage:**
|
|
82
|
+
* ```tsx
|
|
83
|
+
* const renderCount = useRenderCount('MyComponent');
|
|
84
|
+
* console.log(`[MyComponent] Render #${renderCount}`);
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function useRenderCount(componentName?: string): number {
|
|
88
|
+
const countRef = useRef(0);
|
|
89
|
+
countRef.current++;
|
|
90
|
+
|
|
91
|
+
// Log every 5th render in development
|
|
92
|
+
if (process.env.NODE_ENV === 'development' && componentName && countRef.current % 5 === 0) {
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return countRef.current;
|
|
96
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useScrollDirection Hook
|
|
5
|
+
*
|
|
6
|
+
* Detects scroll direction and velocity for predicting which mockups
|
|
7
|
+
* are most likely to be viewed next (Level 3 ordering).
|
|
8
|
+
*
|
|
9
|
+
* Key features:
|
|
10
|
+
* - RAF-debounced for performance
|
|
11
|
+
* - Calculates velocity in pixels per second
|
|
12
|
+
* - Filters noise (ignores small movements < 5px)
|
|
13
|
+
* - Automatic idle detection after 150ms of no scroll
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const { direction, velocity } = useScrollDirection();
|
|
18
|
+
*
|
|
19
|
+
* // direction: 'up' | 'down' | 'idle'
|
|
20
|
+
* // velocity: number (pixels per second)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
25
|
+
|
|
26
|
+
export type ScrollDirection = 'up' | 'down' | 'idle';
|
|
27
|
+
|
|
28
|
+
export interface ScrollState {
|
|
29
|
+
/** Current scroll direction */
|
|
30
|
+
direction: ScrollDirection;
|
|
31
|
+
/** Scroll velocity in pixels per second */
|
|
32
|
+
velocity: number;
|
|
33
|
+
/** Last known scroll position */
|
|
34
|
+
scrollY: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const IDLE_TIMEOUT_MS = 150;
|
|
38
|
+
const MIN_DELTA_PX = 5;
|
|
39
|
+
|
|
40
|
+
export function useScrollDirection(): ScrollState {
|
|
41
|
+
const [state, setState] = useState<ScrollState>({
|
|
42
|
+
direction: 'idle',
|
|
43
|
+
velocity: 0,
|
|
44
|
+
scrollY: typeof window !== 'undefined' ? window.scrollY : 0,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const lastScrollRef = useRef({
|
|
48
|
+
top: typeof window !== 'undefined' ? window.scrollY : 0,
|
|
49
|
+
time: Date.now(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const rafIdRef = useRef<number | null>(null);
|
|
53
|
+
const idleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
54
|
+
|
|
55
|
+
const handleScroll = useCallback(() => {
|
|
56
|
+
// Debounce via RAF - skip if we already have a pending update
|
|
57
|
+
if (rafIdRef.current !== null) return;
|
|
58
|
+
|
|
59
|
+
rafIdRef.current = requestAnimationFrame(() => {
|
|
60
|
+
const currentTop = window.scrollY;
|
|
61
|
+
const currentTime = Date.now();
|
|
62
|
+
const last = lastScrollRef.current;
|
|
63
|
+
|
|
64
|
+
const deltaY = currentTop - last.top;
|
|
65
|
+
const deltaTime = currentTime - last.time;
|
|
66
|
+
|
|
67
|
+
// Clear any pending idle timeout since we're scrolling
|
|
68
|
+
if (idleTimeoutRef.current) {
|
|
69
|
+
clearTimeout(idleTimeoutRef.current);
|
|
70
|
+
idleTimeoutRef.current = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only update for meaningful scroll (avoid noise from tiny movements)
|
|
74
|
+
if (Math.abs(deltaY) > MIN_DELTA_PX && deltaTime > 0) {
|
|
75
|
+
const velocity = (Math.abs(deltaY) / deltaTime) * 1000;
|
|
76
|
+
const direction: ScrollDirection = deltaY > 0 ? 'down' : 'up';
|
|
77
|
+
|
|
78
|
+
setState({
|
|
79
|
+
direction,
|
|
80
|
+
velocity,
|
|
81
|
+
scrollY: currentTop,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
lastScrollRef.current = {
|
|
85
|
+
top: currentTop,
|
|
86
|
+
time: currentTime,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Set idle timeout - will reset direction to 'idle' after scroll stops
|
|
90
|
+
idleTimeoutRef.current = setTimeout(() => {
|
|
91
|
+
setState((prev) => ({
|
|
92
|
+
...prev,
|
|
93
|
+
direction: 'idle',
|
|
94
|
+
velocity: 0,
|
|
95
|
+
}));
|
|
96
|
+
}, IDLE_TIMEOUT_MS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
rafIdRef.current = null;
|
|
100
|
+
});
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
// Only run on client
|
|
105
|
+
if (typeof window === 'undefined') return;
|
|
106
|
+
|
|
107
|
+
// Initialize with current scroll position
|
|
108
|
+
lastScrollRef.current = {
|
|
109
|
+
top: window.scrollY,
|
|
110
|
+
time: Date.now(),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
window.removeEventListener('scroll', handleScroll);
|
|
117
|
+
|
|
118
|
+
if (rafIdRef.current !== null) {
|
|
119
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (idleTimeoutRef.current) {
|
|
123
|
+
clearTimeout(idleTimeoutRef.current);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}, [handleScroll]);
|
|
127
|
+
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get scroll direction without React state (for use outside components)
|
|
133
|
+
* Returns a getter function that always returns the current scroll direction
|
|
134
|
+
*/
|
|
135
|
+
export function createScrollDirectionTracker(): {
|
|
136
|
+
getDirection: () => ScrollDirection;
|
|
137
|
+
getVelocity: () => number;
|
|
138
|
+
destroy: () => void;
|
|
139
|
+
} {
|
|
140
|
+
let direction: ScrollDirection = 'idle';
|
|
141
|
+
let velocity = 0;
|
|
142
|
+
let lastTop = typeof window !== 'undefined' ? window.scrollY : 0;
|
|
143
|
+
let lastTime = Date.now();
|
|
144
|
+
let rafId: number | null = null;
|
|
145
|
+
let idleTimeout: NodeJS.Timeout | null = null;
|
|
146
|
+
|
|
147
|
+
const handleScroll = () => {
|
|
148
|
+
if (rafId !== null) return;
|
|
149
|
+
|
|
150
|
+
rafId = requestAnimationFrame(() => {
|
|
151
|
+
const currentTop = window.scrollY;
|
|
152
|
+
const currentTime = Date.now();
|
|
153
|
+
const deltaY = currentTop - lastTop;
|
|
154
|
+
const deltaTime = currentTime - lastTime;
|
|
155
|
+
|
|
156
|
+
if (idleTimeout) {
|
|
157
|
+
clearTimeout(idleTimeout);
|
|
158
|
+
idleTimeout = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (Math.abs(deltaY) > MIN_DELTA_PX && deltaTime > 0) {
|
|
162
|
+
velocity = (Math.abs(deltaY) / deltaTime) * 1000;
|
|
163
|
+
direction = deltaY > 0 ? 'down' : 'up';
|
|
164
|
+
lastTop = currentTop;
|
|
165
|
+
lastTime = currentTime;
|
|
166
|
+
|
|
167
|
+
idleTimeout = setTimeout(() => {
|
|
168
|
+
direction = 'idle';
|
|
169
|
+
velocity = 0;
|
|
170
|
+
}, IDLE_TIMEOUT_MS);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
rafId = null;
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (typeof window !== 'undefined') {
|
|
178
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
getDirection: () => direction,
|
|
183
|
+
getVelocity: () => velocity,
|
|
184
|
+
destroy: () => {
|
|
185
|
+
if (typeof window !== 'undefined') {
|
|
186
|
+
window.removeEventListener('scroll', handleScroll);
|
|
187
|
+
}
|
|
188
|
+
if (rafId !== null) {
|
|
189
|
+
cancelAnimationFrame(rafId);
|
|
190
|
+
}
|
|
191
|
+
if (idleTimeout) {
|
|
192
|
+
clearTimeout(idleTimeout);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport Hooks
|
|
3
|
+
*
|
|
4
|
+
* A set of hooks for responsive viewport handling with consolidated resize listeners.
|
|
5
|
+
* These hooks work together to provide efficient viewport-aware layout without
|
|
6
|
+
* the performance issues of multiple resize listeners (especially on mobile).
|
|
7
|
+
*
|
|
8
|
+
* Usage pattern:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
11
|
+
* const dimensions = useViewportDimensions(isDesktop);
|
|
12
|
+
* const imageCapInfo = useResponsiveImageCap(dimensions);
|
|
13
|
+
* const isWideMonitor = useWideMonitorMode(imageCapInfo);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { useMediaQuery } from "./useMediaQuery";
|
|
18
|
+
|
|
19
|
+
export { useViewportDimensions } from "./useViewportDimensions";
|
|
20
|
+
export type { ViewportDimensions } from "./useViewportDimensions";
|
|
21
|
+
|
|
22
|
+
export { useResponsiveImageCap, DEFAULT_IMAGE_CONFIG } from "./useResponsiveImageCap";
|
|
23
|
+
export type { ResponsiveImageCapInfo, ResponsiveImageCapOptions } from "./useResponsiveImageCap";
|
|
24
|
+
|
|
25
|
+
export { useWideMonitorMode, isWideMonitor, hasEdgeBlur } from "./useWideMonitorMode";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useLayoutEffect, useEffect, useRef, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measures a container element's width in device pixels (CSS width × devicePixelRatio).
|
|
7
|
+
*
|
|
8
|
+
* Uses useLayoutEffect for the initial measurement so the correct width is
|
|
9
|
+
* available before any useEffect (like mockup URL generation) fires.
|
|
10
|
+
* Updates on window resize (debounced).
|
|
11
|
+
* Clamps to maxWidth to avoid absurdly large image requests.
|
|
12
|
+
*
|
|
13
|
+
* @param containerRef - Ref to the container element to measure
|
|
14
|
+
* @param maxWidth - Maximum device-pixel width to return (default: 3000)
|
|
15
|
+
* @returns Width in device pixels (0 until the container is mounted and laid out)
|
|
16
|
+
*/
|
|
17
|
+
export function useContainerWidth(
|
|
18
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
19
|
+
maxWidth = 3000
|
|
20
|
+
): number {
|
|
21
|
+
const [width, setWidth] = useState(0);
|
|
22
|
+
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
|
|
24
|
+
// Synchronous measurement after DOM commit — fires before any useEffect,
|
|
25
|
+
// so mockup URL generation always sees the real container width.
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
const el = containerRef.current;
|
|
28
|
+
if (!el) return;
|
|
29
|
+
const cssWidth = el.offsetWidth;
|
|
30
|
+
if (cssWidth === 0) return;
|
|
31
|
+
const dpr = window.devicePixelRatio ?? 1;
|
|
32
|
+
setWidth(Math.min(Math.round(cssWidth * dpr), maxWidth));
|
|
33
|
+
}, [containerRef, maxWidth]);
|
|
34
|
+
|
|
35
|
+
// Debounced resize listener
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const measure = () => {
|
|
38
|
+
const el = containerRef.current;
|
|
39
|
+
if (!el) return;
|
|
40
|
+
const cssWidth = el.offsetWidth;
|
|
41
|
+
if (cssWidth === 0) return;
|
|
42
|
+
const dpr = window.devicePixelRatio ?? 1;
|
|
43
|
+
setWidth(Math.min(Math.round(cssWidth * dpr), maxWidth));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleResize = () => {
|
|
47
|
+
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
|
48
|
+
resizeTimeoutRef.current = setTimeout(measure, 200);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
window.addEventListener("resize", handleResize);
|
|
52
|
+
return () => {
|
|
53
|
+
window.removeEventListener("resize", handleResize);
|
|
54
|
+
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
|
55
|
+
};
|
|
56
|
+
}, [containerRef, maxWidth]);
|
|
57
|
+
|
|
58
|
+
return width;
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SSR-safe hook to detect if a media query matches.
|
|
7
|
+
*
|
|
8
|
+
* Returns:
|
|
9
|
+
* - `null` during SSR and initial hydration (unknown state)
|
|
10
|
+
* - `true` if the media query matches
|
|
11
|
+
* - `false` if the media query doesn't match
|
|
12
|
+
*
|
|
13
|
+
* The null return value is important for:
|
|
14
|
+
* - Rendering both layouts during SSR to avoid hydration mismatch
|
|
15
|
+
* - Using CSS classes (md:hidden, md:block) for initial visibility
|
|
16
|
+
* - Transitioning to JS-based layout after hydration
|
|
17
|
+
*
|
|
18
|
+
* @param query - CSS media query string (e.g., "(min-width: 768px)")
|
|
19
|
+
* @returns boolean | null - null during SSR, boolean after hydration
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
24
|
+
*
|
|
25
|
+
* // Render both layouts during SSR, hide with CSS
|
|
26
|
+
* return (
|
|
27
|
+
* <>
|
|
28
|
+
* {(isDesktop === false || isDesktop === null) && (
|
|
29
|
+
* <div className="md:hidden">Mobile Layout</div>
|
|
30
|
+
* )}
|
|
31
|
+
* {(isDesktop === true || isDesktop === null) && (
|
|
32
|
+
* <div className="hidden md:block">Desktop Layout</div>
|
|
33
|
+
* )}
|
|
34
|
+
* </>
|
|
35
|
+
* );
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function useMediaQuery(query: string): boolean | null {
|
|
39
|
+
// Start with null to indicate "unknown" during SSR
|
|
40
|
+
const [matches, setMatches] = useState<boolean | null>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const media = window.matchMedia(query);
|
|
44
|
+
// Set immediately on mount - this is the first time we know the actual value
|
|
45
|
+
setMatches(media.matches);
|
|
46
|
+
const listener = () => setMatches(media.matches);
|
|
47
|
+
media.addEventListener("change", listener);
|
|
48
|
+
return () => media.removeEventListener("change", listener);
|
|
49
|
+
}, [query]);
|
|
50
|
+
|
|
51
|
+
return matches;
|
|
52
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { ViewportDimensions } from "./useViewportDimensions";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default layout configuration.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_IMAGE_CONFIG = {
|
|
10
|
+
ASPECT_RATIO: 16 / 9,
|
|
11
|
+
WIDTH_VW: 140, // Large enough to extend to viewport edge past sidebar
|
|
12
|
+
MAX_WIDTH_PX: 2700, // Allow larger images for bigger viewports
|
|
13
|
+
SIDEBAR_MAX_PX: 384,
|
|
14
|
+
SIDEBAR_MARGIN_PX: 32,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Information about the viewport for responsive image layouts.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResponsiveImageCapInfo {
|
|
21
|
+
/** Maximum width in pixels (uncapped - just returns viewport * 1.5) */
|
|
22
|
+
maxWidthPx: number;
|
|
23
|
+
/** Descriptive zone name for debugging */
|
|
24
|
+
zone: string;
|
|
25
|
+
/** Current viewport aspect ratio */
|
|
26
|
+
viewportAspectRatio: number;
|
|
27
|
+
/** Current viewport width */
|
|
28
|
+
viewportWidth: number;
|
|
29
|
+
/** Current viewport height */
|
|
30
|
+
viewportHeight: number;
|
|
31
|
+
/** Calculated image height based on width and aspect ratio */
|
|
32
|
+
imageHeight: number;
|
|
33
|
+
/** Increments on resize - use for Safari mask repaint workaround */
|
|
34
|
+
resizeVersion: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for customizing the hook behavior.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResponsiveImageCapOptions {
|
|
41
|
+
/** Image aspect ratio (default: 16/9) */
|
|
42
|
+
aspectRatio?: number;
|
|
43
|
+
/** Image width in viewport width units (default: 150) */
|
|
44
|
+
widthVw?: number;
|
|
45
|
+
/** Maximum width in pixels - no longer used, kept for backwards compat */
|
|
46
|
+
maxWidthPx?: number;
|
|
47
|
+
/** Sidebar width in pixels (for dynamic sidebar mode) */
|
|
48
|
+
sidebarWidthPx?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook to provide viewport dimensions for responsive image layouts.
|
|
53
|
+
*
|
|
54
|
+
* Calculates the exact image width needed to extend from the content area
|
|
55
|
+
* center to the viewport right edge (covering the sidebar zone).
|
|
56
|
+
*
|
|
57
|
+
* Formula: imageWidth = viewportWidth + sidebarPx + sidebarMargin
|
|
58
|
+
*
|
|
59
|
+
* @param dimensions - Viewport dimensions from useViewportDimensions hook
|
|
60
|
+
* @param options - Optional configuration
|
|
61
|
+
* @returns Viewport info and calculated image dimensions
|
|
62
|
+
*/
|
|
63
|
+
export function useResponsiveImageCap(
|
|
64
|
+
dimensions: ViewportDimensions,
|
|
65
|
+
options: ResponsiveImageCapOptions = {}
|
|
66
|
+
): ResponsiveImageCapInfo {
|
|
67
|
+
const { aspectRatio = DEFAULT_IMAGE_CONFIG.ASPECT_RATIO, sidebarWidthPx } =
|
|
68
|
+
options;
|
|
69
|
+
|
|
70
|
+
return useMemo(() => {
|
|
71
|
+
const vw = dimensions.width;
|
|
72
|
+
const vh = dimensions.height;
|
|
73
|
+
const viewportAspectRatio = dimensions.aspectRatio;
|
|
74
|
+
|
|
75
|
+
// Mobile/SSR - return sensible defaults
|
|
76
|
+
if (vw < 768) {
|
|
77
|
+
return {
|
|
78
|
+
maxWidthPx: DEFAULT_IMAGE_CONFIG.MAX_WIDTH_PX,
|
|
79
|
+
zone: "mobile",
|
|
80
|
+
viewportAspectRatio: viewportAspectRatio || 0,
|
|
81
|
+
viewportWidth: vw,
|
|
82
|
+
viewportHeight: vh,
|
|
83
|
+
imageHeight: 0,
|
|
84
|
+
resizeVersion: dimensions.resizeVersion,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Calculate sidebar width (use provided value or default 35% capped at 384px)
|
|
89
|
+
const sidebarPx =
|
|
90
|
+
sidebarWidthPx ??
|
|
91
|
+
Math.min(vw * 0.35, DEFAULT_IMAGE_CONFIG.SIDEBAR_MAX_PX);
|
|
92
|
+
|
|
93
|
+
// Content area is the space left of sidebar (where image is centered)
|
|
94
|
+
const contentAreaWidth =
|
|
95
|
+
vw - sidebarPx - DEFAULT_IMAGE_CONFIG.SIDEBAR_MARGIN_PX;
|
|
96
|
+
|
|
97
|
+
// Calculate width needed to reach viewport edge
|
|
98
|
+
const widthToFillEdge =
|
|
99
|
+
vw + sidebarPx + DEFAULT_IMAGE_CONFIG.SIDEBAR_MARGIN_PX;
|
|
100
|
+
|
|
101
|
+
// Smooth height cap based on aspect ratio
|
|
102
|
+
// At aspect ratio 1.8 or below: use 100% of vh (no cap)
|
|
103
|
+
// At aspect ratio 2.6 or above: use 92% of vh (full cap)
|
|
104
|
+
// In between: smooth linear interpolation
|
|
105
|
+
const minAspect = 1.8;
|
|
106
|
+
const maxAspect = 2.6;
|
|
107
|
+
const minHeightPercent = 1.2;
|
|
108
|
+
const maxHeightPercent = 1.0;
|
|
109
|
+
|
|
110
|
+
const aspectBlend = Math.max(
|
|
111
|
+
0,
|
|
112
|
+
Math.min(1, (viewportAspectRatio - minAspect) / (maxAspect - minAspect))
|
|
113
|
+
);
|
|
114
|
+
const heightPercent =
|
|
115
|
+
maxHeightPercent - aspectBlend * (maxHeightPercent - minHeightPercent);
|
|
116
|
+
|
|
117
|
+
const maxHeight = vh * heightPercent;
|
|
118
|
+
const widthFromHeightCap = maxHeight * aspectRatio;
|
|
119
|
+
|
|
120
|
+
// Image must be at least as wide as content area (no left gap)
|
|
121
|
+
// Then try to fill to edge, but respect height cap and absolute max
|
|
122
|
+
const imageWidth = Math.max(
|
|
123
|
+
contentAreaWidth,
|
|
124
|
+
Math.min(
|
|
125
|
+
widthToFillEdge,
|
|
126
|
+
widthFromHeightCap,
|
|
127
|
+
DEFAULT_IMAGE_CONFIG.MAX_WIDTH_PX
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
const imageHeight = imageWidth / aspectRatio;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
maxWidthPx: imageWidth,
|
|
134
|
+
zone: "desktop",
|
|
135
|
+
viewportAspectRatio,
|
|
136
|
+
viewportWidth: vw,
|
|
137
|
+
viewportHeight: vh,
|
|
138
|
+
imageHeight,
|
|
139
|
+
resizeVersion: dimensions.resizeVersion,
|
|
140
|
+
};
|
|
141
|
+
}, [
|
|
142
|
+
dimensions.width,
|
|
143
|
+
dimensions.height,
|
|
144
|
+
dimensions.aspectRatio,
|
|
145
|
+
dimensions.resizeVersion,
|
|
146
|
+
aspectRatio,
|
|
147
|
+
sidebarWidthPx,
|
|
148
|
+
]);
|
|
149
|
+
}
|