@snowcone-app/ui 0.1.43 → 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 +26 -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,230 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef, type ComponentType } from "react";
|
|
4
|
+
import { X as LucideX, Plus as LucidePlus, Minus as LucideMinus } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
// Cast to fix React 19 type compatibility with lucide-react
|
|
7
|
+
type IconProps = { className?: string; size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" | "false" };
|
|
8
|
+
const XIcon = LucideX as ComponentType<IconProps>;
|
|
9
|
+
const PlusIcon = LucidePlus as ComponentType<IconProps>;
|
|
10
|
+
const MinusIcon = LucideMinus as ComponentType<IconProps>;
|
|
11
|
+
|
|
12
|
+
export interface LightboxProps {
|
|
13
|
+
imageUrl: string;
|
|
14
|
+
alt?: string;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Lightbox({
|
|
20
|
+
imageUrl,
|
|
21
|
+
alt = "Lightbox image",
|
|
22
|
+
onClose,
|
|
23
|
+
className,
|
|
24
|
+
}: LightboxProps) {
|
|
25
|
+
const [isZoomed, setIsZoomed] = useState(false);
|
|
26
|
+
const [origin, setOrigin] = useState({ x: 50, y: 50 });
|
|
27
|
+
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
|
28
|
+
const [showCursor, setShowCursor] = useState(false);
|
|
29
|
+
const [isHoveringImage, setIsHoveringImage] = useState(false);
|
|
30
|
+
const [announcement, setAnnouncement] = useState("");
|
|
31
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
32
|
+
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
33
|
+
const previousActiveElementRef = useRef<HTMLElement | null>(null);
|
|
34
|
+
|
|
35
|
+
// Focus management - trap focus and restore on close
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// Save the previously focused element
|
|
38
|
+
previousActiveElementRef.current = document.activeElement as HTMLElement;
|
|
39
|
+
|
|
40
|
+
// Focus close button on mount
|
|
41
|
+
closeButtonRef.current?.focus();
|
|
42
|
+
setAnnouncement("Image opened in lightbox. Press plus to zoom in, minus to zoom out, Escape to close.");
|
|
43
|
+
|
|
44
|
+
// Restore focus on unmount
|
|
45
|
+
return () => {
|
|
46
|
+
if (previousActiveElementRef.current && typeof previousActiveElementRef.current.focus === 'function') {
|
|
47
|
+
previousActiveElementRef.current.focus();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
54
|
+
if (e.key === "Escape") {
|
|
55
|
+
if (isZoomed) {
|
|
56
|
+
setIsZoomed(false);
|
|
57
|
+
setOrigin({ x: 50, y: 50 });
|
|
58
|
+
setAnnouncement("Zoomed out");
|
|
59
|
+
} else {
|
|
60
|
+
onClose();
|
|
61
|
+
}
|
|
62
|
+
} else if (e.key === "+" || e.key === "=") {
|
|
63
|
+
// Zoom in with + or =
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
setIsZoomed(true);
|
|
66
|
+
setAnnouncement("Zoomed in to 200%");
|
|
67
|
+
} else if (e.key === "-" || e.key === "_") {
|
|
68
|
+
// Zoom out with -
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setIsZoomed(false);
|
|
71
|
+
setOrigin({ x: 50, y: 50 });
|
|
72
|
+
setAnnouncement("Zoomed out to 100%");
|
|
73
|
+
} else if (e.key === "Tab") {
|
|
74
|
+
// Trap focus - try to focus close button, fallback to document body if unavailable
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
if (closeButtonRef.current) {
|
|
77
|
+
closeButtonRef.current.focus();
|
|
78
|
+
} else {
|
|
79
|
+
// Fallback: allow focus to escape to body to prevent focus trap deadlock
|
|
80
|
+
(document.activeElement as HTMLElement)?.blur();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
document.addEventListener("keydown", handleEscape);
|
|
86
|
+
document.body.style.overflow = "hidden";
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
document.removeEventListener("keydown", handleEscape);
|
|
90
|
+
document.body.style.overflow = "";
|
|
91
|
+
};
|
|
92
|
+
}, [isZoomed, onClose]);
|
|
93
|
+
|
|
94
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
97
|
+
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
98
|
+
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
99
|
+
|
|
100
|
+
if (!isZoomed) {
|
|
101
|
+
setOrigin({ x, y });
|
|
102
|
+
}
|
|
103
|
+
setIsZoomed(!isZoomed);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
107
|
+
// Update cursor position
|
|
108
|
+
setCursorPos({ x: e.clientX, y: e.clientY });
|
|
109
|
+
|
|
110
|
+
if (!isZoomed) return;
|
|
111
|
+
|
|
112
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
113
|
+
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
114
|
+
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
115
|
+
setOrigin({ x, y });
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleMouseEnter = () => setShowCursor(true);
|
|
119
|
+
const handleMouseLeave = () => setShowCursor(false);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className={`fixed inset-0 z-50 bg-background flex items-center justify-center ${
|
|
124
|
+
className || ""
|
|
125
|
+
}`}
|
|
126
|
+
onClick={onClose}
|
|
127
|
+
role="dialog"
|
|
128
|
+
aria-modal="true"
|
|
129
|
+
aria-label="Image lightbox"
|
|
130
|
+
>
|
|
131
|
+
{/* Screen reader announcements */}
|
|
132
|
+
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
|
133
|
+
{announcement}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Instructions for screen readers */}
|
|
137
|
+
<div className="sr-only">
|
|
138
|
+
Use plus and minus keys to zoom. Press Escape to close.
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Close button with white circle and border */}
|
|
142
|
+
<button
|
|
143
|
+
ref={closeButtonRef}
|
|
144
|
+
onClick={onClose}
|
|
145
|
+
className="absolute top-6 right-6 w-12 h-12 bg-background border border-border rounded-avatar flex items-center justify-center z-10 hover:bg-muted cursor-pointer"
|
|
146
|
+
aria-label="Close lightbox (Escape)"
|
|
147
|
+
>
|
|
148
|
+
<XIcon
|
|
149
|
+
size={16}
|
|
150
|
+
className="text-foreground"
|
|
151
|
+
strokeWidth={2}
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
/>
|
|
154
|
+
</button>
|
|
155
|
+
|
|
156
|
+
{/* Image container - maximized for full viewport */}
|
|
157
|
+
<div
|
|
158
|
+
className="relative w-full h-full overflow-hidden flex items-center justify-center p-4 select-none"
|
|
159
|
+
onClick={(e) => {
|
|
160
|
+
// Close if clicking outside the image
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
onClose();
|
|
163
|
+
}}
|
|
164
|
+
onMouseMove={handleMouseMove}
|
|
165
|
+
onMouseEnter={handleMouseEnter}
|
|
166
|
+
onMouseLeave={handleMouseLeave}
|
|
167
|
+
style={{
|
|
168
|
+
cursor: isHoveringImage ? (isZoomed ? "zoom-out" : "zoom-in") : "default",
|
|
169
|
+
userSelect: "none",
|
|
170
|
+
WebkitUserSelect: "none",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<img
|
|
174
|
+
ref={imageRef}
|
|
175
|
+
src={imageUrl}
|
|
176
|
+
alt={alt}
|
|
177
|
+
crossOrigin="anonymous"
|
|
178
|
+
className="max-w-[95vw] max-h-[95vh] w-auto h-auto object-contain transition-transform duration-300"
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
handleClick(e);
|
|
182
|
+
}}
|
|
183
|
+
onMouseEnter={() => setIsHoveringImage(true)}
|
|
184
|
+
onMouseLeave={() => setIsHoveringImage(false)}
|
|
185
|
+
style={{
|
|
186
|
+
transform: isZoomed ? "scale(2)" : "scale(1)",
|
|
187
|
+
transformOrigin: `${origin.x}% ${origin.y}%`,
|
|
188
|
+
userSelect: "none",
|
|
189
|
+
WebkitUserSelect: "none",
|
|
190
|
+
}}
|
|
191
|
+
draggable={false}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Custom cursor that follows mouse */}
|
|
196
|
+
{showCursor && (
|
|
197
|
+
<div
|
|
198
|
+
className="fixed pointer-events-none z-50"
|
|
199
|
+
style={{
|
|
200
|
+
left: cursorPos.x,
|
|
201
|
+
top: cursorPos.y,
|
|
202
|
+
transform: "translate(-50%, -50%)",
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<div className="bg-background rounded-avatar p-2 shadow-lg">
|
|
206
|
+
{!isHoveringImage ? (
|
|
207
|
+
<XIcon
|
|
208
|
+
size={20}
|
|
209
|
+
className="text-foreground"
|
|
210
|
+
strokeWidth={1.5}
|
|
211
|
+
/>
|
|
212
|
+
) : isZoomed ? (
|
|
213
|
+
<MinusIcon
|
|
214
|
+
size={20}
|
|
215
|
+
className="text-foreground"
|
|
216
|
+
strokeWidth={1.5}
|
|
217
|
+
/>
|
|
218
|
+
) : (
|
|
219
|
+
<PlusIcon
|
|
220
|
+
size={20}
|
|
221
|
+
className="text-foreground"
|
|
222
|
+
strokeWidth={1.5}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useProduct } from "../patterns/Product";
|
|
5
|
+
import type { ClipShape } from "../patterns/Product";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PlacementClipShapeSelector - Select menu for choosing artboard clip shapes per placement
|
|
9
|
+
*
|
|
10
|
+
* Allows users to choose between rectangle, circle, and custom (heart) shapes for the
|
|
11
|
+
* currently selected placement. Also includes a toggle to control whether the clip shape
|
|
12
|
+
* is included in exported images. Integrates with Product context for state management.
|
|
13
|
+
*
|
|
14
|
+
* Features:
|
|
15
|
+
* - Per-placement clip shape configuration
|
|
16
|
+
* - Toggle to include/exclude clip shape in exports
|
|
17
|
+
* - Syncs with Product context
|
|
18
|
+
* - Works alongside placement selection
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <Product productId="cap-123">
|
|
23
|
+
* <PlacementSelector />
|
|
24
|
+
* <PlacementClipShapeSelector />
|
|
25
|
+
* <SnowconeCanvas artboards={[...]} />
|
|
26
|
+
* </Product>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function PlacementClipShapeSelector() {
|
|
30
|
+
const {
|
|
31
|
+
selectedPlacement,
|
|
32
|
+
setPlacementClipShape,
|
|
33
|
+
getPlacementClipShape,
|
|
34
|
+
setClipShapeInExport,
|
|
35
|
+
getClipShapeInExport
|
|
36
|
+
} = useProduct();
|
|
37
|
+
|
|
38
|
+
if (!selectedPlacement) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const currentClipShape = getPlacementClipShape(selectedPlacement);
|
|
43
|
+
const includeInExport = getClipShapeInExport(selectedPlacement);
|
|
44
|
+
|
|
45
|
+
const handleClipShapeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
46
|
+
const newClipShape = e.target.value as ClipShape;
|
|
47
|
+
setPlacementClipShape(selectedPlacement, newClipShape);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleExportToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
setClipShapeInExport(selectedPlacement, e.target.checked);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col gap-3 mb-4 p-3 border border-border rounded-md bg-card">
|
|
56
|
+
{/* Clip Shape Selector */}
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<label htmlFor="clip-shape-selector" className="text-sm font-label text-foreground min-w-[100px]">
|
|
59
|
+
Clip Shape:
|
|
60
|
+
</label>
|
|
61
|
+
<select
|
|
62
|
+
id="clip-shape-selector"
|
|
63
|
+
className="flex-1 px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
64
|
+
value={currentClipShape}
|
|
65
|
+
onChange={handleClipShapeChange}
|
|
66
|
+
>
|
|
67
|
+
<option value="rectangle">Rectangle</option>
|
|
68
|
+
<option value="circle">Circle</option>
|
|
69
|
+
<option value="custom">Heart (Custom SVG)</option>
|
|
70
|
+
</select>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Include in Export Toggle */}
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<label htmlFor="clip-shape-export-toggle" className="flex items-center gap-2 text-sm font-label text-foreground cursor-pointer">
|
|
76
|
+
<input
|
|
77
|
+
id="clip-shape-export-toggle"
|
|
78
|
+
type="checkbox"
|
|
79
|
+
checked={includeInExport}
|
|
80
|
+
onChange={handleExportToggle}
|
|
81
|
+
className="w-4 h-4 rounded border-border bg-background text-primary focus:ring-2 focus:ring-primary cursor-pointer"
|
|
82
|
+
/>
|
|
83
|
+
<span>Include clip shape border in export</span>
|
|
84
|
+
</label>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState, useLayoutEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for a placement option
|
|
7
|
+
*/
|
|
8
|
+
export interface PlacementConfig {
|
|
9
|
+
/** Display label for the placement */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Type of placement background */
|
|
12
|
+
type: "image" | "color";
|
|
13
|
+
/** Width of the placement area */
|
|
14
|
+
width: number;
|
|
15
|
+
/** Height of the placement area */
|
|
16
|
+
height: number;
|
|
17
|
+
/** Default scale mode for artwork: "fill" crops to fill, "fit" shows full artwork. Default: "fill" */
|
|
18
|
+
defaultScaleMode?: "fill" | "fit";
|
|
19
|
+
/** Top margin in pixels for fit mode (reduces available area before scaling) */
|
|
20
|
+
fitMarginTop?: number;
|
|
21
|
+
/** Right margin in pixels for fit mode */
|
|
22
|
+
fitMarginRight?: number;
|
|
23
|
+
/** Bottom margin in pixels for fit mode */
|
|
24
|
+
fitMarginBottom?: number;
|
|
25
|
+
/** Left margin in pixels for fit mode */
|
|
26
|
+
fitMarginLeft?: number;
|
|
27
|
+
/** Alignment for fit mode (9-point grid: 'tl','t','tr','l','c','r','bl','b','br') */
|
|
28
|
+
fitAlign?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PlacementTabsProps {
|
|
32
|
+
/** List of placement options to display */
|
|
33
|
+
placements: PlacementConfig[];
|
|
34
|
+
/** Currently selected placement label */
|
|
35
|
+
selectedPlacement: string;
|
|
36
|
+
/** Callback when placement selection changes */
|
|
37
|
+
onPlacementChange: (placement: string) => void;
|
|
38
|
+
/** When true, don't reserve space for toolbar (it's in the header on desktop) */
|
|
39
|
+
isDesktop?: boolean;
|
|
40
|
+
/** ID for the toolbar placeholder element (default: "toolbar-placeholder") */
|
|
41
|
+
toolbarPlaceholderId?: string;
|
|
42
|
+
/** Size of the toolbar placeholder in pixels (default: 120) */
|
|
43
|
+
toolbarSize?: number;
|
|
44
|
+
/** Additional className for the container */
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* PlacementTabs - Renders placement tabs with optional floating toolbar placeholder.
|
|
50
|
+
*
|
|
51
|
+
* Uses CSS shape-outside for wrap-around behavior on mobile, allowing text
|
|
52
|
+
* to flow around the toolbar placeholder. On desktop, renders simple tabs
|
|
53
|
+
* without the toolbar placeholder (toolbar is typically in header).
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* // Basic usage
|
|
58
|
+
* <PlacementTabs
|
|
59
|
+
* placements={[
|
|
60
|
+
* { label: "Front", type: "image", width: 400, height: 400 },
|
|
61
|
+
* { label: "Back", type: "image", width: 400, height: 400 },
|
|
62
|
+
* ]}
|
|
63
|
+
* selectedPlacement="Front"
|
|
64
|
+
* onPlacementChange={(label) => setSelectedPlacement(label)}
|
|
65
|
+
* />
|
|
66
|
+
*
|
|
67
|
+
* // Desktop mode (toolbar in header)
|
|
68
|
+
* <PlacementTabs
|
|
69
|
+
* placements={placements}
|
|
70
|
+
* selectedPlacement={selected}
|
|
71
|
+
* onPlacementChange={setSelected}
|
|
72
|
+
* isDesktop
|
|
73
|
+
* />
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function PlacementTabs({
|
|
77
|
+
placements,
|
|
78
|
+
selectedPlacement,
|
|
79
|
+
onPlacementChange,
|
|
80
|
+
isDesktop = false,
|
|
81
|
+
toolbarPlaceholderId = "toolbar-placeholder",
|
|
82
|
+
toolbarSize = 120,
|
|
83
|
+
className = "",
|
|
84
|
+
}: PlacementTabsProps) {
|
|
85
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const [lineHeight, setLineHeight] = useState(24); // Default estimate
|
|
87
|
+
|
|
88
|
+
// Measure actual line height from rendered content
|
|
89
|
+
useLayoutEffect(() => {
|
|
90
|
+
if (containerRef.current) {
|
|
91
|
+
const computedStyle = window.getComputedStyle(containerRef.current);
|
|
92
|
+
const measuredLineHeight = parseFloat(computedStyle.lineHeight);
|
|
93
|
+
if (!isNaN(measuredLineHeight) && measuredLineHeight > 0) {
|
|
94
|
+
setLineHeight(measuredLineHeight);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// When only one placement, just render the toolbar placeholder (no tabs needed)
|
|
100
|
+
// On desktop, toolbar is in header so we don't need anything here
|
|
101
|
+
if (placements.length <= 1) {
|
|
102
|
+
if (isDesktop) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return (
|
|
106
|
+
<div className={`flex justify-end w-full ${className}`}>
|
|
107
|
+
<span
|
|
108
|
+
id={toolbarPlaceholderId}
|
|
109
|
+
className="block"
|
|
110
|
+
style={{ width: toolbarSize, height: toolbarSize }}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Desktop: just render placement tabs without toolbar placeholder
|
|
117
|
+
if (isDesktop) {
|
|
118
|
+
return (
|
|
119
|
+
<div className={`flex flex-wrap gap-x-3 text-sm leading-relaxed w-full ${className}`}>
|
|
120
|
+
{placements.map((p) => (
|
|
121
|
+
<button
|
|
122
|
+
key={p.label}
|
|
123
|
+
onClick={() => onPlacementChange(p.label)}
|
|
124
|
+
className={`transition-all duration-200 cursor-pointer border-b-2 ${
|
|
125
|
+
selectedPlacement === p.label
|
|
126
|
+
? "text-foreground border-primary"
|
|
127
|
+
: "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
{p.label}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Mobile: render with floating toolbar placeholder
|
|
138
|
+
// Calculate shape-outside to match toolbar height in terms of line heights
|
|
139
|
+
// This ensures the toolbar occupies space equivalent to whole line(s)
|
|
140
|
+
const linesForToolbar = Math.ceil(toolbarSize / lineHeight);
|
|
141
|
+
const shapeInsetPx = linesForToolbar * lineHeight;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
ref={containerRef}
|
|
146
|
+
className={`flex overflow-hidden text-sm leading-relaxed w-full ${className}`}
|
|
147
|
+
>
|
|
148
|
+
<p className="m-0 w-full">
|
|
149
|
+
<span
|
|
150
|
+
className="float-right h-full ml-2 flex items-end"
|
|
151
|
+
style={{
|
|
152
|
+
shapeOutside: `inset(calc(100% - ${shapeInsetPx}px) 0 0)`,
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<span
|
|
156
|
+
id={toolbarPlaceholderId}
|
|
157
|
+
className="block"
|
|
158
|
+
style={{ width: toolbarSize, height: toolbarSize }}
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
|
|
162
|
+
{/* Placement tabs */}
|
|
163
|
+
{placements.map((p) => (
|
|
164
|
+
<button
|
|
165
|
+
key={p.label}
|
|
166
|
+
onClick={() => onPlacementChange(p.label)}
|
|
167
|
+
className={`inline transition-all duration-200 cursor-pointer mr-3 border-b-2 ${
|
|
168
|
+
selectedPlacement === p.label
|
|
169
|
+
? "text-foreground border-primary"
|
|
170
|
+
: "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
|
|
171
|
+
}`}
|
|
172
|
+
>
|
|
173
|
+
{p.label}
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|