@snowcone-app/ui 0.2.6 → 0.3.1
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 +23 -0
- package/dist/index.cjs +905 -580
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +882 -552
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/LoadingOverlayPrismCandy.tsx +476 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "React components for merchandise visualization and customization",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"react-instantsearch": "^7.15.5",
|
|
104
104
|
"react-zoom-pan-pinch": "^3.6.4",
|
|
105
105
|
"tailwind-merge": "^3.0.0",
|
|
106
|
-
"@snowcone-app/sdk": "0.16.
|
|
106
|
+
"@snowcone-app/sdk": "0.16.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Candy-variant loading overlay — the snowcone rainbow. Same external API as
|
|
5
|
+
* `LoadingOverlayPrism` (drop-in replacement). Lifted from apps/www (where it
|
|
6
|
+
* was born on the PDP hero) so every surface can use the brand loader; the
|
|
7
|
+
* www module re-exports from here.
|
|
8
|
+
*
|
|
9
|
+
* Two flavours exported:
|
|
10
|
+
* - `LoadingOverlayPrismCandy` (default) — visual content portals to
|
|
11
|
+
* `document.body` with `position: absolute` (no z-index), positioned at
|
|
12
|
+
* document-coordinate top/left matching an in-place anchor element's
|
|
13
|
+
* bounding rect. Used on the PDP hero where the artwork sits in the
|
|
14
|
+
* normal document flow.
|
|
15
|
+
* - `LoadingOverlayPrismCandyInline` — renders the same visual in-place,
|
|
16
|
+
* `position: absolute; inset: 0` filling its parent. Used inside
|
|
17
|
+
* fixed-position modal stacks (e.g. the mobile editor overlay at
|
|
18
|
+
* z-[9999]) where a body-level portal can't paint above the modal.
|
|
19
|
+
*
|
|
20
|
+
* Three stacking-context properties had to be avoided to keep
|
|
21
|
+
* `mix-blend-mode: screen` reaching the artwork below:
|
|
22
|
+
* - `z-index` on a positioned element (creates stacking context + isolated
|
|
23
|
+
* blend group)
|
|
24
|
+
* - `position: fixed` (creates stacking context unconditionally)
|
|
25
|
+
* - `opacity < 1` (creates an isolated blend group)
|
|
26
|
+
*
|
|
27
|
+
* That last one ruled out fading via `opacity` — both for the overlay mount
|
|
28
|
+
* fade AND for each band's per-sweep fade-in / fade-out. Both are driven by
|
|
29
|
+
* registered CSS custom properties (`--candy-mount` for the overlay-wide
|
|
30
|
+
* mount, `--band-alpha` per-band for the sweep) that scale the rgba alphas
|
|
31
|
+
* of the white plate and band gradients. `@property` registration lets the
|
|
32
|
+
* browser interpolate the variables across `transition` / `@keyframes`.
|
|
33
|
+
*
|
|
34
|
+
* (Earlier versions animated band `opacity` 0→1→0 in the sweep keyframes —
|
|
35
|
+
* each band became its own isolated blend group during fade-in/out, and
|
|
36
|
+
* `mix-blend-mode: screen` collapsed to plain alpha-blending in those
|
|
37
|
+
* frames, so the bands read as dark coloured rectangles on bright artwork.
|
|
38
|
+
* Most visible inside fixed/modal stacks like the mobile editor preview.)
|
|
39
|
+
*
|
|
40
|
+
* Sparkle particles still use `opacity` — they have no `mix-blend-mode`,
|
|
41
|
+
* so opacity-induced isolation has no visible effect on them.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
memo,
|
|
46
|
+
useState,
|
|
47
|
+
useEffect,
|
|
48
|
+
useLayoutEffect,
|
|
49
|
+
useRef,
|
|
50
|
+
useCallback,
|
|
51
|
+
} from "react";
|
|
52
|
+
import { createPortal } from "react-dom";
|
|
53
|
+
|
|
54
|
+
const BANDS: ReadonlyArray<{ rgb: string; delay: string }> = [
|
|
55
|
+
{ rgb: "230, 45, 55", delay: "-0.0s" }, // red
|
|
56
|
+
{ rgb: "250, 125, 30", delay: "-0.3s" }, // orange
|
|
57
|
+
{ rgb: "244, 215, 60", delay: "-0.6s" }, // yellow
|
|
58
|
+
{ rgb: "110, 200, 70", delay: "-0.9s" }, // green
|
|
59
|
+
{ rgb: "65, 145, 205", delay: "-1.2s" }, // blue
|
|
60
|
+
{ rgb: "140, 90, 175", delay: "-1.5s" }, // purple
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
type Particle = {
|
|
64
|
+
left: number;
|
|
65
|
+
top: number;
|
|
66
|
+
delay: string;
|
|
67
|
+
duration: string;
|
|
68
|
+
size: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function generateParticles(): Particle[] {
|
|
72
|
+
return Array.from({ length: 10 }, () => ({
|
|
73
|
+
left: 2 + Math.random() * 96,
|
|
74
|
+
top: 2 + Math.random() * 96,
|
|
75
|
+
delay: (Math.random() * 3.5).toFixed(1),
|
|
76
|
+
duration: (3 + Math.random() * 2).toFixed(1),
|
|
77
|
+
size: 3 + Math.random() * 5,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PLATE_ALPHA = 0.1;
|
|
82
|
+
const BAND_ALPHA = 0.55;
|
|
83
|
+
const FADE_IN_MS = 600;
|
|
84
|
+
const FADE_OUT_MS = 400;
|
|
85
|
+
|
|
86
|
+
const STYLES = `
|
|
87
|
+
/* Registered custom properties — required for the browser to interpolate
|
|
88
|
+
them across a CSS transition / animation. Without @property, value
|
|
89
|
+
changes snap rather than animate. */
|
|
90
|
+
@property --candy-mount {
|
|
91
|
+
syntax: "<number>";
|
|
92
|
+
initial-value: 0;
|
|
93
|
+
inherits: true;
|
|
94
|
+
}
|
|
95
|
+
/* Per-band sweep-fade. Animated by the keyframes below to scale the band
|
|
96
|
+
rgba alpha 0 → 1 → 1 → 0 across a sweep cycle. Lives on .candy-band
|
|
97
|
+
only (not inherited), so each band has its own animated value. */
|
|
98
|
+
@property --band-alpha {
|
|
99
|
+
syntax: "<number>";
|
|
100
|
+
initial-value: 0;
|
|
101
|
+
inherits: false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.candy-loading-overlay {
|
|
105
|
+
--candy-mount: 0;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
background-color: rgba(255, 255, 255, calc(${PLATE_ALPHA} * var(--candy-mount)));
|
|
108
|
+
transition: --candy-mount ${FADE_IN_MS}ms ease-in,
|
|
109
|
+
background-color ${FADE_IN_MS}ms ease-in;
|
|
110
|
+
}
|
|
111
|
+
.candy-loading-overlay.candy-fade-in { --candy-mount: 1; }
|
|
112
|
+
.candy-loading-overlay.candy-fade-out {
|
|
113
|
+
--candy-mount: 0;
|
|
114
|
+
transition: --candy-mount ${FADE_OUT_MS}ms ease-out,
|
|
115
|
+
background-color ${FADE_OUT_MS}ms ease-out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.candy-band {
|
|
119
|
+
--band-alpha: 0;
|
|
120
|
+
position: absolute;
|
|
121
|
+
width: 220%; height: 30%;
|
|
122
|
+
left: -60%;
|
|
123
|
+
mix-blend-mode: screen;
|
|
124
|
+
-webkit-mask-image: linear-gradient(180deg, transparent 0%, black 35%, black 65%, transparent 100%);
|
|
125
|
+
mask-image: linear-gradient(180deg, transparent 0%, black 35%, black 65%, transparent 100%);
|
|
126
|
+
animation-timing-function: ease-in-out;
|
|
127
|
+
animation-iteration-count: infinite;
|
|
128
|
+
animation-duration: 6s;
|
|
129
|
+
}
|
|
130
|
+
${BANDS.map((b, i) => {
|
|
131
|
+
const top = -5 + i * 16;
|
|
132
|
+
const direction = i % 2 === 0 ? "ltr" : "rtl";
|
|
133
|
+
// Band rgba alpha scales with both --candy-mount (overlay mount fade)
|
|
134
|
+
// and --band-alpha (per-sweep fade-in/out). Neither uses the `opacity`
|
|
135
|
+
// property, which would form an isolated blend group and break
|
|
136
|
+
// mix-blend-mode: screen — the very thing that lets these bands tint
|
|
137
|
+
// the artwork below. With opacity, screen-blend collapses to plain
|
|
138
|
+
// alpha-blending and the bands read as dark coloured rectangles on
|
|
139
|
+
// bright artwork (most visible inside fixed/modal stacks where
|
|
140
|
+
// an ancestor's own opacity transition is mid-flight).
|
|
141
|
+
return `.candy-band:nth-child(${i + 1}) { top: ${top}%; background: linear-gradient(90deg, transparent 0%, rgba(${b.rgb}, calc(${BAND_ALPHA} * var(--candy-mount) * var(--band-alpha) * var(--band-strength, 1))) 30%, rgba(${b.rgb}, calc(${BAND_ALPHA} * var(--candy-mount) * var(--band-alpha) * var(--band-strength, 1))) 70%, transparent 100%); animation-name: candy-sweep-${direction}; animation-delay: ${b.delay}; }`;
|
|
142
|
+
}).join("\n")}
|
|
143
|
+
@keyframes candy-sweep-ltr {
|
|
144
|
+
0% { transform: translateX(-45%) rotate(-6deg); --band-alpha: 0; }
|
|
145
|
+
25% { --band-alpha: 1; }
|
|
146
|
+
50% { transform: translateX(0%) rotate(-6deg); }
|
|
147
|
+
75% { --band-alpha: 1; }
|
|
148
|
+
100% { transform: translateX(45%) rotate(-6deg); --band-alpha: 0; }
|
|
149
|
+
}
|
|
150
|
+
@keyframes candy-sweep-rtl {
|
|
151
|
+
0% { transform: translateX(45%) rotate(-6deg); --band-alpha: 0; }
|
|
152
|
+
25% { --band-alpha: 1; }
|
|
153
|
+
50% { transform: translateX(0%) rotate(-6deg); }
|
|
154
|
+
75% { --band-alpha: 1; }
|
|
155
|
+
100% { transform: translateX(-45%) rotate(-6deg); --band-alpha: 0; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.candy-particle {
|
|
159
|
+
position: absolute; border-radius: 50%;
|
|
160
|
+
/* Particle background and glow alphas also scale with --candy-mount so
|
|
161
|
+
the sparkles fade with the rest of the overlay. */
|
|
162
|
+
background: rgba(255, 255, 255, calc(1 * var(--candy-mount)));
|
|
163
|
+
box-shadow: 0 0 8px rgba(255, 255, 255, calc(0.85 * var(--candy-mount)));
|
|
164
|
+
opacity: 0;
|
|
165
|
+
animation: candy-sparkle infinite ease-in-out;
|
|
166
|
+
}
|
|
167
|
+
@keyframes candy-sparkle {
|
|
168
|
+
0% { opacity: 0; transform: scale(0); }
|
|
169
|
+
15% { opacity: 0.7; transform: scale(1.4); }
|
|
170
|
+
30% { opacity: 0.4; transform: scale(0.6); }
|
|
171
|
+
55% { opacity: 1; transform: scale(2); }
|
|
172
|
+
75% { opacity: 0.5; transform: scale(0.9); }
|
|
173
|
+
100% { opacity: 0; transform: scale(0); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Light-backdrop variant. The default bands screen-blend — vivid on a dark
|
|
177
|
+
stage, but they wash out to nothing over white (screen over white = white).
|
|
178
|
+
On a light card we multiply-blend instead, so each band tints the backdrop
|
|
179
|
+
toward its hue (jewel/pastel rainbow rather than neon). The sparkle particles
|
|
180
|
+
stay white in both variants — they read as snow, and the white glow keeps
|
|
181
|
+
them legible over the multiply rainbow bands. */
|
|
182
|
+
.candy-on-light .candy-band { mix-blend-mode: multiply; }
|
|
183
|
+
/* Opaque light stage baked into the overlay so it fades in/out as one unit
|
|
184
|
+
with the bands — matching the PDP hero's smooth crossfade — instead of a
|
|
185
|
+
hard backdrop on the parent that snaps on/off. The alpha scales with
|
|
186
|
+
--candy-mount (overriding the default 0.1 white plate), so it rides the
|
|
187
|
+
same candy-fade-in / candy-fade-out transition the bands do. The bands
|
|
188
|
+
multiply-blend against this opaque stage to read as the candy rainbow.
|
|
189
|
+
#f5f5f5 = the site page background (defaults.css --color-background), kept
|
|
190
|
+
as a fixed hex (not the token, which would flip dark in dark mode and break
|
|
191
|
+
the multiply blend) so the loader sits flush with the surrounding page. */
|
|
192
|
+
.candy-on-light {
|
|
193
|
+
background-color: rgba(245, 245, 245, calc(1 * var(--candy-mount)));
|
|
194
|
+
/* Half the band opacity on the light stage — multiply-blended bands read
|
|
195
|
+
stronger on light gray than the screen-blended ones do on the dark PDP
|
|
196
|
+
stage, so dial them back to ~50%. Inherits down to .candy-band; the dark
|
|
197
|
+
default leaves --band-strength at its 1 fallback, untouched. */
|
|
198
|
+
--band-strength: 0.5;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
let stylesInjected = false;
|
|
204
|
+
function ensureStyles() {
|
|
205
|
+
if (stylesInjected || typeof document === "undefined") return;
|
|
206
|
+
|
|
207
|
+
// Belt-and-suspenders: also register via the JS API. iOS Safari has a
|
|
208
|
+
// long-standing quirk where `@property` declared inside a dynamically
|
|
209
|
+
// inserted `<style>` (the path below) silently fails to register, leaving
|
|
210
|
+
// the custom properties unregistered → CSS transitions snap rather than
|
|
211
|
+
// interpolate → the rainbow overlay vanishes instantly when the mockup
|
|
212
|
+
// image lands. `CSS.registerProperty` doesn't go through the stylesheet
|
|
213
|
+
// path and isn't affected by that bug. If it succeeds, the `@property`
|
|
214
|
+
// declarations in `STYLES` are redundant; if it fails (already registered
|
|
215
|
+
// by an HMR remount, or older browser), we fall back to those rules.
|
|
216
|
+
if (typeof CSS !== "undefined" && typeof CSS.registerProperty === "function") {
|
|
217
|
+
try {
|
|
218
|
+
CSS.registerProperty({
|
|
219
|
+
name: "--candy-mount",
|
|
220
|
+
syntax: "<number>",
|
|
221
|
+
initialValue: "0",
|
|
222
|
+
inherits: true,
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
// Already registered (HMR) — fine.
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
CSS.registerProperty({
|
|
229
|
+
name: "--band-alpha",
|
|
230
|
+
syntax: "<number>",
|
|
231
|
+
initialValue: "0",
|
|
232
|
+
inherits: false,
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
// Already registered (HMR) — fine.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const s = document.createElement("style");
|
|
240
|
+
s.textContent = STYLES;
|
|
241
|
+
document.head.appendChild(s);
|
|
242
|
+
stylesInjected = true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface LoadingOverlayPrismCandyProps {
|
|
246
|
+
/** When false, the overlay fades out then calls onExited. Default true. */
|
|
247
|
+
visible?: boolean;
|
|
248
|
+
/** Called after the fade-out transition completes. Use this to unmount. */
|
|
249
|
+
onExited?: () => void;
|
|
250
|
+
/**
|
|
251
|
+
* Backdrop the overlay is rendered over. Default `"dark"`: bands
|
|
252
|
+
* screen-blend, vivid neon rainbow on a dark stage (the PDP hero). `"light"`:
|
|
253
|
+
* bands multiply-blend and sparkles recolor dark so the rainbow reads as
|
|
254
|
+
* candy/pastel tones over a white/light-gray backdrop (chat mockup cards).
|
|
255
|
+
* Only the Inline variant honors this today.
|
|
256
|
+
*/
|
|
257
|
+
variant?: "dark" | "light";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface Bounds {
|
|
261
|
+
top: number;
|
|
262
|
+
left: number;
|
|
263
|
+
width: number;
|
|
264
|
+
height: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Plate + bands + particles markup. Rendered by both the portal and inline
|
|
269
|
+
* variants so the visual stays identical. Caller owns positioning of the
|
|
270
|
+
* `.candy-loading-overlay` element via `style`.
|
|
271
|
+
*/
|
|
272
|
+
function CandyVisual({
|
|
273
|
+
fadeClass,
|
|
274
|
+
style,
|
|
275
|
+
onTransitionEnd,
|
|
276
|
+
variantClass = "",
|
|
277
|
+
}: {
|
|
278
|
+
fadeClass: string;
|
|
279
|
+
style: React.CSSProperties;
|
|
280
|
+
onTransitionEnd?: (e: React.TransitionEvent<HTMLDivElement>) => void;
|
|
281
|
+
/** Extra root-level class for variant overrides (e.g. "candy-inline"). */
|
|
282
|
+
variantClass?: string;
|
|
283
|
+
}) {
|
|
284
|
+
// Empty on first render so SSR matches; populate after mount so each
|
|
285
|
+
// show of the loader gets a fresh random field.
|
|
286
|
+
const [particles, setParticles] = useState<Particle[]>([]);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
setParticles(generateParticles());
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div
|
|
293
|
+
className={`candy-loading-overlay ${variantClass} ${fadeClass}`.trim()}
|
|
294
|
+
style={style}
|
|
295
|
+
onTransitionEnd={onTransitionEnd}
|
|
296
|
+
>
|
|
297
|
+
{BANDS.map((_, i) => (
|
|
298
|
+
<div key={`b-${i}`} className="candy-band" />
|
|
299
|
+
))}
|
|
300
|
+
{particles.map((p, i) => (
|
|
301
|
+
<div
|
|
302
|
+
key={`p-${i}`}
|
|
303
|
+
className="candy-particle"
|
|
304
|
+
style={{
|
|
305
|
+
left: `${p.left}%`,
|
|
306
|
+
top: `${p.top}%`,
|
|
307
|
+
animationDelay: `${p.delay}s`,
|
|
308
|
+
animationDuration: `${p.duration}s`,
|
|
309
|
+
width: `${p.size}px`,
|
|
310
|
+
height: `${p.size}px`,
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Wrapped in React.memo so a parent re-render with unchanged `visible` /
|
|
320
|
+
* `onExited` props doesn't trigger reconciliation of all 6 bands + 10
|
|
321
|
+
* particles inside this component. Cuts a meaningful amount of work during
|
|
322
|
+
* realtime mockup updates, where `HeroProductImage`'s internal state
|
|
323
|
+
* thrashes a few times per swap and would otherwise cascade up.
|
|
324
|
+
*/
|
|
325
|
+
export const LoadingOverlayPrismCandy = memo(function LoadingOverlayPrismCandy({
|
|
326
|
+
visible = true,
|
|
327
|
+
onExited,
|
|
328
|
+
}: LoadingOverlayPrismCandyProps) {
|
|
329
|
+
ensureStyles();
|
|
330
|
+
|
|
331
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
332
|
+
const [bounds, setBounds] = useState<Bounds | null>(null);
|
|
333
|
+
// Trigger fade-in on next frame after mount so the CSS transition plays.
|
|
334
|
+
// (If we applied .candy-fade-in on the first render, the browser would
|
|
335
|
+
// never see a transition between two states.)
|
|
336
|
+
const [mounted, setMounted] = useState(false);
|
|
337
|
+
|
|
338
|
+
const measure = useCallback(() => {
|
|
339
|
+
const el = anchorRef.current;
|
|
340
|
+
if (!el) return;
|
|
341
|
+
const r = el.getBoundingClientRect();
|
|
342
|
+
setBounds({
|
|
343
|
+
top: r.top + window.scrollY,
|
|
344
|
+
left: r.left + window.scrollX,
|
|
345
|
+
width: r.width,
|
|
346
|
+
height: r.height,
|
|
347
|
+
});
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
useLayoutEffect(() => {
|
|
351
|
+
measure();
|
|
352
|
+
window.addEventListener("resize", measure);
|
|
353
|
+
|
|
354
|
+
let ro: ResizeObserver | null = null;
|
|
355
|
+
if (typeof ResizeObserver !== "undefined" && anchorRef.current) {
|
|
356
|
+
ro = new ResizeObserver(measure);
|
|
357
|
+
ro.observe(anchorRef.current);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return () => {
|
|
361
|
+
window.removeEventListener("resize", measure);
|
|
362
|
+
ro?.disconnect();
|
|
363
|
+
};
|
|
364
|
+
}, [measure]);
|
|
365
|
+
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
const raf = requestAnimationFrame(() => setMounted(true));
|
|
368
|
+
return () => cancelAnimationFrame(raf);
|
|
369
|
+
}, []);
|
|
370
|
+
|
|
371
|
+
const handleTransitionEnd = useCallback(
|
|
372
|
+
(e: React.TransitionEvent<HTMLDivElement>) => {
|
|
373
|
+
// background-color transitions in lockstep with --candy-mount; fire
|
|
374
|
+
// onExited only when the fade-out finishes.
|
|
375
|
+
if (e.propertyName !== "background-color") return;
|
|
376
|
+
if (!visible) onExited?.();
|
|
377
|
+
},
|
|
378
|
+
[visible, onExited],
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
let fadeClass = "";
|
|
382
|
+
if (!visible) fadeClass = "candy-fade-out";
|
|
383
|
+
else if (mounted) fadeClass = "candy-fade-in";
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<>
|
|
387
|
+
{/* Anchor: invisible, in-place; gets layout from `position: absolute;
|
|
388
|
+
inset: 0` of its parent so its bounding rect drives the portal's
|
|
389
|
+
position. Always rendered, even during fade-out, so we can keep
|
|
390
|
+
re-measuring if the parent resizes mid-fade. */}
|
|
391
|
+
<div
|
|
392
|
+
ref={anchorRef}
|
|
393
|
+
style={{
|
|
394
|
+
position: "absolute",
|
|
395
|
+
inset: 0,
|
|
396
|
+
pointerEvents: "none",
|
|
397
|
+
}}
|
|
398
|
+
aria-hidden="true"
|
|
399
|
+
/>
|
|
400
|
+
{/* Portal: actual visual content at body level. position: absolute (no
|
|
401
|
+
z-index, no fixed) keeps it from forming a stacking context, so the
|
|
402
|
+
bands' mix-blend-mode: screen reaches the artwork through the body's
|
|
403
|
+
stacking context. */}
|
|
404
|
+
{bounds &&
|
|
405
|
+
typeof document !== "undefined" &&
|
|
406
|
+
createPortal(
|
|
407
|
+
<CandyVisual
|
|
408
|
+
fadeClass={fadeClass}
|
|
409
|
+
style={{
|
|
410
|
+
position: "absolute",
|
|
411
|
+
top: bounds.top,
|
|
412
|
+
left: bounds.left,
|
|
413
|
+
width: bounds.width,
|
|
414
|
+
height: bounds.height,
|
|
415
|
+
pointerEvents: "none",
|
|
416
|
+
}}
|
|
417
|
+
onTransitionEnd={handleTransitionEnd}
|
|
418
|
+
/>,
|
|
419
|
+
document.body,
|
|
420
|
+
)}
|
|
421
|
+
</>
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Inline variant — renders the candy visual directly in place, filling its
|
|
427
|
+
* parent (which must be `position: relative` / `absolute` / `fixed`). Use
|
|
428
|
+
* when the overlay needs to paint inside a higher-stacking-context modal
|
|
429
|
+
* (e.g. the mobile editor at z-[9999]) where a body-level portal can't reach
|
|
430
|
+
* above the modal.
|
|
431
|
+
*
|
|
432
|
+
* Bands still screen-blend with the artwork as long as the IMG and the
|
|
433
|
+
* overlay share the same isolated blend group (true even when an ancestor
|
|
434
|
+
* has `opacity < 1` mid-transition — the IMG and bands are inside that
|
|
435
|
+
* group together, so screen-blending between them works).
|
|
436
|
+
*/
|
|
437
|
+
export const LoadingOverlayPrismCandyInline = memo(
|
|
438
|
+
function LoadingOverlayPrismCandyInline({
|
|
439
|
+
visible = true,
|
|
440
|
+
onExited,
|
|
441
|
+
variant = "dark",
|
|
442
|
+
}: LoadingOverlayPrismCandyProps) {
|
|
443
|
+
ensureStyles();
|
|
444
|
+
|
|
445
|
+
const [mounted, setMounted] = useState(false);
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
const raf = requestAnimationFrame(() => setMounted(true));
|
|
448
|
+
return () => cancelAnimationFrame(raf);
|
|
449
|
+
}, []);
|
|
450
|
+
|
|
451
|
+
const handleTransitionEnd = useCallback(
|
|
452
|
+
(e: React.TransitionEvent<HTMLDivElement>) => {
|
|
453
|
+
if (e.propertyName !== "background-color") return;
|
|
454
|
+
if (!visible) onExited?.();
|
|
455
|
+
},
|
|
456
|
+
[visible, onExited],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
let fadeClass = "";
|
|
460
|
+
if (!visible) fadeClass = "candy-fade-out";
|
|
461
|
+
else if (mounted) fadeClass = "candy-fade-in";
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<CandyVisual
|
|
465
|
+
fadeClass={fadeClass}
|
|
466
|
+
variantClass={variant === "light" ? "candy-on-light" : ""}
|
|
467
|
+
style={{
|
|
468
|
+
position: "absolute",
|
|
469
|
+
inset: 0,
|
|
470
|
+
pointerEvents: "none",
|
|
471
|
+
}}
|
|
472
|
+
onTransitionEnd={handleTransitionEnd}
|
|
473
|
+
/>
|
|
474
|
+
);
|
|
475
|
+
},
|
|
476
|
+
);
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,11 @@ export type { CanvasExportServiceConfig } from "./services/CanvasExportService";
|
|
|
126
126
|
export { CanvasIsolationBoundary, CanvasIsolationConfigurator, useIsolatedCanvas } from "./components/CanvasIsolationBoundary";
|
|
127
127
|
export { LoadingOverlayPrism, useLoadingOverlay } from "./components/LoadingOverlayPrism";
|
|
128
128
|
export type { LoadingOverlayPrismProps } from "./components/LoadingOverlayPrism";
|
|
129
|
+
export {
|
|
130
|
+
LoadingOverlayPrismCandy,
|
|
131
|
+
LoadingOverlayPrismCandyInline,
|
|
132
|
+
} from "./components/LoadingOverlayPrismCandy";
|
|
133
|
+
export type { LoadingOverlayPrismCandyProps } from "./components/LoadingOverlayPrismCandy";
|
|
129
134
|
|
|
130
135
|
// Re-export realtime mockup hook from SDK for convenience
|
|
131
136
|
export { useRealtimeMockup } from "@snowcone-app/sdk/react";
|