@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,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanvasExportService - Singleton for zero-lag canvas exports
|
|
3
|
+
*
|
|
4
|
+
* This service provides a STABLE callback reference for SnowconeCanvas's onExport prop.
|
|
5
|
+
* By keeping the callback outside React's render cycle, we prevent re-renders from
|
|
6
|
+
* causing canvas drag lag.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - The service holds mutable state (refs to functions and values)
|
|
10
|
+
* - React components configure the service via `configure()` on mount/updates
|
|
11
|
+
* - The `handleExport` method is a stable reference that never changes
|
|
12
|
+
* - During drag, canvas calls `handleExport` which reads current values from instance
|
|
13
|
+
*
|
|
14
|
+
* Memory Management (iOS Safari crash prevention):
|
|
15
|
+
* - Blob URLs are revoked immediately when new ones arrive
|
|
16
|
+
* - ImageBitmap cache is cleared when exiting editor mode
|
|
17
|
+
* - DataURL conversion is lazy (only on getLastExportedArtwork call, not every export)
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```tsx
|
|
21
|
+
* // In your component
|
|
22
|
+
* useEffect(() => {
|
|
23
|
+
* canvasExportService.configure({
|
|
24
|
+
* sendCanvasBlob: productContext.sendCanvasBlob,
|
|
25
|
+
* mockupCount: productContext.product?.mockups?.length ?? 1,
|
|
26
|
+
* isRealtimeEnabled: productContext.realtime?.isEnabled ?? false,
|
|
27
|
+
* onArtworkChange: (src) => setCurrentArtwork({ src, type: 'regular' }),
|
|
28
|
+
* });
|
|
29
|
+
* }, [productContext.sendCanvasBlob, ...]);
|
|
30
|
+
*
|
|
31
|
+
* // Pass stable reference - never causes re-render
|
|
32
|
+
* <SnowconeCanvas onExport={canvasExportService.handleExport} />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface CanvasExportServiceConfig {
|
|
37
|
+
/** Function to send blob to realtime mockup service (PNG mode) */
|
|
38
|
+
sendCanvasBlob?: (placement: string, blob: Blob, expectedMockupCount: number, throttleMs?: number) => void;
|
|
39
|
+
/** Function to send canvas state JSON to realtime mockup service (JSON mode — server-side rendering) */
|
|
40
|
+
sendCanvasState?: (placement: string, state: object, expectedMockupCount: number, throttleMs?: number) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Function to serialize canvas onChange state to server render format.
|
|
43
|
+
* Pass `serializeStateForServer` from @snowcone-app/canvas.
|
|
44
|
+
* Required for JSON mode — if not provided, falls back to PNG mode.
|
|
45
|
+
*/
|
|
46
|
+
serializeStateForServer?: (state: any) => any;
|
|
47
|
+
/** Number of mockups to expect (for throttling) */
|
|
48
|
+
mockupCount?: number;
|
|
49
|
+
/** Whether realtime mockups are enabled */
|
|
50
|
+
isRealtimeEnabled?: boolean;
|
|
51
|
+
/** Current placement name (e.g., "Front") */
|
|
52
|
+
placementName?: string;
|
|
53
|
+
/** Callback when artwork changes (called on exit from editor, not during drag) */
|
|
54
|
+
onArtworkChange?: (src: string) => void;
|
|
55
|
+
/** Enable realtime function (called when entering editor mode) */
|
|
56
|
+
enableRealtime?: () => void;
|
|
57
|
+
/** Disable realtime function (called when exiting editor mode) */
|
|
58
|
+
disableRealtime?: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Function to clear ImageBitmap cache (memory management for iOS Safari)
|
|
61
|
+
* Pass clearImageBitmapCache from @snowcone-app/canvas
|
|
62
|
+
*/
|
|
63
|
+
clearImageBitmapCache?: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Called when handleExport runs but ends up sending nothing (skipBlob=true
|
|
66
|
+
* AND no JSON state was sent in this call). Pairs with the optimistic
|
|
67
|
+
* `notifyPendingExport` from RealtimeProvider so consumers (loading
|
|
68
|
+
* overlays, shimmer effects) can roll back state when an export turns out
|
|
69
|
+
* to be a no-op. Wire to `realtimeContext.notifyPendingExportCancelled`.
|
|
70
|
+
*/
|
|
71
|
+
notifyExportSkipped?: (placement: string) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class CanvasExportService {
|
|
75
|
+
// Configuration - updated by React components
|
|
76
|
+
private sendCanvasBlob: CanvasExportServiceConfig['sendCanvasBlob'] = undefined;
|
|
77
|
+
private sendCanvasState: CanvasExportServiceConfig['sendCanvasState'] = undefined;
|
|
78
|
+
private serializeStateForServer: CanvasExportServiceConfig['serializeStateForServer'] = undefined;
|
|
79
|
+
private mockupCount = 1;
|
|
80
|
+
private isRealtimeEnabled = false;
|
|
81
|
+
private placementName = 'Front';
|
|
82
|
+
private onArtworkChange: CanvasExportServiceConfig['onArtworkChange'] = undefined;
|
|
83
|
+
private enableRealtime: CanvasExportServiceConfig['enableRealtime'] = undefined;
|
|
84
|
+
private disableRealtime: CanvasExportServiceConfig['disableRealtime'] = undefined;
|
|
85
|
+
private clearImageBitmapCache: CanvasExportServiceConfig['clearImageBitmapCache'] = undefined;
|
|
86
|
+
private notifyExportSkipped: CanvasExportServiceConfig['notifyExportSkipped'] = undefined;
|
|
87
|
+
|
|
88
|
+
// Cache latest canvas state for replay when WS connects
|
|
89
|
+
private pendingCanvasState: any = null;
|
|
90
|
+
// Track whether JSON mode has successfully sent at least once
|
|
91
|
+
private jsonModeSentSuccessfully = false;
|
|
92
|
+
|
|
93
|
+
// Debounce state for handleStateChange
|
|
94
|
+
private stateChangeThrottleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
95
|
+
private stateChangeLastSendTime = 0;
|
|
96
|
+
/** How long to wait after last change before sending (quick discrete actions) */
|
|
97
|
+
private static SETTLE_MS = 300;
|
|
98
|
+
/** During continuous activity (drag), force a send at least this often */
|
|
99
|
+
private static MAX_WAIT_MS = 1000;
|
|
100
|
+
|
|
101
|
+
// Internal state for tracking exports
|
|
102
|
+
private lastExportedArtwork: string | null = null;
|
|
103
|
+
private initialArtwork: string | null = null;
|
|
104
|
+
private blobUrl: string | null = null;
|
|
105
|
+
// Store the last blob directly instead of converting to DataURL on every export
|
|
106
|
+
// This reduces memory pressure on iOS Safari - DataURL conversion creates large strings
|
|
107
|
+
private lastExportedBlob: Blob | null = null;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Configure the service with current values from React context.
|
|
111
|
+
* Call this in useEffect when context values change.
|
|
112
|
+
* This does NOT cause re-renders - it just updates internal state.
|
|
113
|
+
*/
|
|
114
|
+
configure(config: CanvasExportServiceConfig): void {
|
|
115
|
+
if (config.sendCanvasBlob !== undefined) {
|
|
116
|
+
this.sendCanvasBlob = config.sendCanvasBlob;
|
|
117
|
+
}
|
|
118
|
+
if (config.sendCanvasState !== undefined) {
|
|
119
|
+
this.sendCanvasState = config.sendCanvasState;
|
|
120
|
+
}
|
|
121
|
+
if (config.serializeStateForServer !== undefined) {
|
|
122
|
+
this.serializeStateForServer = config.serializeStateForServer;
|
|
123
|
+
}
|
|
124
|
+
if (config.mockupCount !== undefined) {
|
|
125
|
+
this.mockupCount = config.mockupCount;
|
|
126
|
+
}
|
|
127
|
+
if (config.isRealtimeEnabled !== undefined) {
|
|
128
|
+
this.isRealtimeEnabled = config.isRealtimeEnabled;
|
|
129
|
+
}
|
|
130
|
+
if (config.placementName !== undefined) {
|
|
131
|
+
this.placementName = config.placementName;
|
|
132
|
+
}
|
|
133
|
+
if (config.onArtworkChange !== undefined) {
|
|
134
|
+
this.onArtworkChange = config.onArtworkChange;
|
|
135
|
+
}
|
|
136
|
+
if (config.enableRealtime !== undefined) {
|
|
137
|
+
this.enableRealtime = config.enableRealtime;
|
|
138
|
+
}
|
|
139
|
+
if (config.disableRealtime !== undefined) {
|
|
140
|
+
this.disableRealtime = config.disableRealtime;
|
|
141
|
+
}
|
|
142
|
+
if (config.clearImageBitmapCache !== undefined) {
|
|
143
|
+
this.clearImageBitmapCache = config.clearImageBitmapCache;
|
|
144
|
+
}
|
|
145
|
+
if (config.notifyExportSkipped !== undefined) {
|
|
146
|
+
this.notifyExportSkipped = config.notifyExportSkipped;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Replay pending canvas state when JSON mode becomes fully available
|
|
150
|
+
if (this.pendingCanvasState && this.isRealtimeEnabled && this.sendCanvasState && this.serializeStateForServer) {
|
|
151
|
+
const pending = this.pendingCanvasState;
|
|
152
|
+
this.pendingCanvasState = null;
|
|
153
|
+
// Defer to avoid calling sendCanvasState during configure
|
|
154
|
+
queueMicrotask(() => this.handleStateChange(pending));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Set the initial artwork when entering editor mode.
|
|
160
|
+
* This is used to detect if artwork changed when exiting.
|
|
161
|
+
*/
|
|
162
|
+
setInitialArtwork(src: string | null): void {
|
|
163
|
+
this.initialArtwork = src;
|
|
164
|
+
this.lastExportedArtwork = src;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the last exported artwork (for saving on exit)
|
|
169
|
+
* Uses lazy DataURL conversion - only converts when called, not on every export
|
|
170
|
+
* Returns a Promise to support async blob-to-DataURL conversion
|
|
171
|
+
*/
|
|
172
|
+
getLastExportedArtwork(): string | null {
|
|
173
|
+
// If we have a cached DataURL string, return it
|
|
174
|
+
if (this.lastExportedArtwork) {
|
|
175
|
+
return this.lastExportedArtwork;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get the last exported artwork as DataURL (async version)
|
|
182
|
+
* Converts from blob on demand to reduce memory pressure during editing
|
|
183
|
+
*/
|
|
184
|
+
async getLastExportedArtworkAsync(): Promise<string | null> {
|
|
185
|
+
// If we have a cached DataURL string, return it
|
|
186
|
+
if (this.lastExportedArtwork) {
|
|
187
|
+
return this.lastExportedArtwork;
|
|
188
|
+
}
|
|
189
|
+
// If we have a stored blob, convert it to DataURL now (lazy conversion)
|
|
190
|
+
if (this.lastExportedBlob) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const reader = new FileReader();
|
|
193
|
+
reader.onloadend = () => {
|
|
194
|
+
this.lastExportedArtwork = reader.result as string;
|
|
195
|
+
// Clear the blob after conversion to free memory
|
|
196
|
+
this.lastExportedBlob = null;
|
|
197
|
+
resolve(this.lastExportedArtwork);
|
|
198
|
+
};
|
|
199
|
+
reader.onerror = () => resolve(null);
|
|
200
|
+
reader.readAsDataURL(this.lastExportedBlob as Blob);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if artwork has changed since entering editor
|
|
208
|
+
*/
|
|
209
|
+
hasArtworkChanged(): boolean {
|
|
210
|
+
// Check if we have any export (either cached DataURL or stored blob)
|
|
211
|
+
const hasExport = this.lastExportedArtwork !== null || this.lastExportedBlob !== null;
|
|
212
|
+
// Consider changed if we have an export and it differs from initial
|
|
213
|
+
// (or if we have a blob, meaning the user made edits)
|
|
214
|
+
return hasExport && (
|
|
215
|
+
this.lastExportedArtwork !== this.initialArtwork ||
|
|
216
|
+
this.lastExportedBlob !== null
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Call when entering editor mode to enable realtime if available
|
|
222
|
+
*/
|
|
223
|
+
onEnterEditorMode(): void {
|
|
224
|
+
if (this.enableRealtime && !this.isRealtimeEnabled) {
|
|
225
|
+
this.enableRealtime();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Call when exiting editor mode to cleanup and disable realtime
|
|
231
|
+
* CRITICAL: Clears ImageBitmap cache to prevent iOS Safari memory crashes
|
|
232
|
+
*/
|
|
233
|
+
onExitEditorMode(): void {
|
|
234
|
+
// Disable realtime mode so hero images stop showing realtime mockups
|
|
235
|
+
if (this.disableRealtime) {
|
|
236
|
+
this.disableRealtime();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Cleanup blob URL
|
|
240
|
+
if (this.blobUrl) {
|
|
241
|
+
URL.revokeObjectURL(this.blobUrl);
|
|
242
|
+
this.blobUrl = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Clear ImageBitmap cache - CRITICAL for iOS Safari memory management
|
|
246
|
+
// Each cached ImageBitmap can consume 16MB+ for large images
|
|
247
|
+
// Without this cleanup, memory accumulates and causes Safari to crash
|
|
248
|
+
if (this.clearImageBitmapCache) {
|
|
249
|
+
this.clearImageBitmapCache();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Reset state
|
|
253
|
+
this.initialArtwork = null;
|
|
254
|
+
this.lastExportedArtwork = null;
|
|
255
|
+
this.lastExportedBlob = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Cleanup realtime resources without disabling realtime mode
|
|
260
|
+
* Use for iOS Safari memory pressure situations where we need to free memory
|
|
261
|
+
* but want to keep realtime mode active
|
|
262
|
+
*/
|
|
263
|
+
cleanupRealtimeResources(): void {
|
|
264
|
+
// Revoke any stored blob URL
|
|
265
|
+
if (this.blobUrl) {
|
|
266
|
+
URL.revokeObjectURL(this.blobUrl);
|
|
267
|
+
this.blobUrl = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Clear stored blob to free memory
|
|
271
|
+
this.lastExportedBlob = null;
|
|
272
|
+
|
|
273
|
+
// Clear DataURL string if it's large
|
|
274
|
+
// Keep initialArtwork for change detection but clear lastExportedArtwork
|
|
275
|
+
// since the blob was already sent to realtime service
|
|
276
|
+
if (this.lastExportedArtwork && this.lastExportedArtwork.length > 100000) {
|
|
277
|
+
this.lastExportedArtwork = null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Clear ImageBitmap cache to release GPU memory
|
|
281
|
+
if (this.clearImageBitmapCache) {
|
|
282
|
+
this.clearImageBitmapCache();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* STABLE export handler - pass this to SnowconeCanvas onExport prop.
|
|
288
|
+
* This method reference NEVER changes, preventing re-renders.
|
|
289
|
+
*
|
|
290
|
+
* CRITICAL: This is an arrow function assigned to an instance property,
|
|
291
|
+
* so `this` is always bound correctly and the reference is stable.
|
|
292
|
+
*
|
|
293
|
+
* Handles both single artboard exports and multi-artboard exports:
|
|
294
|
+
* - Single: { 'Front': blob } - sends as 'Front' placement
|
|
295
|
+
* - Multi: { 'Front': blob1, 'Back': blob2 } - sends each with its artboard name as placement
|
|
296
|
+
*/
|
|
297
|
+
handleExport = async (exports: Record<string, string | Blob>): Promise<void> => {
|
|
298
|
+
const artboardNames = Object.keys(exports);
|
|
299
|
+
if (artboardNames.length === 0) return;
|
|
300
|
+
|
|
301
|
+
// Process the first (or active) artboard for local storage
|
|
302
|
+
const primaryArtboardName = artboardNames[0];
|
|
303
|
+
const primaryExportData = exports[artboardNames[0]];
|
|
304
|
+
|
|
305
|
+
// Store for saving on exit (works with both blob and dataUrl)
|
|
306
|
+
// MEMORY OPTIMIZATION: Store blob directly instead of converting to DataURL on every export
|
|
307
|
+
// DataURL conversion creates large strings (1.33x blob size due to base64 encoding)
|
|
308
|
+
// which causes memory pressure on iOS Safari. Conversion is done lazily in getLastExportedArtworkAsync()
|
|
309
|
+
if (primaryExportData instanceof Blob) {
|
|
310
|
+
// Store the blob directly - conversion to DataURL happens only when needed
|
|
311
|
+
this.lastExportedBlob = primaryExportData;
|
|
312
|
+
// Clear any cached DataURL since we have a newer blob
|
|
313
|
+
this.lastExportedArtwork = null;
|
|
314
|
+
|
|
315
|
+
// Store blob URL for potential future use
|
|
316
|
+
if (this.blobUrl) {
|
|
317
|
+
URL.revokeObjectURL(this.blobUrl);
|
|
318
|
+
}
|
|
319
|
+
this.blobUrl = URL.createObjectURL(primaryExportData);
|
|
320
|
+
} else if (typeof primaryExportData === 'string') {
|
|
321
|
+
// If it's already a DataURL string, store it directly
|
|
322
|
+
this.lastExportedArtwork = primaryExportData;
|
|
323
|
+
this.lastExportedBlob = null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Send blobs to realtime mockup service if enabled
|
|
327
|
+
// When JSON mode is active, handleStateChange sends state on every onChange.
|
|
328
|
+
// Only fall back to blob if JSON mode isn't configured at all.
|
|
329
|
+
const useJsonMode = this.isRealtimeEnabled && this.sendCanvasState && this.serializeStateForServer;
|
|
330
|
+
let jsonSentNow = false;
|
|
331
|
+
if (useJsonMode && this.pendingCanvasState) {
|
|
332
|
+
this.handleStateChange(this.pendingCanvasState);
|
|
333
|
+
jsonSentNow = !this.pendingCanvasState;
|
|
334
|
+
}
|
|
335
|
+
// Skip blob when JSON mode is configured — JSON is the primary realtime path.
|
|
336
|
+
// Blob is only used when JSON mode is not available (e.g., third-party canvas without serializeStateForServer).
|
|
337
|
+
const skipBlob = !!useJsonMode || this.jsonModeSentSuccessfully;
|
|
338
|
+
console.log(`[CES] handleExport: useJsonMode=${!!useJsonMode} pendingState=${!!this.pendingCanvasState} jsonSentNow=${jsonSentNow} jsonEverSent=${this.jsonModeSentSuccessfully} skipBlob=${skipBlob}`);
|
|
339
|
+
|
|
340
|
+
// If we ended up sending nothing — skipBlob is true AND no JSON state went
|
|
341
|
+
// out in this call — notify any optimistic pending-export listeners so
|
|
342
|
+
// they can roll back. Without this, an upstream `notifyPendingExport`
|
|
343
|
+
// would leave loading UI stuck (no `blobSent` / `mockupResults` will fire
|
|
344
|
+
// to clear it because no actual export happened).
|
|
345
|
+
if (skipBlob && !jsonSentNow) {
|
|
346
|
+
const placements = artboardNames.length > 0 ? artboardNames : [this.placementName];
|
|
347
|
+
placements.forEach((p) => {
|
|
348
|
+
try {
|
|
349
|
+
this.notifyExportSkipped?.(p);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.warn(`[CES] notifyExportSkipped("${p}") threw:`, err);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (this.isRealtimeEnabled && this.sendCanvasBlob && !skipBlob) {
|
|
357
|
+
// PNG fallback — used when sendCanvasState is not configured (third-party canvas)
|
|
358
|
+
for (const artboardName of artboardNames) {
|
|
359
|
+
const exportedData = exports[artboardName];
|
|
360
|
+
let blob: Blob;
|
|
361
|
+
|
|
362
|
+
if (exportedData instanceof Blob) {
|
|
363
|
+
blob = exportedData;
|
|
364
|
+
} else if (typeof exportedData === 'string') {
|
|
365
|
+
try {
|
|
366
|
+
const response = await fetch(exportedData);
|
|
367
|
+
blob = await response.blob();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error(`[CanvasExportService] Failed to convert dataURL to blob for artboard '${artboardName}':`, error);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const placement = artboardName;
|
|
377
|
+
this.sendCanvasBlob(placement, blob, this.mockupCount, 500);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* STABLE state change handler - pass this to SnowconeCanvas onChange prop.
|
|
384
|
+
* Sends canvas state JSON to server for server-side rendering (JSON mode).
|
|
385
|
+
*
|
|
386
|
+
* When configured with sendCanvasState + serializeStateForServer, this replaces
|
|
387
|
+
* the blob upload path for realtime previews. The server renders the canvas
|
|
388
|
+
* instead of the client, eliminating the PNG upload bandwidth.
|
|
389
|
+
*
|
|
390
|
+
* handleExport still fires for local blob storage (save/exit, thumbnails).
|
|
391
|
+
*/
|
|
392
|
+
private stateChangeCallCount = 0;
|
|
393
|
+
|
|
394
|
+
handleStateChange = (state: any): void => {
|
|
395
|
+
if (!state) return;
|
|
396
|
+
|
|
397
|
+
const hasElements = state.elements?.length > 0;
|
|
398
|
+
if (!hasElements) return;
|
|
399
|
+
|
|
400
|
+
// Don't send or cache state while any image is still loading —
|
|
401
|
+
// the serializer would skip it and the server would render without artwork
|
|
402
|
+
const hasLoadingImages = state.elements?.some(
|
|
403
|
+
(el: any) => (el.type === 'image' || el.transformType === 'image') && el.imageLoadState === 'loading'
|
|
404
|
+
);
|
|
405
|
+
if (hasLoadingImages) {
|
|
406
|
+
console.log(`[CES] handleStateChange: skipping — images still loading`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.stateChangeCallCount++;
|
|
411
|
+
const callId = this.stateChangeCallCount;
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
console.log(`[CES] handleStateChange #${callId} at ${now} (lastSend=${this.stateChangeLastSendTime}, elapsed=${this.stateChangeLastSendTime > 0 ? now - this.stateChangeLastSendTime : 'never'}ms, pendingTimer=${!!this.stateChangeThrottleTimeout})`);
|
|
414
|
+
|
|
415
|
+
// Cache for replay when WS connects
|
|
416
|
+
this.pendingCanvasState = state;
|
|
417
|
+
|
|
418
|
+
if (!this.isRealtimeEnabled || !this.sendCanvasState || !this.serializeStateForServer) {
|
|
419
|
+
console.log(`[CES] handleStateChange #${callId}: not ready (realtime=${this.isRealtimeEnabled} sendState=${!!this.sendCanvasState} serialize=${!!this.serializeStateForServer}) — state cached`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Debounce with max wait: reset timer on each call so rapid drag frames
|
|
424
|
+
// coalesce, but if we haven't sent in >= throttleMs, flush immediately
|
|
425
|
+
// so long drags still get periodic updates.
|
|
426
|
+
const settleMs = CanvasExportService.SETTLE_MS;
|
|
427
|
+
const maxWaitMs = CanvasExportService.MAX_WAIT_MS;
|
|
428
|
+
const hasSentBefore = this.stateChangeLastSendTime > 0;
|
|
429
|
+
const timeSinceLastSend = hasSentBefore ? now - this.stateChangeLastSendTime : 0;
|
|
430
|
+
|
|
431
|
+
// Max wait: during a continuous drag, force a send if it's been >= maxWaitMs
|
|
432
|
+
// since last send. "Active" means last send was recent (within 3× maxWait).
|
|
433
|
+
const isActiveDrag = hasSentBefore && timeSinceLastSend < maxWaitMs * 3;
|
|
434
|
+
if (isActiveDrag && timeSinceLastSend >= maxWaitMs) {
|
|
435
|
+
console.log(`[CES] handleStateChange #${callId}: MAX-WAIT FLUSH (${timeSinceLastSend}ms >= ${maxWaitMs}ms)`);
|
|
436
|
+
if (this.stateChangeThrottleTimeout) {
|
|
437
|
+
clearTimeout(this.stateChangeThrottleTimeout);
|
|
438
|
+
this.stateChangeThrottleTimeout = null;
|
|
439
|
+
}
|
|
440
|
+
this.sendStateNow(state);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Settle timer: reset on every call, fires settleMs after last change.
|
|
445
|
+
// Handles discrete actions (type a letter, nudge, color pick).
|
|
446
|
+
if (this.stateChangeThrottleTimeout) {
|
|
447
|
+
clearTimeout(this.stateChangeThrottleTimeout);
|
|
448
|
+
}
|
|
449
|
+
console.log(`[CES] handleStateChange #${callId}: SETTLE scheduled ${settleMs}ms`);
|
|
450
|
+
this.stateChangeThrottleTimeout = setTimeout(() => {
|
|
451
|
+
this.stateChangeThrottleTimeout = null;
|
|
452
|
+
console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
|
|
453
|
+
if (this.pendingCanvasState) {
|
|
454
|
+
this.sendStateNow(this.pendingCanvasState);
|
|
455
|
+
}
|
|
456
|
+
}, settleMs);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
private sendStateNow(state: any): void {
|
|
460
|
+
if (!this.sendCanvasState || !this.serializeStateForServer) {
|
|
461
|
+
console.log(`[CES] sendStateNow SKIPPED: sendCanvasState=${!!this.sendCanvasState} serializeStateForServer=${!!this.serializeStateForServer}`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.stateChangeLastSendTime = Date.now();
|
|
466
|
+
|
|
467
|
+
const serverState = this.serializeStateForServer(state);
|
|
468
|
+
const artboards = serverState?.artboards;
|
|
469
|
+
if (!artboards || artboards.length === 0) {
|
|
470
|
+
console.log(`[CES] sendStateNow SKIPPED: no artboards in serialized state`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let sent = false;
|
|
475
|
+
for (const artboard of artboards) {
|
|
476
|
+
const placement = artboard.name || this.placementName;
|
|
477
|
+
const result = this.sendCanvasState(placement, serverState, this.mockupCount, 0);
|
|
478
|
+
console.log(`[CES] sendCanvasState("${placement}") → ${result ? 'OK' : 'FAILED (WS not ready)'}`);
|
|
479
|
+
if (result) {
|
|
480
|
+
sent = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (sent) {
|
|
485
|
+
this.pendingCanvasState = null;
|
|
486
|
+
this.jsonModeSentSuccessfully = true;
|
|
487
|
+
console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
|
|
488
|
+
} else {
|
|
489
|
+
console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) — state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Reset the service state (e.g., when unmounting)
|
|
495
|
+
*/
|
|
496
|
+
reset(): void {
|
|
497
|
+
this.lastExportedArtwork = null;
|
|
498
|
+
this.lastExportedBlob = null;
|
|
499
|
+
this.initialArtwork = null;
|
|
500
|
+
if (this.blobUrl) {
|
|
501
|
+
URL.revokeObjectURL(this.blobUrl);
|
|
502
|
+
this.blobUrl = null;
|
|
503
|
+
}
|
|
504
|
+
// Clear pending state change throttle
|
|
505
|
+
if (this.stateChangeThrottleTimeout) {
|
|
506
|
+
clearTimeout(this.stateChangeThrottleTimeout);
|
|
507
|
+
this.stateChangeThrottleTimeout = null;
|
|
508
|
+
}
|
|
509
|
+
this.stateChangeLastSendTime = 0;
|
|
510
|
+
// Also clear ImageBitmap cache on full reset
|
|
511
|
+
if (this.clearImageBitmapCache) {
|
|
512
|
+
this.clearImageBitmapCache();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Singleton instance - use this throughout the app
|
|
518
|
+
export const canvasExportService = new CanvasExportService();
|