mirror of
https://github.com/Sped0n/bridget.git
synced 2026-04-14 10:09:31 -07:00
91
assets/ts/configState.tsx
Normal file
91
assets/ts/configState.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX
|
||||
} from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { getThresholdSessionIndex } from './utils'
|
||||
|
||||
export interface ThresholdRelated {
|
||||
threshold: number
|
||||
trailLength: number
|
||||
}
|
||||
|
||||
export interface ConfigState {
|
||||
thresholdIndex: number
|
||||
threshold: number
|
||||
trailLength: number
|
||||
}
|
||||
|
||||
export type ConfigStateContextType = readonly [
|
||||
Accessor<ConfigState>,
|
||||
{
|
||||
readonly incThreshold: () => void
|
||||
readonly decThreshold: () => void
|
||||
}
|
||||
]
|
||||
|
||||
const thresholds: ThresholdRelated[] = [
|
||||
{ threshold: 20, trailLength: 20 },
|
||||
{ threshold: 40, trailLength: 10 },
|
||||
{ threshold: 80, trailLength: 5 },
|
||||
{ threshold: 140, trailLength: 5 },
|
||||
{ threshold: 200, trailLength: 5 }
|
||||
]
|
||||
|
||||
const ConfigStateContext = createContext<ConfigStateContextType>()
|
||||
|
||||
function getSafeThresholdIndex(): number {
|
||||
const index = getThresholdSessionIndex()
|
||||
if (index < 0 || index >= thresholds.length) return 2
|
||||
return index
|
||||
}
|
||||
|
||||
export function ConfigStateProvider(props: { children?: JSX.Element }): JSX.Element {
|
||||
const [thresholdIndex, setThresholdIndex] = createSignal(getSafeThresholdIndex())
|
||||
|
||||
const state = createMemo<ConfigState>(() => {
|
||||
const current = thresholds[thresholdIndex()]
|
||||
|
||||
return {
|
||||
thresholdIndex: thresholdIndex(),
|
||||
threshold: current.threshold,
|
||||
trailLength: current.trailLength
|
||||
}
|
||||
})
|
||||
|
||||
const updateThreshold = (stride: number): void => {
|
||||
const nextIndex = thresholdIndex() + stride
|
||||
if (nextIndex < 0 || nextIndex >= thresholds.length) return
|
||||
sessionStorage.setItem('thresholdsIndex', nextIndex.toString())
|
||||
setThresholdIndex(nextIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigStateContext.Provider
|
||||
value={[
|
||||
state,
|
||||
{
|
||||
incThreshold: () => {
|
||||
updateThreshold(1)
|
||||
},
|
||||
decThreshold: () => {
|
||||
updateThreshold(-1)
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</ConfigStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfigState(): ConfigStateContextType {
|
||||
const context = useContext(ConfigStateContext)
|
||||
invariant(context, 'undefined config context')
|
||||
return context
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export default function CustomCursor(props: {
|
||||
children?: JSX.Element
|
||||
active: Accessor<boolean>
|
||||
cursorText: Accessor<string>
|
||||
isOpen: Accessor<boolean>
|
||||
}): JSX.Element {
|
||||
// types
|
||||
interface XY {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Show, createMemo, createSignal, type JSX } from 'solid-js'
|
||||
import { Show, createMemo, type JSX } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import type { Vector } from '../utils'
|
||||
import { useImageState } from '../imageState'
|
||||
|
||||
import CustomCursor from './customCursor'
|
||||
import Nav from './nav'
|
||||
import Stage from './stage'
|
||||
import StageNav from './stageNav'
|
||||
import { useDesktopState } from './state'
|
||||
|
||||
/**
|
||||
* interfaces and types
|
||||
@@ -23,65 +23,36 @@ export interface DesktopImage extends HTMLImageElement {
|
||||
}
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
i: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* components
|
||||
*/
|
||||
|
||||
export default function Desktop(props: {
|
||||
children?: JSX.Element
|
||||
ijs: ImageJSON[]
|
||||
prevText: string
|
||||
closeText: string
|
||||
nextText: string
|
||||
loadingText: string
|
||||
}): JSX.Element {
|
||||
const [cordHist, setCordHist] = createSignal<HistoryItem[]>([])
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
||||
const [hoverText, setHoverText] = createSignal('')
|
||||
const [navVector, setNavVector] = createSignal<Vector>('none')
|
||||
const imageState = useImageState()
|
||||
const [desktop] = useDesktopState()
|
||||
|
||||
const active = createMemo(() => isOpen() && !isAnimating())
|
||||
const cursorText = createMemo(() => (isLoading() ? props.loadingText : hoverText()))
|
||||
const active = createMemo(() => desktop.isOpen() && !desktop.isAnimating())
|
||||
const cursorText = createMemo(() =>
|
||||
desktop.isLoading() ? props.loadingText : desktop.hoverText()
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<Show when={props.ijs.length > 0}>
|
||||
<Stage
|
||||
ijs={props.ijs}
|
||||
setIsLoading={setIsLoading}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
isAnimating={isAnimating}
|
||||
setIsAnimating={setIsAnimating}
|
||||
cordHist={cordHist}
|
||||
setCordHist={setCordHist}
|
||||
navVector={navVector}
|
||||
setNavVector={setNavVector}
|
||||
/>
|
||||
<Show when={isOpen()}>
|
||||
<CustomCursor cursorText={cursorText} active={active} isOpen={isOpen} />
|
||||
<Show when={imageState().length > 0}>
|
||||
<Stage />
|
||||
<Show when={desktop.isOpen()}>
|
||||
<CustomCursor cursorText={cursorText} active={active} />
|
||||
<StageNav
|
||||
prevText={props.prevText}
|
||||
closeText={props.closeText}
|
||||
nextText={props.nextText}
|
||||
loadingText={props.loadingText}
|
||||
active={active}
|
||||
isAnimating={isAnimating}
|
||||
setCordHist={setCordHist}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
setHoverText={setHoverText}
|
||||
navVector={navVector}
|
||||
setNavVector={setNavVector}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,66 +1,68 @@
|
||||
import { createEffect } from 'solid-js'
|
||||
import { createEffect, onCleanup, onMount } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { useConfigState } from '../configState'
|
||||
import { useImageState } from '../imageState'
|
||||
import { expand } from '../utils'
|
||||
|
||||
/**
|
||||
* constants
|
||||
*/
|
||||
|
||||
// threshold div
|
||||
const thresholdDiv = document.getElementsByClassName('threshold')[0] as HTMLDivElement
|
||||
// threshold nums span
|
||||
const thresholdDispNums = Array.from(
|
||||
thresholdDiv.getElementsByClassName('num')
|
||||
) as HTMLSpanElement[]
|
||||
// threshold buttons
|
||||
const decButton = thresholdDiv
|
||||
.getElementsByClassName('dec')
|
||||
.item(0) as HTMLButtonElement
|
||||
const incButton = thresholdDiv
|
||||
.getElementsByClassName('inc')
|
||||
.item(0) as HTMLButtonElement
|
||||
// index div
|
||||
const indexDiv = document.getElementsByClassName('index').item(0) as HTMLDivElement
|
||||
// index nums span
|
||||
const indexDispNums = Array.from(
|
||||
indexDiv.getElementsByClassName('num')
|
||||
) as HTMLSpanElement[]
|
||||
|
||||
/**
|
||||
* helper functions
|
||||
*/
|
||||
|
||||
function updateThresholdText(thresholdValue: string): void {
|
||||
thresholdDispNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
e.innerText = thresholdValue[i]
|
||||
})
|
||||
}
|
||||
|
||||
function updateIndexText(indexValue: string, indexLength: string): void {
|
||||
indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
if (i < 4) {
|
||||
e.innerText = indexValue[i]
|
||||
} else {
|
||||
e.innerText = indexLength[i - 4]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav component
|
||||
*/
|
||||
import { useDesktopState } from './state'
|
||||
|
||||
export default function Nav(): null {
|
||||
const [state, { incThreshold, decThreshold }] = useState()
|
||||
let thresholdNums: HTMLSpanElement[] = []
|
||||
let indexNums: HTMLSpanElement[] = []
|
||||
let decButton: HTMLButtonElement | undefined
|
||||
let incButton: HTMLButtonElement | undefined
|
||||
let controller: AbortController | undefined
|
||||
|
||||
createEffect(() => {
|
||||
updateIndexText(expand(state().index + 1), expand(state().length))
|
||||
updateThresholdText(expand(state().threshold))
|
||||
const imageState = useImageState()
|
||||
const [config, { incThreshold, decThreshold }] = useConfigState()
|
||||
const [desktop] = useDesktopState()
|
||||
|
||||
const updateThresholdText = (thresholdValue: string): void => {
|
||||
thresholdNums.forEach((element, i) => {
|
||||
element.innerText = thresholdValue[i]
|
||||
})
|
||||
}
|
||||
|
||||
const updateIndexText = (indexValue: string, indexLength: string): void => {
|
||||
indexNums.forEach((element, i) => {
|
||||
if (i < 4) {
|
||||
element.innerText = indexValue[i]
|
||||
} else {
|
||||
element.innerText = indexLength[i - 4]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const thresholdDiv = document.getElementsByClassName(
|
||||
'threshold'
|
||||
)[0] as HTMLDivElement
|
||||
const indexDiv = document.getElementsByClassName('index').item(0) as HTMLDivElement
|
||||
|
||||
thresholdNums = Array.from(
|
||||
thresholdDiv.getElementsByClassName('num')
|
||||
) as HTMLSpanElement[]
|
||||
indexNums = Array.from(indexDiv.getElementsByClassName('num')) as HTMLSpanElement[]
|
||||
decButton = thresholdDiv.getElementsByClassName('dec').item(0) as HTMLButtonElement
|
||||
incButton = thresholdDiv.getElementsByClassName('inc').item(0) as HTMLButtonElement
|
||||
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
|
||||
decButton.addEventListener('click', decThreshold, { signal })
|
||||
incButton.addEventListener('click', incThreshold, { signal })
|
||||
})
|
||||
|
||||
decButton.onclick = decThreshold
|
||||
incButton.onclick = incThreshold
|
||||
createEffect(() => {
|
||||
if (thresholdNums.length === 0 || indexNums.length === 0) return
|
||||
|
||||
updateIndexText(expand(desktop.index() + 1), expand(imageState().length))
|
||||
updateThresholdText(expand(config().threshold))
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,409 +1,167 @@
|
||||
import { type gsap } from 'gsap'
|
||||
import {
|
||||
For,
|
||||
createEffect,
|
||||
on,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
import { For, createEffect, on, onMount, type JSX } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useState, type State } from '../state'
|
||||
import { decrement, increment, loadGsap, type Vector } from '../utils'
|
||||
import { useConfigState } from '../configState'
|
||||
import { useImageState } from '../imageState'
|
||||
import { increment, loadGsap } from '../utils'
|
||||
|
||||
import type { DesktopImage, HistoryItem } from './layout'
|
||||
import type { DesktopImage } from './layout'
|
||||
import { expandStage, minimizeStage, syncStagePosition } from './stageAnimations'
|
||||
import { onMutation } from './stageUtils'
|
||||
import { useDesktopState } from './state'
|
||||
|
||||
/**
|
||||
* helper functions
|
||||
*/
|
||||
|
||||
function getTrailElsIndex(cordHistValue: HistoryItem[]): number[] {
|
||||
return cordHistValue.map((el) => el.i)
|
||||
}
|
||||
|
||||
function getTrailCurrentElsIndex(
|
||||
cordHistValue: HistoryItem[],
|
||||
stateValue: State
|
||||
): number[] {
|
||||
return getTrailElsIndex(cordHistValue).slice(-stateValue.trailLength)
|
||||
}
|
||||
|
||||
function getTrailInactiveElsIndex(
|
||||
cordHistValue: HistoryItem[],
|
||||
stateValue: State
|
||||
): number[] {
|
||||
return getTrailCurrentElsIndex(cordHistValue, stateValue).slice(0, -1)
|
||||
}
|
||||
|
||||
function getCurrentElIndex(cordHistValue: HistoryItem[]): number {
|
||||
return getTrailElsIndex(cordHistValue).slice(-1)[0]
|
||||
}
|
||||
|
||||
function getPrevElIndex(cordHistValue: HistoryItem[], stateValue: State): number {
|
||||
return decrement(cordHistValue.slice(-1)[0].i, stateValue.length)
|
||||
}
|
||||
|
||||
function getNextElIndex(cordHistValue: HistoryItem[], stateValue: State): number {
|
||||
return increment(cordHistValue.slice(-1)[0].i, stateValue.length)
|
||||
}
|
||||
|
||||
function getImagesFromIndexes(imgs: DesktopImage[], indexes: number[]): DesktopImage[] {
|
||||
return indexes.map((i) => imgs[i])
|
||||
}
|
||||
|
||||
function hires(imgs: DesktopImage[]): void {
|
||||
imgs.forEach((img) => {
|
||||
if (img.src === img.dataset.hiUrl) return
|
||||
img.src = img.dataset.hiUrl
|
||||
img.height = parseInt(img.dataset.hiImgH)
|
||||
img.width = parseInt(img.dataset.hiImgW)
|
||||
})
|
||||
}
|
||||
|
||||
function lores(imgs: DesktopImage[]): void {
|
||||
imgs.forEach((img) => {
|
||||
if (img.src === img.dataset.loUrl) return
|
||||
img.src = img.dataset.loUrl
|
||||
img.height = parseInt(img.dataset.loImgH)
|
||||
img.width = parseInt(img.dataset.loImgW)
|
||||
})
|
||||
}
|
||||
|
||||
function onMutation<T extends HTMLElement>(
|
||||
element: T,
|
||||
trigger: (arg0: MutationRecord) => boolean,
|
||||
observeOptions: MutationObserverInit = { attributes: true }
|
||||
): void {
|
||||
new MutationObserver((mutations, observer) => {
|
||||
for (const mutation of mutations) {
|
||||
if (trigger(mutation)) {
|
||||
observer.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}).observe(element, observeOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage component
|
||||
*/
|
||||
|
||||
export default function Stage(props: {
|
||||
ijs: ImageJSON[]
|
||||
setIsLoading: Setter<boolean>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
isAnimating: Accessor<boolean>
|
||||
setIsAnimating: Setter<boolean>
|
||||
cordHist: Accessor<HistoryItem[]>
|
||||
setCordHist: Setter<HistoryItem[]>
|
||||
navVector: Accessor<Vector>
|
||||
setNavVector: Setter<Vector>
|
||||
}): JSX.Element {
|
||||
// variables
|
||||
export default function Stage(): JSX.Element {
|
||||
let _gsap: typeof gsap
|
||||
let gsapPromise: Promise<void> | undefined
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const imgs: DesktopImage[] = Array<DesktopImage>(props.ijs.length)
|
||||
const imageState = useImageState()
|
||||
const [config] = useConfigState()
|
||||
const [
|
||||
desktop,
|
||||
{ setIndex, setCordHist, setIsOpen, setIsAnimating, setIsLoading, setNavVector }
|
||||
] = useDesktopState()
|
||||
|
||||
const imgs: DesktopImage[] = Array<DesktopImage>(imageState().length)
|
||||
let last = { x: 0, y: 0 }
|
||||
|
||||
let abortController: AbortController | undefined
|
||||
|
||||
// states
|
||||
let gsapLoaded = false
|
||||
|
||||
const [state, { incIndex }] = useState()
|
||||
const stateLength = state().length
|
||||
|
||||
let mounted = false
|
||||
|
||||
const onMouse: (e: MouseEvent) => void = (e) => {
|
||||
if (props.isOpen() || props.isAnimating() || !gsapLoaded || !mounted) return
|
||||
const cord = { x: e.clientX, y: e.clientY }
|
||||
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
|
||||
const ensureGsapReady: () => Promise<void> = async () => {
|
||||
if (gsapPromise !== undefined) return await gsapPromise
|
||||
|
||||
if (travelDist > state().threshold) {
|
||||
last = cord
|
||||
incIndex()
|
||||
|
||||
const _state = state()
|
||||
const newHist = { i: _state.index, ...cord }
|
||||
props.setCordHist((prev) => [...prev, newHist].slice(-stateLength))
|
||||
}
|
||||
}
|
||||
|
||||
const onClick: () => void = () => {
|
||||
if (!props.isAnimating()) props.setIsOpen(true)
|
||||
}
|
||||
|
||||
const setPosition: () => void = () => {
|
||||
if (!mounted) return
|
||||
if (imgs.length === 0) return
|
||||
const _cordHist = props.cordHist()
|
||||
const trailElsIndex = getTrailElsIndex(_cordHist)
|
||||
if (trailElsIndex.length === 0) return
|
||||
|
||||
const elsTrail = getImagesFromIndexes(imgs, trailElsIndex)
|
||||
|
||||
const _isOpen = props.isOpen()
|
||||
const _state = state()
|
||||
|
||||
_gsap.set(elsTrail, {
|
||||
x: (i: number) => _cordHist[i].x - window.innerWidth / 2,
|
||||
y: (i: number) => _cordHist[i].y - window.innerHeight / 2,
|
||||
opacity: (i: number) =>
|
||||
Math.max(
|
||||
(i + 1 + _state.trailLength <= _cordHist.length ? 0 : 1) - (_isOpen ? 1 : 0),
|
||||
0
|
||||
),
|
||||
zIndex: (i: number) => i,
|
||||
scale: 0.6
|
||||
})
|
||||
|
||||
if (_isOpen) {
|
||||
const elc = getImagesFromIndexes(imgs, [getCurrentElIndex(_cordHist)])[0]
|
||||
const indexArrayToHires: number[] = []
|
||||
const indexArrayToCleanup: number[] = []
|
||||
switch (props.navVector()) {
|
||||
case 'prev':
|
||||
indexArrayToHires.push(getPrevElIndex(_cordHist, _state))
|
||||
indexArrayToCleanup.push(getNextElIndex(_cordHist, _state))
|
||||
break
|
||||
case 'next':
|
||||
indexArrayToHires.push(getNextElIndex(_cordHist, _state))
|
||||
indexArrayToCleanup.push(getPrevElIndex(_cordHist, _state))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
hires(getImagesFromIndexes(imgs, indexArrayToHires)) // preload
|
||||
_gsap.set(getImagesFromIndexes(imgs, indexArrayToCleanup), { opacity: 0 })
|
||||
_gsap.set(elc, { x: 0, y: 0, scale: 1 }) // set current to center
|
||||
setLoaderForHiresImage(elc) // set loader, if loaded set current opacity to 1
|
||||
} else {
|
||||
lores(elsTrail)
|
||||
}
|
||||
}
|
||||
|
||||
const expandImage: () => Promise<
|
||||
gsap.core.Omit<gsap.core.Timeline, 'then'>
|
||||
> = async () => {
|
||||
// isAnimating is prechecked in isOpen effect
|
||||
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||
|
||||
props.setIsAnimating(true)
|
||||
|
||||
const _cordHist = props.cordHist()
|
||||
const _state = state()
|
||||
|
||||
const elcIndex = getCurrentElIndex(_cordHist)
|
||||
const elc = imgs[elcIndex]
|
||||
|
||||
// don't hide here because we want a better transition
|
||||
hires(
|
||||
getImagesFromIndexes(imgs, [
|
||||
elcIndex,
|
||||
getPrevElIndex(_cordHist, _state),
|
||||
getNextElIndex(_cordHist, _state)
|
||||
])
|
||||
)
|
||||
setLoaderForHiresImage(elc)
|
||||
|
||||
const tl = _gsap.timeline()
|
||||
const trailInactiveEls = getImagesFromIndexes(
|
||||
imgs,
|
||||
getTrailInactiveElsIndex(_cordHist, _state)
|
||||
)
|
||||
// move down and hide trail inactive
|
||||
tl.to(trailInactiveEls, {
|
||||
y: '+=20',
|
||||
ease: 'power3.in',
|
||||
stagger: 0.075,
|
||||
duration: 0.3,
|
||||
delay: 0.1,
|
||||
opacity: 0
|
||||
})
|
||||
// current move to center
|
||||
tl.to(elc, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
ease: 'power3.inOut',
|
||||
duration: 0.7,
|
||||
delay: 0.3
|
||||
})
|
||||
// current expand
|
||||
tl.to(elc, {
|
||||
delay: 0.1,
|
||||
scale: 1,
|
||||
ease: 'power3.inOut'
|
||||
})
|
||||
// finished
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
return await tl.then(() => {
|
||||
props.setIsAnimating(false)
|
||||
})
|
||||
}
|
||||
|
||||
const minimizeImage: () => Promise<
|
||||
gsap.core.Omit<gsap.core.Timeline, 'then'>
|
||||
> = async () => {
|
||||
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||
|
||||
props.setIsAnimating(true)
|
||||
props.setNavVector('none') // cleanup
|
||||
|
||||
const _cordHist = props.cordHist()
|
||||
const _state = state()
|
||||
|
||||
const elcIndex = getCurrentElIndex(_cordHist)
|
||||
const elsTrailInactiveIndexes = getTrailInactiveElsIndex(_cordHist, _state)
|
||||
|
||||
lores(getImagesFromIndexes(imgs, [...elsTrailInactiveIndexes, elcIndex]))
|
||||
|
||||
const tl = _gsap.timeline()
|
||||
const elc = getImagesFromIndexes(imgs, [elcIndex])[0]
|
||||
const elsTrailInactive = getImagesFromIndexes(imgs, elsTrailInactiveIndexes)
|
||||
// shrink current
|
||||
tl.to(elc, {
|
||||
scale: 0.6,
|
||||
duration: 0.6,
|
||||
ease: 'power3.inOut'
|
||||
})
|
||||
// move current to original position
|
||||
tl.to(elc, {
|
||||
delay: 0.3,
|
||||
duration: 0.7,
|
||||
ease: 'power3.inOut',
|
||||
x: _cordHist.slice(-1)[0].x - window.innerWidth / 2,
|
||||
y: _cordHist.slice(-1)[0].y - window.innerHeight / 2
|
||||
})
|
||||
// show trail inactive
|
||||
tl.to(elsTrailInactive, {
|
||||
y: '-=20',
|
||||
ease: 'power3.out',
|
||||
stagger: -0.1,
|
||||
duration: 0.3,
|
||||
opacity: 1
|
||||
})
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
return await tl.then(() => {
|
||||
props.setIsAnimating(false)
|
||||
})
|
||||
}
|
||||
|
||||
function setLoaderForHiresImage(img: DesktopImage): void {
|
||||
if (!mounted || !gsapLoaded) return
|
||||
if (!img.complete) {
|
||||
props.setIsLoading(true)
|
||||
// abort controller for cleanup
|
||||
const controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
// event listeners
|
||||
img.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
_gsap
|
||||
.to(img, { opacity: 1, ease: 'power3.out', duration: 0.5 })
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
.then(() => {
|
||||
props.setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
img.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
_gsap
|
||||
.set(img, { opacity: 1 })
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
.then(() => {
|
||||
props.setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
} else {
|
||||
_gsap
|
||||
.set(img, { opacity: 1 })
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
.then(() => {
|
||||
props.setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// preload logic
|
||||
imgs.forEach((img, i) => {
|
||||
// preload first 5 images on page load
|
||||
if (i < 5) {
|
||||
img.src = img.dataset.loUrl
|
||||
}
|
||||
// lores preloader for rest of the images
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
onMutation(img, (mutation) => {
|
||||
// if open or animating, hold
|
||||
if (props.isOpen() || props.isAnimating()) return false
|
||||
// if mutation is not about style attribute, hold
|
||||
if (mutation.attributeName !== 'style') return false
|
||||
const opacity = parseFloat(img.style.opacity)
|
||||
// if opacity is not 1, hold
|
||||
if (opacity !== 1) return false
|
||||
// preload the i + 5th image, if it exists
|
||||
if (i + 5 < imgs.length) {
|
||||
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
|
||||
}
|
||||
// triggered
|
||||
return true
|
||||
})
|
||||
})
|
||||
// load gsap on mousemove
|
||||
window.addEventListener(
|
||||
'mousemove',
|
||||
() => {
|
||||
loadGsap()
|
||||
gsapPromise = loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
gsapLoaded = true
|
||||
})
|
||||
.catch((e) => {
|
||||
gsapPromise = undefined
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
{ passive: true, once: true }
|
||||
)
|
||||
// event listeners
|
||||
|
||||
await gsapPromise
|
||||
}
|
||||
|
||||
const onMouse: (e: MouseEvent) => void = (e) => {
|
||||
if (desktop.isOpen() || desktop.isAnimating() || !gsapLoaded || !mounted) return
|
||||
|
||||
const length = imageState().length
|
||||
if (length <= 0) return
|
||||
|
||||
const cord = { x: e.clientX, y: e.clientY }
|
||||
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
|
||||
|
||||
if (travelDist > config().threshold) {
|
||||
const nextIndex = increment(desktop.index(), length)
|
||||
|
||||
last = cord
|
||||
setIndex(nextIndex)
|
||||
setCordHist((prev) => [...prev, { i: nextIndex, ...cord }].slice(-length))
|
||||
}
|
||||
}
|
||||
|
||||
const onClick: () => Promise<void> = async () => {
|
||||
if (!gsapLoaded) {
|
||||
await ensureGsapReady()
|
||||
}
|
||||
|
||||
if (desktop.isAnimating() || !gsapLoaded) return
|
||||
if (desktop.index() < 0 || desktop.cordHist().length === 0) return
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const setPosition: () => void = () => {
|
||||
syncStagePosition({
|
||||
gsap: _gsap,
|
||||
imgs,
|
||||
cordHist: desktop.cordHist(),
|
||||
trailLength: config().trailLength,
|
||||
length: imageState().length,
|
||||
isOpen: desktop.isOpen(),
|
||||
navVector: desktop.navVector(),
|
||||
mounted,
|
||||
setIsLoading
|
||||
})
|
||||
}
|
||||
|
||||
const expandImage: () => Promise<void> = async () => {
|
||||
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||
|
||||
await expandStage({
|
||||
gsap: _gsap,
|
||||
imgs,
|
||||
cordHist: desktop.cordHist(),
|
||||
trailLength: config().trailLength,
|
||||
length: imageState().length,
|
||||
mounted,
|
||||
setIsLoading,
|
||||
setIsAnimating
|
||||
})
|
||||
}
|
||||
|
||||
const minimizeImage: () => Promise<void> = async () => {
|
||||
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||
|
||||
setNavVector('none')
|
||||
|
||||
await minimizeStage({
|
||||
gsap: _gsap,
|
||||
imgs,
|
||||
cordHist: desktop.cordHist(),
|
||||
trailLength: config().trailLength,
|
||||
mounted,
|
||||
setIsAnimating
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
imgs.forEach((img, i) => {
|
||||
if (i < 5) {
|
||||
img.src = img.dataset.loUrl
|
||||
}
|
||||
|
||||
onMutation(img, (mutation) => {
|
||||
if (desktop.isOpen() || desktop.isAnimating()) return false
|
||||
if (mutation.attributeName !== 'style') return false
|
||||
|
||||
const opacity = parseFloat(img.style.opacity)
|
||||
if (opacity !== 1) return false
|
||||
|
||||
if (i + 5 < imgs.length) {
|
||||
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('pointermove', () => void ensureGsapReady(), {
|
||||
passive: true,
|
||||
once: true
|
||||
})
|
||||
window.addEventListener('pointerdown', () => void ensureGsapReady(), {
|
||||
passive: true,
|
||||
once: true
|
||||
})
|
||||
window.addEventListener('click', () => void ensureGsapReady(), {
|
||||
passive: true,
|
||||
once: true
|
||||
})
|
||||
|
||||
abortController = new AbortController()
|
||||
const abortSignal = abortController.signal
|
||||
window.addEventListener('mousemove', onMouse, {
|
||||
passive: true,
|
||||
signal: abortSignal
|
||||
})
|
||||
// mounted
|
||||
|
||||
mounted = true
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.cordHist(),
|
||||
() => desktop.cordHist(),
|
||||
() => {
|
||||
setPosition()
|
||||
},
|
||||
@@ -413,36 +171,38 @@ export default function Stage(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isOpen(),
|
||||
async () => {
|
||||
if (props.isAnimating()) return
|
||||
if (props.isOpen()) {
|
||||
// expand image
|
||||
desktop.isOpen,
|
||||
async (isOpen) => {
|
||||
if (desktop.isAnimating()) return
|
||||
|
||||
if (isOpen) {
|
||||
if (desktop.index() < 0 || desktop.cordHist().length === 0) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
await expandImage()
|
||||
.catch(() => {
|
||||
void 0
|
||||
setIsOpen(false)
|
||||
setIsAnimating(false)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.then(() => {
|
||||
// abort controller for cleanup
|
||||
abortController?.abort()
|
||||
})
|
||||
} else {
|
||||
// minimize image
|
||||
await minimizeImage()
|
||||
.catch(() => {
|
||||
void 0
|
||||
})
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
.then(() => {
|
||||
// event listeners and its abort controller
|
||||
abortController = new AbortController()
|
||||
const abortSignal = abortController.signal
|
||||
window.addEventListener('mousemove', onMouse, {
|
||||
passive: true,
|
||||
signal: abortSignal
|
||||
})
|
||||
// cleanup isLoading
|
||||
props.setIsLoading(false)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -453,7 +213,7 @@ export default function Stage(props: {
|
||||
return (
|
||||
<>
|
||||
<div class="stage" onClick={onClick} onKeyDown={onClick}>
|
||||
<For each={props.ijs}>
|
||||
<For each={imageState().images}>
|
||||
{(ij, i) => (
|
||||
<img
|
||||
ref={imgs[i()]}
|
||||
|
||||
263
assets/ts/desktop/stageAnimations.ts
Normal file
263
assets/ts/desktop/stageAnimations.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { type gsap } from 'gsap'
|
||||
|
||||
import type { Vector } from '../utils'
|
||||
|
||||
import type { DesktopImage } from './layout'
|
||||
import {
|
||||
getCurrentElIndex,
|
||||
getImagesFromIndexes,
|
||||
getNextElIndex,
|
||||
getPrevElIndex,
|
||||
getTrailElsIndex,
|
||||
getTrailInactiveElsIndex,
|
||||
hires,
|
||||
lores
|
||||
} from './stageUtils'
|
||||
import type { HistoryItem } from './state'
|
||||
|
||||
type SetLoading = (value: boolean) => void
|
||||
|
||||
export function setLoaderForHiresImage(args: {
|
||||
gsap: typeof gsap
|
||||
img: DesktopImage
|
||||
mounted: boolean
|
||||
setIsLoading: SetLoading
|
||||
}): void {
|
||||
const { gsap, img, mounted, setIsLoading } = args
|
||||
if (!mounted) return
|
||||
|
||||
if (img.complete) {
|
||||
gsap
|
||||
.set(img, { opacity: 1 })
|
||||
.then(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
|
||||
img.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
gsap
|
||||
.to(img, { opacity: 1, ease: 'power3.out', duration: 0.5 })
|
||||
.then(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
img.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
gsap
|
||||
.set(img, { opacity: 1 })
|
||||
.then(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
}
|
||||
|
||||
export function syncStagePosition(args: {
|
||||
gsap: typeof gsap
|
||||
imgs: DesktopImage[]
|
||||
cordHist: HistoryItem[]
|
||||
trailLength: number
|
||||
length: number
|
||||
isOpen: boolean
|
||||
navVector: Vector
|
||||
mounted: boolean
|
||||
setIsLoading: SetLoading
|
||||
}): void {
|
||||
const {
|
||||
gsap,
|
||||
imgs,
|
||||
cordHist,
|
||||
trailLength,
|
||||
length,
|
||||
isOpen,
|
||||
navVector,
|
||||
mounted,
|
||||
setIsLoading
|
||||
} = args
|
||||
|
||||
if (!mounted || imgs.length === 0) return
|
||||
|
||||
const trailElsIndex = getTrailElsIndex(cordHist)
|
||||
if (trailElsIndex.length === 0) return
|
||||
|
||||
const elsTrail = getImagesFromIndexes(imgs, trailElsIndex)
|
||||
|
||||
gsap.set(elsTrail, {
|
||||
x: (i: number) => cordHist[i].x - window.innerWidth / 2,
|
||||
y: (i: number) => cordHist[i].y - window.innerHeight / 2,
|
||||
opacity: (i: number) =>
|
||||
Math.max((i + 1 + trailLength <= cordHist.length ? 0 : 1) - (isOpen ? 1 : 0), 0),
|
||||
zIndex: (i: number) => i,
|
||||
scale: 0.6
|
||||
})
|
||||
|
||||
if (!isOpen) {
|
||||
lores(elsTrail)
|
||||
return
|
||||
}
|
||||
|
||||
const current = getImagesFromIndexes(imgs, [getCurrentElIndex(cordHist)])[0]
|
||||
const indexArrayToHires: number[] = []
|
||||
const indexArrayToCleanup: number[] = []
|
||||
|
||||
switch (navVector) {
|
||||
case 'prev':
|
||||
indexArrayToHires.push(getPrevElIndex(cordHist, length))
|
||||
indexArrayToCleanup.push(getNextElIndex(cordHist, length))
|
||||
break
|
||||
case 'next':
|
||||
indexArrayToHires.push(getNextElIndex(cordHist, length))
|
||||
indexArrayToCleanup.push(getPrevElIndex(cordHist, length))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
hires(getImagesFromIndexes(imgs, indexArrayToHires))
|
||||
gsap.set(getImagesFromIndexes(imgs, indexArrayToCleanup), { opacity: 0 })
|
||||
gsap.set(current, { x: 0, y: 0, scale: 1 })
|
||||
setLoaderForHiresImage({ gsap, img: current, mounted, setIsLoading })
|
||||
}
|
||||
|
||||
export async function expandStage(args: {
|
||||
gsap: typeof gsap
|
||||
imgs: DesktopImage[]
|
||||
cordHist: HistoryItem[]
|
||||
trailLength: number
|
||||
length: number
|
||||
mounted: boolean
|
||||
setIsLoading: SetLoading
|
||||
setIsAnimating: (value: boolean) => void
|
||||
}): Promise<void> {
|
||||
const {
|
||||
gsap,
|
||||
imgs,
|
||||
cordHist,
|
||||
trailLength,
|
||||
length,
|
||||
mounted,
|
||||
setIsLoading,
|
||||
setIsAnimating
|
||||
} = args
|
||||
|
||||
if (!mounted) throw new Error('not mounted')
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
const currentIndex = getCurrentElIndex(cordHist)
|
||||
const current = imgs[currentIndex]
|
||||
|
||||
hires(
|
||||
getImagesFromIndexes(imgs, [
|
||||
currentIndex,
|
||||
getPrevElIndex(cordHist, length),
|
||||
getNextElIndex(cordHist, length)
|
||||
])
|
||||
)
|
||||
setLoaderForHiresImage({ gsap, img: current, mounted, setIsLoading })
|
||||
|
||||
const tl = gsap.timeline()
|
||||
const trailInactiveEls = getImagesFromIndexes(
|
||||
imgs,
|
||||
getTrailInactiveElsIndex(cordHist, trailLength)
|
||||
)
|
||||
|
||||
tl.to(trailInactiveEls, {
|
||||
y: '+=20',
|
||||
ease: 'power3.in',
|
||||
stagger: 0.075,
|
||||
duration: 0.3,
|
||||
delay: 0.1,
|
||||
opacity: 0
|
||||
})
|
||||
tl.to(current, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
ease: 'power3.inOut',
|
||||
duration: 0.7,
|
||||
delay: 0.3
|
||||
})
|
||||
tl.to(current, {
|
||||
delay: 0.1,
|
||||
scale: 1,
|
||||
ease: 'power3.inOut'
|
||||
})
|
||||
|
||||
await tl.then(() => {
|
||||
setIsAnimating(false)
|
||||
})
|
||||
}
|
||||
|
||||
export async function minimizeStage(args: {
|
||||
gsap: typeof gsap
|
||||
imgs: DesktopImage[]
|
||||
cordHist: HistoryItem[]
|
||||
trailLength: number
|
||||
mounted: boolean
|
||||
setIsAnimating: (value: boolean) => void
|
||||
}): Promise<void> {
|
||||
const { gsap, imgs, cordHist, trailLength, mounted, setIsAnimating } = args
|
||||
if (!mounted) throw new Error('not mounted')
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
const currentIndex = getCurrentElIndex(cordHist)
|
||||
const trailInactiveIndexes = getTrailInactiveElsIndex(cordHist, trailLength)
|
||||
|
||||
lores(getImagesFromIndexes(imgs, [...trailInactiveIndexes, currentIndex]))
|
||||
|
||||
const tl = gsap.timeline()
|
||||
const current = getImagesFromIndexes(imgs, [currentIndex])[0]
|
||||
const trailInactiveEls = getImagesFromIndexes(imgs, trailInactiveIndexes)
|
||||
|
||||
tl.to(current, {
|
||||
scale: 0.6,
|
||||
duration: 0.6,
|
||||
ease: 'power3.inOut'
|
||||
})
|
||||
tl.to(current, {
|
||||
delay: 0.3,
|
||||
duration: 0.7,
|
||||
ease: 'power3.inOut',
|
||||
x: cordHist.slice(-1)[0].x - window.innerWidth / 2,
|
||||
y: cordHist.slice(-1)[0].y - window.innerHeight / 2
|
||||
})
|
||||
tl.to(trailInactiveEls, {
|
||||
y: '-=20',
|
||||
ease: 'power3.out',
|
||||
stagger: -0.1,
|
||||
duration: 0.3,
|
||||
opacity: 1
|
||||
})
|
||||
|
||||
await tl.then(() => {
|
||||
setIsAnimating(false)
|
||||
})
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
import { For, createEffect, type Accessor, type JSX, type Setter } from 'solid-js'
|
||||
import { For, createEffect, createMemo, on, onCleanup, type JSX } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { decrement, increment, type Vector } from '../utils'
|
||||
import { useImageState } from '../imageState'
|
||||
import { decrement, increment } from '../utils'
|
||||
|
||||
import type { HistoryItem } from './layout'
|
||||
import { useDesktopState } from './state'
|
||||
|
||||
export default function StageNav(props: {
|
||||
children?: JSX.Element
|
||||
prevText: string
|
||||
closeText: string
|
||||
nextText: string
|
||||
loadingText: string
|
||||
active: Accessor<boolean>
|
||||
isAnimating: Accessor<boolean>
|
||||
setCordHist: Setter<HistoryItem[]>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
setHoverText: Setter<string>
|
||||
navVector: Accessor<Vector>
|
||||
setNavVector: Setter<Vector>
|
||||
}): JSX.Element {
|
||||
// types
|
||||
type NavItem = (typeof navItems)[number]
|
||||
@@ -29,64 +20,74 @@ export default function StageNav(props: {
|
||||
const navItems = [props.prevText, props.closeText, props.nextText] as const
|
||||
|
||||
// states
|
||||
const [state, { incIndex, decIndex }] = useState()
|
||||
const imageState = useImageState()
|
||||
const [
|
||||
desktop,
|
||||
{ incIndex, decIndex, setCordHist, setHoverText, setIsOpen, setNavVector }
|
||||
] = useDesktopState()
|
||||
|
||||
const stateLength = state().length
|
||||
const active = createMemo(() => desktop.isOpen() && !desktop.isAnimating())
|
||||
|
||||
const prevImage: () => void = () => {
|
||||
props.setNavVector('prev')
|
||||
props.setCordHist((c) =>
|
||||
setNavVector('prev')
|
||||
setCordHist((c) =>
|
||||
c.map((item) => {
|
||||
return { ...item, i: decrement(item.i, stateLength) }
|
||||
return { ...item, i: decrement(item.i, imageState().length) }
|
||||
})
|
||||
)
|
||||
decIndex()
|
||||
}
|
||||
|
||||
const closeImage: () => void = () => {
|
||||
props.setIsOpen(false)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const nextImage: () => void = () => {
|
||||
props.setNavVector('next')
|
||||
props.setCordHist((c) =>
|
||||
setNavVector('next')
|
||||
setCordHist((c) =>
|
||||
c.map((item) => {
|
||||
return { ...item, i: increment(item.i, stateLength) }
|
||||
return { ...item, i: increment(item.i, imageState().length) }
|
||||
})
|
||||
)
|
||||
incIndex()
|
||||
}
|
||||
|
||||
const handleClick: (item: NavItem) => void = (item) => {
|
||||
if (!props.isOpen() || props.isAnimating()) return
|
||||
if (!desktop.isOpen() || desktop.isAnimating()) return
|
||||
if (item === navItems[0]) prevImage()
|
||||
else if (item === navItems[1]) closeImage()
|
||||
else nextImage()
|
||||
}
|
||||
|
||||
const handleKey: (e: KeyboardEvent) => void = (e) => {
|
||||
if (!props.isOpen() || props.isAnimating()) return
|
||||
if (!desktop.isOpen() || desktop.isAnimating()) return
|
||||
if (e.key === 'ArrowLeft') prevImage()
|
||||
else if (e.key === 'Escape') closeImage()
|
||||
else if (e.key === 'ArrowRight') nextImage()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.isOpen()) {
|
||||
createEffect(
|
||||
on(desktop.isOpen, (isOpen) => {
|
||||
controller?.abort()
|
||||
|
||||
if (isOpen) {
|
||||
controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
window.addEventListener('keydown', handleKey, {
|
||||
passive: true,
|
||||
signal: abortSignal
|
||||
})
|
||||
} else {
|
||||
controller?.abort()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="navOverlay" classList={{ active: props.active() }}>
|
||||
<div class="navOverlay" classList={{ active: active() }}>
|
||||
<For each={navItems}>
|
||||
{(item) => (
|
||||
<div
|
||||
@@ -94,8 +95,8 @@ export default function StageNav(props: {
|
||||
onClick={() => {
|
||||
handleClick(item)
|
||||
}}
|
||||
onFocus={() => props.setHoverText(item)}
|
||||
onMouseOver={() => props.setHoverText(item)}
|
||||
onFocus={() => setHoverText(item)}
|
||||
onMouseOver={() => setHoverText(item)}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
67
assets/ts/desktop/stageUtils.ts
Normal file
67
assets/ts/desktop/stageUtils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { decrement, increment } from '../utils'
|
||||
|
||||
import type { DesktopImage } from './layout'
|
||||
import type { HistoryItem } from './state'
|
||||
|
||||
export function getTrailElsIndex(cordHistValue: HistoryItem[]): number[] {
|
||||
return cordHistValue.map((el) => el.i)
|
||||
}
|
||||
|
||||
export function getTrailInactiveElsIndex(
|
||||
cordHistValue: HistoryItem[],
|
||||
trailLength: number
|
||||
): number[] {
|
||||
return getTrailElsIndex(cordHistValue).slice(-trailLength).slice(0, -1)
|
||||
}
|
||||
|
||||
export function getCurrentElIndex(cordHistValue: HistoryItem[]): number {
|
||||
return getTrailElsIndex(cordHistValue).slice(-1)[0]
|
||||
}
|
||||
|
||||
export function getPrevElIndex(cordHistValue: HistoryItem[], length: number): number {
|
||||
return decrement(cordHistValue.slice(-1)[0].i, length)
|
||||
}
|
||||
|
||||
export function getNextElIndex(cordHistValue: HistoryItem[], length: number): number {
|
||||
return increment(cordHistValue.slice(-1)[0].i, length)
|
||||
}
|
||||
|
||||
export function getImagesFromIndexes(
|
||||
imgs: DesktopImage[],
|
||||
indexes: number[]
|
||||
): DesktopImage[] {
|
||||
return indexes.map((i) => imgs[i])
|
||||
}
|
||||
|
||||
export function hires(imgs: DesktopImage[]): void {
|
||||
imgs.forEach((img) => {
|
||||
if (img.src === img.dataset.hiUrl) return
|
||||
img.src = img.dataset.hiUrl
|
||||
img.height = parseInt(img.dataset.hiImgH)
|
||||
img.width = parseInt(img.dataset.hiImgW)
|
||||
})
|
||||
}
|
||||
|
||||
export function lores(imgs: DesktopImage[]): void {
|
||||
imgs.forEach((img) => {
|
||||
if (img.src === img.dataset.loUrl) return
|
||||
img.src = img.dataset.loUrl
|
||||
img.height = parseInt(img.dataset.loImgH)
|
||||
img.width = parseInt(img.dataset.loImgW)
|
||||
})
|
||||
}
|
||||
|
||||
export function onMutation<T extends HTMLElement>(
|
||||
element: T,
|
||||
trigger: (arg0: MutationRecord) => boolean,
|
||||
observeOptions: MutationObserverInit = { attributes: true }
|
||||
): void {
|
||||
new MutationObserver((mutations, observer) => {
|
||||
for (const mutation of mutations) {
|
||||
if (trigger(mutation)) {
|
||||
observer.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}).observe(element, observeOptions)
|
||||
}
|
||||
96
assets/ts/desktop/state.ts
Normal file
96
assets/ts/desktop/state.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
createComponent,
|
||||
createContext,
|
||||
createSignal,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { useImageState } from '../imageState'
|
||||
import { decrement, increment, type Vector } from '../utils'
|
||||
|
||||
export interface HistoryItem {
|
||||
i: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DesktopState {
|
||||
index: Accessor<number>
|
||||
cordHist: Accessor<HistoryItem[]>
|
||||
hoverText: Accessor<string>
|
||||
isOpen: Accessor<boolean>
|
||||
isAnimating: Accessor<boolean>
|
||||
isLoading: Accessor<boolean>
|
||||
navVector: Accessor<Vector>
|
||||
}
|
||||
|
||||
export type DesktopStateContextType = readonly [
|
||||
DesktopState,
|
||||
{
|
||||
readonly setIndex: Setter<number>
|
||||
readonly incIndex: () => void
|
||||
readonly decIndex: () => void
|
||||
readonly setCordHist: Setter<HistoryItem[]>
|
||||
readonly setHoverText: Setter<string>
|
||||
readonly setIsOpen: Setter<boolean>
|
||||
readonly setIsAnimating: Setter<boolean>
|
||||
readonly setIsLoading: Setter<boolean>
|
||||
readonly setNavVector: Setter<Vector>
|
||||
}
|
||||
]
|
||||
|
||||
const DesktopStateContext = createContext<DesktopStateContextType>()
|
||||
|
||||
export function DesktopStateProvider(props: { children?: JSX.Element }): JSX.Element {
|
||||
const imageState = useImageState()
|
||||
|
||||
const [index, setIndex] = createSignal(-1)
|
||||
const [cordHist, setCordHist] = createSignal<HistoryItem[]>([])
|
||||
const [hoverText, setHoverText] = createSignal('')
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [navVector, setNavVector] = createSignal<Vector>('none')
|
||||
|
||||
const updateIndex = (stride: 1 | -1): void => {
|
||||
const length = imageState().length
|
||||
if (length <= 0) return
|
||||
setIndex((current) =>
|
||||
stride === 1 ? increment(current, length) : decrement(current, length)
|
||||
)
|
||||
}
|
||||
|
||||
return createComponent(DesktopStateContext.Provider, {
|
||||
value: [
|
||||
{ index, cordHist, hoverText, isOpen, isAnimating, isLoading, navVector },
|
||||
{
|
||||
setIndex,
|
||||
incIndex: () => {
|
||||
updateIndex(1)
|
||||
},
|
||||
decIndex: () => {
|
||||
updateIndex(-1)
|
||||
},
|
||||
setCordHist,
|
||||
setHoverText,
|
||||
setIsOpen,
|
||||
setIsAnimating,
|
||||
setIsLoading,
|
||||
setNavVector
|
||||
}
|
||||
],
|
||||
get children() {
|
||||
return props.children
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useDesktopState(): DesktopStateContextType {
|
||||
const context = useContext(DesktopStateContext)
|
||||
invariant(context, 'undefined desktop context')
|
||||
return context
|
||||
}
|
||||
41
assets/ts/imageState.tsx
Normal file
41
assets/ts/imageState.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX
|
||||
} from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import type { ImageJSON } from './resources'
|
||||
|
||||
export interface ImageState {
|
||||
images: ImageJSON[]
|
||||
length: number
|
||||
}
|
||||
|
||||
type ImageStateContextType = Accessor<ImageState>
|
||||
|
||||
const ImageStateContext = createContext<ImageStateContextType>()
|
||||
|
||||
export function ImageStateProvider(props: {
|
||||
children?: JSX.Element
|
||||
images: ImageJSON[]
|
||||
}): JSX.Element {
|
||||
const state = createMemo<ImageState>(() => ({
|
||||
images: props.images,
|
||||
length: props.images.length
|
||||
}))
|
||||
|
||||
return (
|
||||
<ImageStateContext.Provider value={state}>
|
||||
{props.children}
|
||||
</ImageStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useImageState(): ImageStateContextType {
|
||||
const context = useContext(ImageStateContext)
|
||||
invariant(context, 'undefined image context')
|
||||
return context
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import {
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
lazy,
|
||||
type JSX
|
||||
} from 'solid-js'
|
||||
import { Match, Show, Switch, createResource, lazy, type JSX } from 'solid-js'
|
||||
import { render } from 'solid-js/web'
|
||||
|
||||
import { ConfigStateProvider } from './configState'
|
||||
import { DesktopStateProvider } from './desktop/state'
|
||||
import { ImageStateProvider } from './imageState'
|
||||
import { MobileStateProvider } from './mobile/state'
|
||||
import { getImageJSON } from './resources'
|
||||
import { StateProvider } from './state'
|
||||
|
||||
import '../scss/style.scss'
|
||||
|
||||
@@ -35,48 +29,60 @@ const container = document.getElementsByClassName('container')[0] as Container
|
||||
const Desktop = lazy(async () => await import('./desktop/layout'))
|
||||
const Mobile = lazy(async () => await import('./mobile/layout'))
|
||||
|
||||
function AppContent(props: {
|
||||
isMobile: boolean
|
||||
prevText: string
|
||||
closeText: string
|
||||
nextText: string
|
||||
loadingText: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Switch fallback={<div>Error</div>}>
|
||||
<Match when={props.isMobile}>
|
||||
<MobileStateProvider>
|
||||
<Mobile closeText={props.closeText} loadingText={props.loadingText} />
|
||||
</MobileStateProvider>
|
||||
</Match>
|
||||
<Match when={!props.isMobile}>
|
||||
<DesktopStateProvider>
|
||||
<Desktop
|
||||
prevText={props.prevText}
|
||||
closeText={props.closeText}
|
||||
nextText={props.nextText}
|
||||
loadingText={props.loadingText}
|
||||
/>
|
||||
</DesktopStateProvider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Main(): JSX.Element {
|
||||
// variables
|
||||
const [ijs] = createResource(getImageJSON)
|
||||
const isMobile =
|
||||
window.matchMedia('(hover: none)').matches &&
|
||||
!window.navigator.userAgent.includes('Win')
|
||||
|
||||
// states
|
||||
const [scrollable, setScollable] = createSignal(true)
|
||||
|
||||
createEffect(() => {
|
||||
if (scrollable()) {
|
||||
container.classList.remove('disableScroll')
|
||||
} else {
|
||||
container.classList.add('disableScroll')
|
||||
}
|
||||
})
|
||||
const ua = window.navigator.userAgent.toLowerCase()
|
||||
const hasTouchInput = 'ontouchstart' in window || window.navigator.maxTouchPoints > 0
|
||||
const hasTouchLayout =
|
||||
window.matchMedia('(pointer: coarse)').matches ||
|
||||
window.matchMedia('(hover: none)').matches
|
||||
const isMobileUA = /android|iphone|ipad|ipod|mobile/.test(ua)
|
||||
const isWindowsDesktop = /windows nt/.test(ua)
|
||||
const isMobile = isMobileUA || (hasTouchInput && hasTouchLayout && !isWindowsDesktop)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={ijs.state === 'ready'}>
|
||||
<StateProvider length={ijs()?.length ?? 0}>
|
||||
<Switch fallback={<div>Error</div>}>
|
||||
<Match when={isMobile}>
|
||||
<Mobile
|
||||
ijs={ijs() ?? []}
|
||||
closeText={container.dataset.close}
|
||||
loadingText={container.dataset.loading}
|
||||
setScrollable={setScollable}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!isMobile}>
|
||||
<Desktop
|
||||
ijs={ijs() ?? []}
|
||||
<ImageStateProvider images={ijs() ?? []}>
|
||||
<ConfigStateProvider>
|
||||
<AppContent
|
||||
isMobile={isMobile}
|
||||
prevText={container.dataset.prev}
|
||||
closeText={container.dataset.close}
|
||||
nextText={container.dataset.next}
|
||||
loadingText={container.dataset.loading}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</StateProvider>
|
||||
</ConfigStateProvider>
|
||||
</ImageStateProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import {
|
||||
For,
|
||||
createEffect,
|
||||
on,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
import { For, createEffect, on, onMount, type JSX } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useState } from '../state'
|
||||
import { useImageState } from '../imageState'
|
||||
|
||||
import type { MobileImage } from './layout'
|
||||
import { useMobileState } from './state'
|
||||
|
||||
function getRandom(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
@@ -31,29 +23,26 @@ function onIntersection<T extends HTMLElement>(
|
||||
}).observe(element)
|
||||
}
|
||||
|
||||
export default function Collection(props: {
|
||||
children?: JSX.Element
|
||||
ijs: ImageJSON[]
|
||||
isAnimating: Accessor<boolean>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
export default function Collection(): JSX.Element {
|
||||
// variables
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const imgs: MobileImage[] = Array<MobileImage>(props.ijs.length)
|
||||
const imageState = useImageState()
|
||||
const imgs: MobileImage[] = Array<MobileImage>(imageState().length)
|
||||
|
||||
// states
|
||||
const [state, { setIndex }] = useState()
|
||||
const [mobile, { setIndex, setIsOpen }] = useMobileState()
|
||||
|
||||
// helper functions
|
||||
const handleClick: (i: number) => void = (i) => {
|
||||
if (props.isAnimating()) return
|
||||
if (mobile.isAnimating()) return
|
||||
setIndex(i)
|
||||
props.setIsOpen(true)
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const scrollToActive: () => void = () => {
|
||||
imgs[state().index].scrollIntoView({ behavior: 'auto', block: 'center' })
|
||||
const index = mobile.index()
|
||||
|
||||
if (index < 0) return
|
||||
imgs[index].scrollIntoView({ behavior: 'auto', block: 'center' })
|
||||
}
|
||||
|
||||
// effects
|
||||
@@ -94,11 +83,9 @@ export default function Collection(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
mobile.isOpen,
|
||||
() => {
|
||||
props.isOpen()
|
||||
},
|
||||
() => {
|
||||
if (!props.isOpen()) scrollToActive() // scroll to active when closed
|
||||
if (!mobile.isOpen()) scrollToActive() // scroll to active when closed
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
@@ -107,7 +94,7 @@ export default function Collection(props: {
|
||||
return (
|
||||
<>
|
||||
<div class="collection">
|
||||
<For each={props.ijs}>
|
||||
<For each={imageState().images}>
|
||||
{(ij, i) => (
|
||||
<img
|
||||
ref={imgs[i()]}
|
||||
|
||||
@@ -1,209 +1,170 @@
|
||||
import { type gsap } from 'gsap'
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
on,
|
||||
onMount,
|
||||
Show,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
untrack,
|
||||
type JSX
|
||||
} from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { type Swiper } from 'swiper'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { type ImageJSON } from '../resources'
|
||||
import { useState } from '../state'
|
||||
import { loadGsap, type Vector } from '../utils'
|
||||
import { useImageState } from '../imageState'
|
||||
import { loadGsap, removeDuplicates, type Vector } from '../utils'
|
||||
|
||||
import GalleryImage from './galleryImage'
|
||||
import GalleryNav, { capitalizeFirstLetter } from './galleryNav'
|
||||
|
||||
function removeDuplicates<T>(arr: T[]): T[] {
|
||||
if (arr.length < 2) return arr // optimization
|
||||
return [...new Set(arr)]
|
||||
}
|
||||
|
||||
async function loadSwiper(): Promise<typeof Swiper> {
|
||||
const s = await import('swiper')
|
||||
return s.Swiper
|
||||
}
|
||||
import { closeGallery, openGallery } from './galleryTransitions'
|
||||
import { getActiveImageIndexes, loadSwiper } from './galleryUtils'
|
||||
import { useMobileState } from './state'
|
||||
|
||||
export default function Gallery(props: {
|
||||
children?: JSX.Element
|
||||
ijs: ImageJSON[]
|
||||
closeText: string
|
||||
loadingText: string
|
||||
isAnimating: Accessor<boolean>
|
||||
setIsAnimating: Setter<boolean>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
setScrollable: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
// variables
|
||||
let _gsap: typeof gsap
|
||||
let _swiper: Swiper
|
||||
let _swiper: Swiper | undefined
|
||||
let initPromise: Promise<void> | undefined
|
||||
|
||||
let curtain: HTMLDivElement | undefined
|
||||
let gallery: HTMLDivElement | undefined
|
||||
let galleryInner: HTMLDivElement | undefined
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const _loadingText = capitalizeFirstLetter(props.loadingText)
|
||||
const imageState = useImageState()
|
||||
const [mobile, { setIndex, setIsAnimating, setIsScrollLocked }] = useMobileState()
|
||||
|
||||
const loadingText = createMemo(() => capitalizeFirstLetter(props.loadingText))
|
||||
|
||||
// states
|
||||
let lastIndex = -1
|
||||
let mounted = false
|
||||
let navigateVector: Vector = 'none'
|
||||
|
||||
const [state, { setIndex }] = useState()
|
||||
const [libLoaded, setLibLoaded] = createSignal(false)
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const [loads, setLoads] = createStore(Array<boolean>(props.ijs.length).fill(false))
|
||||
const [swiperReady, setSwiperReady] = createSignal(false)
|
||||
const [loads, setLoads] = createStore(Array<boolean>(imageState().length).fill(false))
|
||||
|
||||
// helper functions
|
||||
const slideUp: () => void = () => {
|
||||
// isAnimating is prechecked in isOpen effect
|
||||
if (!libLoaded() || !mounted) return
|
||||
props.setIsAnimating(true)
|
||||
|
||||
invariant(curtain, 'curtain is not defined')
|
||||
invariant(gallery, 'gallery is not defined')
|
||||
|
||||
_gsap.to(curtain, {
|
||||
opacity: 1,
|
||||
duration: 1
|
||||
openGallery({
|
||||
gsap: _gsap,
|
||||
curtain,
|
||||
gallery,
|
||||
setIsAnimating,
|
||||
setIsScrollLocked
|
||||
})
|
||||
|
||||
_gsap.to(gallery, {
|
||||
y: 0,
|
||||
ease: 'power3.inOut',
|
||||
duration: 1,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
props.setScrollable(false)
|
||||
props.setIsAnimating(false)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const slideDown: () => void = () => {
|
||||
// isAnimating is prechecked in isOpen effect
|
||||
props.setIsAnimating(true)
|
||||
|
||||
invariant(gallery, 'curtain is not defined')
|
||||
invariant(curtain, 'gallery is not defined')
|
||||
|
||||
_gsap.to(gallery, {
|
||||
y: '100%',
|
||||
ease: 'power3.inOut',
|
||||
duration: 1
|
||||
})
|
||||
|
||||
_gsap.to(curtain, {
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
// cleanup
|
||||
props.setScrollable(true)
|
||||
props.setIsAnimating(false)
|
||||
closeGallery({
|
||||
gsap: _gsap,
|
||||
curtain,
|
||||
gallery,
|
||||
setIsAnimating,
|
||||
setIsScrollLocked,
|
||||
onClosed: () => {
|
||||
lastIndex = -1
|
||||
}, 1400)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const galleryLoadImages: () => void = () => {
|
||||
let activeImagesIndex: number[] = []
|
||||
const _state = state()
|
||||
const currentIndex = _state.index
|
||||
const nextIndex = Math.min(currentIndex + 1, _state.length - 1)
|
||||
const prevIndex = Math.max(currentIndex - 1, 0)
|
||||
switch (navigateVector) {
|
||||
case 'next':
|
||||
activeImagesIndex = [nextIndex]
|
||||
break
|
||||
case 'prev':
|
||||
activeImagesIndex = [prevIndex]
|
||||
break
|
||||
case 'none':
|
||||
activeImagesIndex = [currentIndex, nextIndex, prevIndex]
|
||||
break
|
||||
}
|
||||
setLoads(removeDuplicates(activeImagesIndex), true)
|
||||
const currentIndex = mobile.index()
|
||||
|
||||
setLoads(
|
||||
removeDuplicates(
|
||||
getActiveImageIndexes(currentIndex, imageState().length, navigateVector)
|
||||
),
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const changeSlide: (slide: number) => void = (slide) => {
|
||||
// we are already in the gallery, don't need to
|
||||
// check mounted or libLoaded
|
||||
if (!swiperReady() || _swiper === undefined) return
|
||||
galleryLoadImages()
|
||||
_swiper.slideTo(slide, 0)
|
||||
}
|
||||
|
||||
// effects
|
||||
onMount(() => {
|
||||
window.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
loadGsap()
|
||||
.then((g) => {
|
||||
const ensureGalleryReady: () => Promise<void> = async () => {
|
||||
if (initPromise !== undefined) return await initPromise
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
const [g, S] = await Promise.all([loadGsap(), loadSwiper()])
|
||||
|
||||
_gsap = g
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
loadSwiper()
|
||||
.then((S) => {
|
||||
|
||||
invariant(galleryInner, 'galleryInner is not defined')
|
||||
_swiper = new S(galleryInner, { spaceBetween: 20 })
|
||||
_swiper.on('slideChange', ({ realIndex }) => {
|
||||
setIndex(realIndex)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
|
||||
setLibLoaded(true)
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
setSwiperReady(true)
|
||||
|
||||
const initialIndex = untrack(mobile.index)
|
||||
|
||||
if (initialIndex >= 0) {
|
||||
changeSlide(initialIndex)
|
||||
lastIndex = initialIndex
|
||||
}
|
||||
} catch (e) {
|
||||
initPromise = undefined
|
||||
setSwiperReady(false)
|
||||
console.log(e)
|
||||
}
|
||||
})()
|
||||
|
||||
await initPromise
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('touchstart', () => void ensureGalleryReady(), {
|
||||
once: true,
|
||||
passive: true
|
||||
})
|
||||
mounted = true
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
state()
|
||||
},
|
||||
() => {
|
||||
const i = state().index
|
||||
if (i === lastIndex)
|
||||
return // change slide only when index is changed
|
||||
else if (lastIndex === -1)
|
||||
navigateVector = 'none' // lastIndex before set
|
||||
else if (i < lastIndex)
|
||||
navigateVector = 'prev' // set navigate vector for galleryLoadImages
|
||||
else if (i > lastIndex)
|
||||
navigateVector = 'next' // set navigate vector for galleryLoadImages
|
||||
else navigateVector = 'none' // default
|
||||
changeSlide(i) // change slide to new index
|
||||
lastIndex = i // update last index
|
||||
() => [swiperReady(), mobile.index()] as const,
|
||||
([ready, index]) => {
|
||||
if (!ready || index < 0) return
|
||||
if (index === lastIndex) return
|
||||
if (lastIndex === -1) navigateVector = 'none'
|
||||
else if (index < lastIndex) navigateVector = 'prev'
|
||||
else if (index > lastIndex) navigateVector = 'next'
|
||||
else navigateVector = 'none'
|
||||
changeSlide(index)
|
||||
lastIndex = index
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
props.isOpen()
|
||||
},
|
||||
() => {
|
||||
if (props.isAnimating()) return
|
||||
if (props.isOpen()) slideUp()
|
||||
() => mobile.isOpen(),
|
||||
async (isOpen) => {
|
||||
if (isOpen && !swiperReady()) {
|
||||
await ensureGalleryReady()
|
||||
}
|
||||
|
||||
if (!libLoaded() || !swiperReady()) return
|
||||
if (mobile.isAnimating()) return
|
||||
if (isOpen) slideUp()
|
||||
else slideDown()
|
||||
},
|
||||
{ defer: true }
|
||||
@@ -215,26 +176,16 @@ export default function Gallery(props: {
|
||||
<div ref={gallery} class="gallery">
|
||||
<div ref={galleryInner} class="galleryInner">
|
||||
<div class="swiper-wrapper">
|
||||
<Show when={libLoaded()}>
|
||||
<For each={props.ijs}>
|
||||
<For each={imageState().images}>
|
||||
{(ij, i) => (
|
||||
<div class="swiper-slide">
|
||||
<GalleryImage
|
||||
load={loads[i()]}
|
||||
ij={ij}
|
||||
loadingText={_loadingText}
|
||||
/>
|
||||
<GalleryImage load={loads[i()]} ij={ij} loadingText={loadingText()} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<GalleryNav
|
||||
closeText={props.closeText}
|
||||
isAnimating={props.isAnimating}
|
||||
setIsOpen={props.setIsOpen}
|
||||
/>
|
||||
<GalleryNav closeText={props.closeText} />
|
||||
</div>
|
||||
<div ref={curtain} class="curtain" />
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { onMount, type JSX } from 'solid-js'
|
||||
import { type gsap } from 'gsap'
|
||||
import { createEffect, on, onMount, type JSX } from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useState } from '../state'
|
||||
import { loadGsap } from '../utils'
|
||||
|
||||
import { useMobileState } from './state'
|
||||
|
||||
export default function GalleryImage(props: {
|
||||
children?: JSX.Element
|
||||
load: boolean
|
||||
@@ -14,27 +16,39 @@ export default function GalleryImage(props: {
|
||||
let img: HTMLImageElement | undefined
|
||||
let loadingDiv: HTMLDivElement | undefined
|
||||
|
||||
let _gsap: typeof gsap
|
||||
let _gsap: typeof gsap | undefined
|
||||
let gsapPromise: Promise<typeof gsap> | undefined
|
||||
let revealed = false
|
||||
|
||||
const [state] = useState()
|
||||
const [mobile] = useMobileState()
|
||||
|
||||
const revealImage = async (): Promise<void> => {
|
||||
if (revealed) return
|
||||
revealed = true
|
||||
|
||||
onMount(() => {
|
||||
loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
img?.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
invariant(img, 'ref must be defined')
|
||||
invariant(loadingDiv, 'loadingDiv must be defined')
|
||||
if (state().index !== props.ij.index) {
|
||||
|
||||
gsapPromise ??= loadGsap()
|
||||
|
||||
try {
|
||||
_gsap ??= await gsapPromise
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
if (_gsap === undefined) {
|
||||
img.style.opacity = '1'
|
||||
loadingDiv.style.opacity = '0'
|
||||
return
|
||||
}
|
||||
|
||||
if (mobile.index() !== props.ij.index) {
|
||||
_gsap.set(img, { opacity: 1 })
|
||||
_gsap.set(loadingDiv, { opacity: 0 })
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
_gsap.to(img, {
|
||||
opacity: 1,
|
||||
delay: 0.5,
|
||||
@@ -43,11 +57,42 @@ export default function GalleryImage(props: {
|
||||
})
|
||||
_gsap.to(loadingDiv, { opacity: 0, duration: 0.5, ease: 'power3.in' })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
gsapPromise = loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
return g
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
throw e
|
||||
})
|
||||
|
||||
img?.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
void revealImage()
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
|
||||
if (props.load && img?.complete && img.currentSrc !== '') {
|
||||
void revealImage()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.load,
|
||||
(load) => {
|
||||
if (!load || img === undefined || !img.complete || img.currentSrc === '') return
|
||||
void revealImage()
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="slideContainer">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createMemo, type Accessor, type JSX, type Setter } from 'solid-js'
|
||||
import { createMemo, type JSX } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { useImageState } from '../imageState'
|
||||
import { expand } from '../utils'
|
||||
|
||||
import { useMobileState } from './state'
|
||||
|
||||
export function capitalizeFirstLetter(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
@@ -10,17 +12,16 @@ export function capitalizeFirstLetter(str: string): string {
|
||||
export default function GalleryNav(props: {
|
||||
children?: JSX.Element
|
||||
closeText: string
|
||||
isAnimating: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
// states
|
||||
const [state] = useState()
|
||||
const indexValue = createMemo(() => expand(state().index + 1))
|
||||
const indexLength = createMemo(() => expand(state().length))
|
||||
const imageState = useImageState()
|
||||
const [mobile, { setIsOpen }] = useMobileState()
|
||||
const indexValue = createMemo(() => expand(mobile.index() + 1))
|
||||
const indexLength = createMemo(() => expand(imageState().length))
|
||||
|
||||
const onClick: () => void = () => {
|
||||
if (props.isAnimating()) return
|
||||
props.setIsOpen(false)
|
||||
if (mobile.isAnimating()) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -37,7 +38,14 @@ export default function GalleryNav(props: {
|
||||
<span class="num">{indexLength()[2]}</span>
|
||||
<span class="num">{indexLength()[3]}</span>
|
||||
</div>
|
||||
<div class="navClose" onClick={onClick} onKeyDown={onClick}>
|
||||
<div
|
||||
class="navClose"
|
||||
onClick={onClick}
|
||||
onTouchEnd={onClick}
|
||||
onKeyDown={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
>
|
||||
{capitalizeFirstLetter(props.closeText)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
64
assets/ts/mobile/galleryTransitions.ts
Normal file
64
assets/ts/mobile/galleryTransitions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { type gsap } from 'gsap'
|
||||
|
||||
const OPEN_DELAY_MS = 1200
|
||||
const CLOSE_DELAY_MS = 1400
|
||||
|
||||
export function openGallery(args: {
|
||||
gsap: typeof gsap
|
||||
curtain: HTMLDivElement
|
||||
gallery: HTMLDivElement
|
||||
setIsAnimating: (value: boolean) => void
|
||||
setIsScrollLocked: (value: boolean) => void
|
||||
}): void {
|
||||
const { gsap, curtain, gallery, setIsAnimating, setIsScrollLocked } = args
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
gsap.to(curtain, {
|
||||
opacity: 1,
|
||||
duration: 1
|
||||
})
|
||||
|
||||
gsap.to(gallery, {
|
||||
y: 0,
|
||||
ease: 'power3.inOut',
|
||||
duration: 1,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setIsScrollLocked(true)
|
||||
setIsAnimating(false)
|
||||
}, OPEN_DELAY_MS)
|
||||
}
|
||||
|
||||
export function closeGallery(args: {
|
||||
gsap: typeof gsap
|
||||
curtain: HTMLDivElement
|
||||
gallery: HTMLDivElement
|
||||
setIsAnimating: (value: boolean) => void
|
||||
setIsScrollLocked: (value: boolean) => void
|
||||
onClosed: () => void
|
||||
}): void {
|
||||
const { gsap, curtain, gallery, setIsAnimating, setIsScrollLocked, onClosed } = args
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
gsap.to(gallery, {
|
||||
y: '100%',
|
||||
ease: 'power3.inOut',
|
||||
duration: 1
|
||||
})
|
||||
|
||||
gsap.to(curtain, {
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setIsScrollLocked(false)
|
||||
setIsAnimating(false)
|
||||
onClosed()
|
||||
}, CLOSE_DELAY_MS)
|
||||
}
|
||||
26
assets/ts/mobile/galleryUtils.ts
Normal file
26
assets/ts/mobile/galleryUtils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type Swiper } from 'swiper'
|
||||
|
||||
import type { Vector } from '../utils'
|
||||
|
||||
export async function loadSwiper(): Promise<typeof Swiper> {
|
||||
const swiper = await import('swiper')
|
||||
return swiper.Swiper
|
||||
}
|
||||
|
||||
export function getActiveImageIndexes(
|
||||
currentIndex: number,
|
||||
length: number,
|
||||
navigateVector: Vector
|
||||
): number[] {
|
||||
const nextIndex = Math.min(currentIndex + 1, length - 1)
|
||||
const prevIndex = Math.max(currentIndex - 1, 0)
|
||||
|
||||
switch (navigateVector) {
|
||||
case 'next':
|
||||
return [nextIndex]
|
||||
case 'prev':
|
||||
return [prevIndex]
|
||||
case 'none':
|
||||
return [currentIndex, nextIndex, prevIndex]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Show, createSignal, type JSX, type Setter } from 'solid-js'
|
||||
import { Show, createEffect, onCleanup, type JSX } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useImageState } from '../imageState'
|
||||
|
||||
import Collection from './collection'
|
||||
import Gallery from './gallery'
|
||||
import { useMobileState } from './state'
|
||||
|
||||
/**
|
||||
* interfaces
|
||||
@@ -18,34 +19,33 @@ export interface MobileImage extends HTMLImageElement {
|
||||
|
||||
export default function Mobile(props: {
|
||||
children?: JSX.Element
|
||||
ijs: ImageJSON[]
|
||||
closeText: string
|
||||
loadingText: string
|
||||
setScrollable: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
// states
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
||||
const imageState = useImageState()
|
||||
const [mobile] = useMobileState()
|
||||
|
||||
createEffect(() => {
|
||||
const container = document.getElementsByClassName('container').item(0)
|
||||
if (container === null) return
|
||||
|
||||
if (mobile.isScrollLocked()) {
|
||||
container.classList.add('disableScroll')
|
||||
} else {
|
||||
container.classList.remove('disableScroll')
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
const container = document.getElementsByClassName('container').item(0)
|
||||
container?.classList.remove('disableScroll')
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.ijs.length > 0}>
|
||||
<Collection
|
||||
ijs={props.ijs}
|
||||
isAnimating={isAnimating}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
<Gallery
|
||||
ijs={props.ijs}
|
||||
closeText={props.closeText}
|
||||
loadingText={props.loadingText}
|
||||
isAnimating={isAnimating}
|
||||
setIsAnimating={setIsAnimating}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
setScrollable={props.setScrollable}
|
||||
/>
|
||||
<Show when={imageState().length > 0}>
|
||||
<Collection />
|
||||
<Gallery closeText={props.closeText} loadingText={props.loadingText} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
78
assets/ts/mobile/state.ts
Normal file
78
assets/ts/mobile/state.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
createComponent,
|
||||
createContext,
|
||||
createSignal,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { useImageState } from '../imageState'
|
||||
import { decrement, increment } from '../utils'
|
||||
|
||||
export interface MobileState {
|
||||
index: Accessor<number>
|
||||
isOpen: Accessor<boolean>
|
||||
isAnimating: Accessor<boolean>
|
||||
isScrollLocked: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type MobileStateContextType = readonly [
|
||||
MobileState,
|
||||
{
|
||||
readonly setIndex: Setter<number>
|
||||
readonly incIndex: () => void
|
||||
readonly decIndex: () => void
|
||||
readonly setIsOpen: Setter<boolean>
|
||||
readonly setIsAnimating: Setter<boolean>
|
||||
readonly setIsScrollLocked: Setter<boolean>
|
||||
}
|
||||
]
|
||||
|
||||
const MobileStateContext = createContext<MobileStateContextType>()
|
||||
|
||||
export function MobileStateProvider(props: { children?: JSX.Element }): JSX.Element {
|
||||
const imageState = useImageState()
|
||||
|
||||
const [index, setIndex] = createSignal(-1)
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
||||
const [isScrollLocked, setIsScrollLocked] = createSignal(false)
|
||||
|
||||
const updateIndex = (stride: 1 | -1): void => {
|
||||
const length = imageState().length
|
||||
if (length <= 0) return
|
||||
setIndex((current) =>
|
||||
stride === 1 ? increment(current, length) : decrement(current, length)
|
||||
)
|
||||
}
|
||||
|
||||
return createComponent(MobileStateContext.Provider, {
|
||||
value: [
|
||||
{ index, isOpen, isAnimating, isScrollLocked },
|
||||
{
|
||||
setIndex,
|
||||
incIndex: () => {
|
||||
updateIndex(1)
|
||||
},
|
||||
decIndex: () => {
|
||||
updateIndex(-1)
|
||||
},
|
||||
setIsOpen,
|
||||
setIsAnimating,
|
||||
setIsScrollLocked
|
||||
}
|
||||
],
|
||||
get children() {
|
||||
return props.children
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useMobileState(): MobileStateContextType {
|
||||
const context = useContext(MobileStateContext)
|
||||
invariant(context, 'undefined mobile context')
|
||||
return context
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
createSignal,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { decrement, getThresholdSessionIndex, increment } from './utils'
|
||||
|
||||
/**
|
||||
* interfaces and types
|
||||
*/
|
||||
|
||||
export interface ThresholdRelated {
|
||||
threshold: number
|
||||
trailLength: number
|
||||
}
|
||||
|
||||
export interface State {
|
||||
index: number
|
||||
length: number
|
||||
threshold: number
|
||||
trailLength: number
|
||||
}
|
||||
|
||||
export type StateContextType = readonly [
|
||||
Accessor<State>,
|
||||
{
|
||||
readonly setIndex: (index: number) => void
|
||||
readonly incIndex: () => void
|
||||
readonly decIndex: () => void
|
||||
readonly incThreshold: () => void
|
||||
readonly decThreshold: () => void
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* constants
|
||||
*/
|
||||
|
||||
const thresholds: ThresholdRelated[] = [
|
||||
{ threshold: 20, trailLength: 20 },
|
||||
{ threshold: 40, trailLength: 10 },
|
||||
{ threshold: 80, trailLength: 5 },
|
||||
{ threshold: 140, trailLength: 5 },
|
||||
{ threshold: 200, trailLength: 5 }
|
||||
]
|
||||
const makeStateContext: (
|
||||
state: Accessor<State>,
|
||||
setState: Setter<State>
|
||||
) => StateContextType = (state: Accessor<State>, setState: Setter<State>) => {
|
||||
return [
|
||||
state,
|
||||
{
|
||||
setIndex: (index: number) => {
|
||||
setState((s) => {
|
||||
return { ...s, index }
|
||||
})
|
||||
},
|
||||
incIndex: () => {
|
||||
setState((s) => {
|
||||
return { ...s, index: increment(s.index, s.length) }
|
||||
})
|
||||
},
|
||||
decIndex: () => {
|
||||
setState((s) => {
|
||||
return { ...s, index: decrement(s.index, s.length) }
|
||||
})
|
||||
},
|
||||
incThreshold: () => {
|
||||
setState((s) => {
|
||||
return { ...s, ...updateThreshold(s.threshold, thresholds, 1) }
|
||||
})
|
||||
},
|
||||
decThreshold: () => {
|
||||
setState((s) => {
|
||||
return { ...s, ...updateThreshold(s.threshold, thresholds, -1) }
|
||||
})
|
||||
}
|
||||
}
|
||||
] as const
|
||||
}
|
||||
const StateContext = createContext<StateContextType>()
|
||||
|
||||
/**
|
||||
* helper functions
|
||||
*/
|
||||
|
||||
function updateThreshold(
|
||||
currentThreshold: number,
|
||||
thresholds: ThresholdRelated[],
|
||||
stride: number
|
||||
): ThresholdRelated {
|
||||
const i = thresholds.findIndex((t) => t.threshold === currentThreshold) + stride
|
||||
if (i < 0 || i >= thresholds.length) return thresholds[i - stride]
|
||||
// storage the index so we can restore it even if we go to another page
|
||||
sessionStorage.setItem('thresholdsIndex', i.toString())
|
||||
return thresholds[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* StateProvider
|
||||
*/
|
||||
|
||||
export function StateProvider(props: {
|
||||
children?: JSX.Element
|
||||
length: number
|
||||
}): JSX.Element {
|
||||
const defaultState: State = {
|
||||
index: -1,
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
length: props.length,
|
||||
threshold: thresholds[getThresholdSessionIndex()].threshold,
|
||||
trailLength: thresholds[getThresholdSessionIndex()].trailLength
|
||||
}
|
||||
|
||||
const [state, setState] = createSignal(defaultState)
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const contextValue = makeStateContext(state, setState)
|
||||
return (
|
||||
<StateContext.Provider value={contextValue}>{props.children}</StateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* use context
|
||||
*/
|
||||
|
||||
export function useState(): StateContextType {
|
||||
const uc = useContext(StateContext)
|
||||
invariant(uc, 'undefined context')
|
||||
return uc
|
||||
}
|
||||
Reference in New Issue
Block a user