gclass-anims 1.0.0-beta.2 → 1.0.0-beta.21

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/AnimToggle.js CHANGED
@@ -1,10 +1,12 @@
1
- import initListeners from './Listeners'
1
+ import initListeners from './Listeners.js'
2
+ import { defaults, animations } from './Config.js'
3
+ import gsap from 'gsap'
2
4
 
3
5
  // localStorage key controlling whether the GSAP animation system is mounted.
4
- const STORAGE_KEY = 'funbyte-animations-enabled'
6
+ const STORAGE_KEY = 'gclass-animations-enabled'
5
7
  // localStorage key for a forced reduced-motion override (see
6
8
  // enableReducedMotion / disableReducedMotion).
7
- const REDUCED_KEY = 'funbyte-reduced-motion'
9
+ const REDUCED_KEY = 'gclass-reduced-motion'
8
10
 
9
11
  const reducedMotionQuery = typeof window !== 'undefined'
10
12
  ? window.matchMedia('(prefers-reduced-motion: reduce)')
@@ -44,7 +46,7 @@ function readReducedOverride() {
44
46
  // - Otherwise, if the user HAS an explicit stored choice, respect it
45
47
  // (override wins), even under reduced motion.
46
48
  // - Otherwise (no stored value) fall back to the default, which is ON unless
47
- // reduced motion is detected in which case animations are off.
49
+ // reduced motion is detected - in which case animations are off.
48
50
  function getEnabled() {
49
51
  if (forcedReduced) return false
50
52
  return stored === null ? !reduced : stored
@@ -111,12 +113,202 @@ export function disableReducedMotion() {
111
113
  }
112
114
 
113
115
  let cleanup = null
116
+ let bootCleanup = null
117
+ let bootTimeout = null
118
+ let bootStyle = null
119
+ let hasBooted = false // true after first hard-load boot, skips boot on SPA path changes (remains false until first boot, resets on hard reload)
120
+
121
+ // Runtime config for gclassOpts — 0 = defaults (no throttle, default GSAP ticker)
122
+ let currentThrottle = 0
123
+ let currentFps = 0
124
+ const configSubscribers = new Set()
125
+ function getConfigSnapshot() { return { throttlePerFrame: currentThrottle, fps: currentFps } }
126
+ function emitConfig() { configSubscribers.forEach(fn => fn(getConfigSnapshot())) }
127
+ function applyFps(fps) {
128
+ const v = Number(fps) || 0
129
+ currentFps = v
130
+ if (v > 0) gsap.ticker.fps(v)
131
+ else gsap.ticker.fps(0) // 0 = remove cap, fallback to rAF (GSAP default)
132
+ emitConfig()
133
+ }
134
+ function normalizeGclassArgs(throttlePerFrame, fps) {
135
+ // support gclassOpts({throttlePerFrame, fps}) object overload
136
+ if (typeof throttlePerFrame === 'object' && throttlePerFrame !== null) {
137
+ fps = throttlePerFrame.fps
138
+ throttlePerFrame = throttlePerFrame.throttlePerFrame
139
+ }
140
+ const nextThrottle = throttlePerFrame == null ? 0 : Number(throttlePerFrame) || 0
141
+ const nextFps = fps == null ? 0 : Number(fps) || 0
142
+ return { nextThrottle, nextFps }
143
+ }
144
+
145
+ /**
146
+ * Change GClass runtime options on the fly without reload.
147
+ * gclassOpts(throttlePerFrame, fps) — both optional numbers.
148
+ * gclassOpts() or gclassOpts(undefined, undefined) resets to defaults (no throttle, default ticker).
149
+ * Also accepts gclassOpts({throttlePerFrame, fps}).
150
+ * Example low-end button: onClick={() => gclassOpts(1, 30)}
151
+ * Example reset: onClick={() => gclassOpts()} // 0, 60fps rAF
152
+ */
153
+ export function gclassOpts(throttlePerFrame, fps) {
154
+ const { nextThrottle, nextFps } = normalizeGclassArgs(throttlePerFrame, fps)
155
+ currentThrottle = nextThrottle
156
+ applyFps(nextFps)
157
+ // re-wire observers with new throttle without full boot reload (if already running)
158
+ if (typeof window !== 'undefined' && cleanup && !bootTimeout) {
159
+ try { cleanup() } catch {}
160
+ cleanup = null
161
+ if (getEnabled()) cleanup = initListeners(document, currentThrottle)
162
+ }
163
+ // if boot is in progress we just store for next initAnimations; if not running, next initAnimations will use stored values
164
+ return getConfigSnapshot()
165
+ }
166
+ export function getGClassConfig() { return getConfigSnapshot() }
167
+ export function subscribeGClassConfig(cb) {
168
+ configSubscribers.add(cb)
169
+ return () => configSubscribers.delete(cb)
170
+ }
171
+
172
+ const readBootTime = (els, fallback) => {
173
+ let max = null
174
+ for (const el of els) {
175
+ const cls = [...el.classList].find(c => c.startsWith('boot-time-'))
176
+ if (cls) {
177
+ const n = Number(cls.slice('boot-time-'.length))
178
+ if (!Number.isNaN(n)) max = max === null ? n : Math.max(max, n)
179
+ }
180
+ }
181
+ return max ?? fallback
182
+ }
114
183
 
115
184
  // Boots the GSAP animation system unless animations are disabled (stored "off"
116
185
  // or reduced-motion fallback with no explicit choice). Idempotent: calling it
117
186
  // again tears down any previous run first.
118
- export function initAnimations() {
187
+ // Now also handles boot screen: any HTML/JSX with `.boot-up` anywhere is treated as the boot overlay.
188
+ // No separate initBoot needed - just call initAnimations().
189
+ // Boot stops all DOM rendering for defaults.bootTime (overwritten by boot-time-N class).
190
+ // throttlePerFrame / fps: forwarded to gclassOpts-equivalent runtime.
191
+ // initAnimations(throttlePerFrame, fps) — positional numbers, 0/undefined = defaults
192
+ // initAnimations({throttlePerFrame, fps}) — object overload
193
+ // initAnimations() — uses last gclassOpts values (defaults on first call)
194
+ export function initAnimations(throttlePerFrame, fps) {
195
+ // gclassOpts-style normalization: (throttle, fps) positional or {throttlePerFrame, fps} object
196
+ // no args -> fallback to last gclassOpts values (defaults 0 = no throttle, default ticker)
197
+ let effThrottle = currentThrottle
198
+ let effFps = currentFps
199
+ if (throttlePerFrame !== undefined || fps !== undefined) {
200
+ const { nextThrottle, nextFps } = normalizeGclassArgs(throttlePerFrame, fps)
201
+ effThrottle = nextThrottle
202
+ effFps = nextFps
203
+ currentThrottle = effThrottle
204
+ currentFps = effFps
205
+ if (effFps > 0) gsap.ticker.fps(effFps)
206
+ else gsap.ticker.fps(0)
207
+ emitConfig()
208
+ } else {
209
+ if (effFps > 0) gsap.ticker.fps(effFps)
210
+ else gsap.ticker.fps(0)
211
+ }
119
212
  if (typeof window === 'undefined' || !getEnabled()) return
120
- if (cleanup) cleanup()
121
- cleanup = initListeners()
213
+ // boot already in progress (first mount in StrictMode) - ignore second mount
214
+ if (bootTimeout) {
215
+ console.log(`[initAnimations] boot already in progress - ignoring duplicate call`)
216
+ return
217
+ }
218
+ if (cleanup) { cleanup(); cleanup = null }
219
+ if (bootCleanup) { bootCleanup(); bootCleanup = null }
220
+ if (bootStyle) { bootStyle.remove(); bootStyle = null; document.documentElement.classList.remove('gclass-booting') }
221
+
222
+ const hideBootEls = (els) => {
223
+ // React-safe: don't el.remove() - React owns the nodes and will throw
224
+ // insertBefore/removeChild on next commit if we mutate outside React.
225
+ // Hiding keeps React's tree intact but visually removes boot screen.
226
+ els.forEach(el => {
227
+ el.style.display = 'none'
228
+ el.setAttribute('hidden', '')
229
+ el.setAttribute('data-gclass-boot-hidden', '1')
230
+ })
231
+ }
232
+
233
+ const bootEls = typeof document !== 'undefined' ? [...document.querySelectorAll(".boot-up")].filter(el => !el.hasAttribute('data-gclass-boot-hidden')) : []
234
+ if (!bootEls.length) {
235
+ // no .boot-up -> completely skip boot
236
+ } else if (bootEls.length > 1) {
237
+ console.error(`[initAnimations] Multiple .boot-up elements detected (${bootEls.length}) - skipping all boot animations`, bootEls)
238
+ hideBootEls(bootEls)
239
+ hasBooted = true
240
+ // fall through to normal initListeners without pausing DOM
241
+ } else if (hasBooted && !bootTimeout) {
242
+ // path change after already booted (SPA navigation) - skip boot, hard reload resets hasBooted
243
+ console.log(`[initAnimations] skipping boot on path change (already booted)`, bootEls)
244
+ hideBootEls(bootEls)
245
+ hasBooted = true
246
+ // fall through
247
+ } else {
248
+ const bootEl = bootEls[0]
249
+ const hasBootEnd = [...bootEl.classList].some(c => c.startsWith('boot-end-'))
250
+ if (!hasBootEnd) {
251
+ // no boot-end-* -> completely skip boot animation (still pause? spec says skip it)
252
+ // spec: skip boot-end animation if no class, but still do boot pause? user said "completely skip it if no .boot-end-<name> class is present"
253
+ // interpret as skip the exit animation only, still pause for bootTime
254
+ // To match "completely skip it" for boot-end, we just don't play exit tween
255
+ }
256
+ const bootTime = readBootTime(bootEls, defaults.bootTime ?? 2)
257
+ console.log(`[initAnimations] .boot-up found: ${bootEls.length} - pausing DOM for ${bootTime}s`, bootEls)
258
+ hasBooted = true
259
+ // stop all DOM rendering except .boot-up
260
+ bootStyle = document.createElement('style')
261
+ bootStyle.id = 'gclass-boot-style'
262
+ bootStyle.textContent = `html.gclass-booting{visibility:hidden} html.gclass-booting .boot-up,html.gclass-booting .boot-up *{visibility:visible} html.gclass-booting .boot-up{position:fixed;inset:0;z-index:9999;display:grid;place-items:center}`
263
+ document.head.appendChild(bootStyle)
264
+ document.documentElement.classList.add('gclass-booting')
265
+ // ensure boot els are visible even if nested inside hidden ancestors
266
+ bootEls.forEach(el => { el.style.visibility = 'visible' })
267
+
268
+ // animations inside boot screen must play while rest of DOM is hidden - init scoped to boot-up
269
+ bootCleanup = initListeners(bootEl, effThrottle)
270
+
271
+ bootTimeout = setTimeout(() => {
272
+ const bootEndCls = [...bootEl.classList].find(c => c.startsWith('boot-end-'))
273
+ if (!bootEndCls) {
274
+ // no boot-end -> skip exit animation, just hide
275
+ bootCleanup?.(); bootCleanup = null
276
+ document.documentElement.classList.remove('gclass-booting')
277
+ bootStyle?.remove(); bootStyle = null
278
+ hideBootEls(bootEls)
279
+ bootTimeout = null
280
+ cleanup = initListeners(document, effThrottle)
281
+ return
282
+ }
283
+ const name = bootEndCls.slice('boot-end-'.length) // e.g. spawn-blur
284
+ const cfg = animations.find(a => a.sel === '.' + name)
285
+ const from = cfg?.from
286
+ const easeCl = [...bootEl.classList].find(c => c.startsWith('ease-'))
287
+ const ease = easeCl ? easeCl.split('-')[1] : defaults.ease
288
+ const dur = readBootTime([bootEl], defaults.effectDuration ?? 1) // reuse boot-time- or fallback to effectDuration; if boot-time used for pause, reuse same value for exit unless overridden
289
+ // Actually use boot-end-time-N if present, else effectDuration
290
+ const endTimeCls = [...bootEl.classList].find(c => c.startsWith('boot-end-time-'))
291
+ const endDur = endTimeCls ? Number(endTimeCls.slice('boot-end-time-'.length)) : dur
292
+
293
+ const finish = () => {
294
+ bootCleanup?.(); bootCleanup = null
295
+ document.documentElement.classList.remove('gclass-booting')
296
+ bootStyle?.remove(); bootStyle = null
297
+ hideBootEls(bootEls)
298
+ bootTimeout = null
299
+ cleanup = initListeners(document, effThrottle)
300
+ }
301
+
302
+ if (!cfg || !from) {
303
+ console.warn(`[initAnimations] boot-end-${name} has no from state - removing without animation`)
304
+ finish()
305
+ return
306
+ }
307
+ // play spawn in reverse (visible -> hidden) before removing
308
+ gsap.to(bootEl, { ...from, duration: endDur, ease, onComplete: finish })
309
+ }, bootTime * 1000)
310
+ return
311
+ }
312
+
313
+ cleanup = initListeners(document, effThrottle)
122
314
  }