@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.
Files changed (192) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,2 @@
1
+ export { Lightbox } from "./Lightbox";
2
+ export type { LightboxProps } from "./Lightbox";
@@ -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
+ }