mirror of
https://github.com/Sped0n/bridget.git
synced 2026-04-14 10:09:31 -07:00
refactor: split monolithic state into context-based modules
Extract image, desktop, mobile, and config state into separate context providers to improve modularity and reduce unnecessary re-renders. Signed-off-by: Sped0n <hi@sped0n.com>
This commit is contained in:
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
|
children?: JSX.Element
|
||||||
active: Accessor<boolean>
|
active: Accessor<boolean>
|
||||||
cursorText: Accessor<string>
|
cursorText: Accessor<string>
|
||||||
isOpen: Accessor<boolean>
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
// types
|
// types
|
||||||
interface XY {
|
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 { useImageState } from '../imageState'
|
||||||
import type { Vector } from '../utils'
|
|
||||||
|
|
||||||
import CustomCursor from './customCursor'
|
import CustomCursor from './customCursor'
|
||||||
import Nav from './nav'
|
import Nav from './nav'
|
||||||
import Stage from './stage'
|
import Stage from './stage'
|
||||||
import StageNav from './stageNav'
|
import StageNav from './stageNav'
|
||||||
|
import { useDesktopState } from './state'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* interfaces and types
|
* interfaces and types
|
||||||
@@ -23,65 +23,36 @@ export interface DesktopImage extends HTMLImageElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryItem {
|
|
||||||
i: number
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* components
|
* components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function Desktop(props: {
|
export default function Desktop(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
ijs: ImageJSON[]
|
|
||||||
prevText: string
|
prevText: string
|
||||||
closeText: string
|
closeText: string
|
||||||
nextText: string
|
nextText: string
|
||||||
loadingText: string
|
loadingText: string
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [cordHist, setCordHist] = createSignal<HistoryItem[]>([])
|
const imageState = useImageState()
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [desktop] = useDesktopState()
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
|
||||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
|
||||||
const [hoverText, setHoverText] = createSignal('')
|
|
||||||
const [navVector, setNavVector] = createSignal<Vector>('none')
|
|
||||||
|
|
||||||
const active = createMemo(() => isOpen() && !isAnimating())
|
const active = createMemo(() => desktop.isOpen() && !desktop.isAnimating())
|
||||||
const cursorText = createMemo(() => (isLoading() ? props.loadingText : hoverText()))
|
const cursorText = createMemo(() =>
|
||||||
|
desktop.isLoading() ? props.loadingText : desktop.hoverText()
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
<Show when={props.ijs.length > 0}>
|
<Show when={imageState().length > 0}>
|
||||||
<Stage
|
<Stage />
|
||||||
ijs={props.ijs}
|
<Show when={desktop.isOpen()}>
|
||||||
setIsLoading={setIsLoading}
|
<CustomCursor cursorText={cursorText} active={active} />
|
||||||
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} />
|
|
||||||
<StageNav
|
<StageNav
|
||||||
prevText={props.prevText}
|
prevText={props.prevText}
|
||||||
closeText={props.closeText}
|
closeText={props.closeText}
|
||||||
nextText={props.nextText}
|
nextText={props.nextText}
|
||||||
loadingText={props.loadingText}
|
|
||||||
active={active}
|
|
||||||
isAnimating={isAnimating}
|
|
||||||
setCordHist={setCordHist}
|
|
||||||
isOpen={isOpen}
|
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
setHoverText={setHoverText}
|
|
||||||
navVector={navVector}
|
|
||||||
setNavVector={setNavVector}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</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'
|
import { expand } from '../utils'
|
||||||
|
|
||||||
/**
|
import { useDesktopState } from './state'
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function Nav(): null {
|
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(() => {
|
const imageState = useImageState()
|
||||||
updateIndexText(expand(state().index + 1), expand(state().length))
|
const [config, { incThreshold, decThreshold }] = useConfigState()
|
||||||
updateThresholdText(expand(state().threshold))
|
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
|
createEffect(() => {
|
||||||
incButton.onclick = incThreshold
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,409 +1,167 @@
|
|||||||
import { type gsap } from 'gsap'
|
import { type gsap } from 'gsap'
|
||||||
import {
|
import { For, createEffect, on, onMount, type JSX } from 'solid-js'
|
||||||
For,
|
|
||||||
createEffect,
|
|
||||||
on,
|
|
||||||
onMount,
|
|
||||||
type Accessor,
|
|
||||||
type JSX,
|
|
||||||
type Setter
|
|
||||||
} from 'solid-js'
|
|
||||||
|
|
||||||
import type { ImageJSON } from '../resources'
|
import { useConfigState } from '../configState'
|
||||||
import { useState, type State } from '../state'
|
import { useImageState } from '../imageState'
|
||||||
import { decrement, increment, loadGsap, type Vector } from '../utils'
|
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'
|
||||||
|
|
||||||
/**
|
export default function Stage(): JSX.Element {
|
||||||
* 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
|
|
||||||
let _gsap: typeof gsap
|
let _gsap: typeof gsap
|
||||||
|
let gsapPromise: Promise<void> | undefined
|
||||||
|
|
||||||
// eslint-disable-next-line solid/reactivity
|
const imageState = useImageState()
|
||||||
const imgs: DesktopImage[] = Array<DesktopImage>(props.ijs.length)
|
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 last = { x: 0, y: 0 }
|
||||||
|
|
||||||
let abortController: AbortController | undefined
|
let abortController: AbortController | undefined
|
||||||
|
|
||||||
// states
|
|
||||||
let gsapLoaded = false
|
let gsapLoaded = false
|
||||||
|
|
||||||
const [state, { incIndex }] = useState()
|
|
||||||
const stateLength = state().length
|
|
||||||
|
|
||||||
let mounted = false
|
let mounted = false
|
||||||
|
|
||||||
|
const ensureGsapReady: () => Promise<void> = async () => {
|
||||||
|
if (gsapPromise !== undefined) return await gsapPromise
|
||||||
|
|
||||||
|
gsapPromise = loadGsap()
|
||||||
|
.then((g) => {
|
||||||
|
_gsap = g
|
||||||
|
gsapLoaded = true
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
gsapPromise = undefined
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
await gsapPromise
|
||||||
|
}
|
||||||
|
|
||||||
const onMouse: (e: MouseEvent) => void = (e) => {
|
const onMouse: (e: MouseEvent) => void = (e) => {
|
||||||
if (props.isOpen() || props.isAnimating() || !gsapLoaded || !mounted) return
|
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 cord = { x: e.clientX, y: e.clientY }
|
||||||
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
|
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
|
||||||
|
|
||||||
if (travelDist > state().threshold) {
|
if (travelDist > config().threshold) {
|
||||||
last = cord
|
const nextIndex = increment(desktop.index(), length)
|
||||||
incIndex()
|
|
||||||
|
|
||||||
const _state = state()
|
last = cord
|
||||||
const newHist = { i: _state.index, ...cord }
|
setIndex(nextIndex)
|
||||||
props.setCordHist((prev) => [...prev, newHist].slice(-stateLength))
|
setCordHist((prev) => [...prev, { i: nextIndex, ...cord }].slice(-length))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick: () => void = () => {
|
const onClick: () => Promise<void> = async () => {
|
||||||
if (!props.isAnimating()) props.setIsOpen(true)
|
if (!gsapLoaded) {
|
||||||
|
await ensureGsapReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktop.isAnimating() || !gsapLoaded) return
|
||||||
|
if (desktop.index() < 0 || desktop.cordHist().length === 0) return
|
||||||
|
setIsOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPosition: () => void = () => {
|
const setPosition: () => void = () => {
|
||||||
if (!mounted) return
|
syncStagePosition({
|
||||||
if (imgs.length === 0) return
|
gsap: _gsap,
|
||||||
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,
|
imgs,
|
||||||
getTrailInactiveElsIndex(_cordHist, _state)
|
cordHist: desktop.cordHist(),
|
||||||
)
|
trailLength: config().trailLength,
|
||||||
// move down and hide trail inactive
|
length: imageState().length,
|
||||||
tl.to(trailInactiveEls, {
|
isOpen: desktop.isOpen(),
|
||||||
y: '+=20',
|
navVector: desktop.navVector(),
|
||||||
ease: 'power3.in',
|
mounted,
|
||||||
stagger: 0.075,
|
setIsLoading
|
||||||
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<
|
const expandImage: () => Promise<void> = async () => {
|
||||||
gsap.core.Omit<gsap.core.Timeline, 'then'>
|
|
||||||
> = async () => {
|
|
||||||
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||||
|
|
||||||
props.setIsAnimating(true)
|
await expandStage({
|
||||||
props.setNavVector('none') // cleanup
|
gsap: _gsap,
|
||||||
|
imgs,
|
||||||
const _cordHist = props.cordHist()
|
cordHist: desktop.cordHist(),
|
||||||
const _state = state()
|
trailLength: config().trailLength,
|
||||||
|
length: imageState().length,
|
||||||
const elcIndex = getCurrentElIndex(_cordHist)
|
mounted,
|
||||||
const elsTrailInactiveIndexes = getTrailInactiveElsIndex(_cordHist, _state)
|
setIsLoading,
|
||||||
|
setIsAnimating
|
||||||
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 {
|
const minimizeImage: () => Promise<void> = async () => {
|
||||||
if (!mounted || !gsapLoaded) return
|
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
|
||||||
if (!img.complete) {
|
|
||||||
props.setIsLoading(true)
|
setNavVector('none')
|
||||||
// abort controller for cleanup
|
|
||||||
const controller = new AbortController()
|
await minimizeStage({
|
||||||
const abortSignal = controller.signal
|
gsap: _gsap,
|
||||||
// event listeners
|
imgs,
|
||||||
img.addEventListener(
|
cordHist: desktop.cordHist(),
|
||||||
'load',
|
trailLength: config().trailLength,
|
||||||
() => {
|
mounted,
|
||||||
_gsap
|
setIsAnimating
|
||||||
.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(() => {
|
onMount(() => {
|
||||||
// preload logic
|
|
||||||
imgs.forEach((img, i) => {
|
imgs.forEach((img, i) => {
|
||||||
// preload first 5 images on page load
|
|
||||||
if (i < 5) {
|
if (i < 5) {
|
||||||
img.src = img.dataset.loUrl
|
img.src = img.dataset.loUrl
|
||||||
}
|
}
|
||||||
// lores preloader for rest of the images
|
|
||||||
// eslint-disable-next-line solid/reactivity
|
|
||||||
onMutation(img, (mutation) => {
|
onMutation(img, (mutation) => {
|
||||||
// if open or animating, hold
|
if (desktop.isOpen() || desktop.isAnimating()) return false
|
||||||
if (props.isOpen() || props.isAnimating()) return false
|
|
||||||
// if mutation is not about style attribute, hold
|
|
||||||
if (mutation.attributeName !== 'style') return false
|
if (mutation.attributeName !== 'style') return false
|
||||||
|
|
||||||
const opacity = parseFloat(img.style.opacity)
|
const opacity = parseFloat(img.style.opacity)
|
||||||
// if opacity is not 1, hold
|
|
||||||
if (opacity !== 1) return false
|
if (opacity !== 1) return false
|
||||||
// preload the i + 5th image, if it exists
|
|
||||||
if (i + 5 < imgs.length) {
|
if (i + 5 < imgs.length) {
|
||||||
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
|
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
|
||||||
}
|
}
|
||||||
// triggered
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// load gsap on mousemove
|
|
||||||
window.addEventListener(
|
window.addEventListener('pointermove', () => void ensureGsapReady(), {
|
||||||
'mousemove',
|
passive: true,
|
||||||
() => {
|
once: true
|
||||||
loadGsap()
|
})
|
||||||
.then((g) => {
|
window.addEventListener('pointerdown', () => void ensureGsapReady(), {
|
||||||
_gsap = g
|
passive: true,
|
||||||
gsapLoaded = true
|
once: true
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
window.addEventListener('click', () => void ensureGsapReady(), {
|
||||||
console.log(e)
|
passive: true,
|
||||||
})
|
once: true
|
||||||
},
|
})
|
||||||
{ passive: true, once: true }
|
|
||||||
)
|
|
||||||
// event listeners
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
const abortSignal = abortController.signal
|
const abortSignal = abortController.signal
|
||||||
window.addEventListener('mousemove', onMouse, {
|
window.addEventListener('mousemove', onMouse, {
|
||||||
passive: true,
|
passive: true,
|
||||||
signal: abortSignal
|
signal: abortSignal
|
||||||
})
|
})
|
||||||
// mounted
|
|
||||||
mounted = true
|
mounted = true
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.cordHist(),
|
() => desktop.cordHist(),
|
||||||
() => {
|
() => {
|
||||||
setPosition()
|
setPosition()
|
||||||
},
|
},
|
||||||
@@ -413,36 +171,38 @@ export default function Stage(props: {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.isOpen(),
|
desktop.isOpen,
|
||||||
async () => {
|
async (isOpen) => {
|
||||||
if (props.isAnimating()) return
|
if (desktop.isAnimating()) return
|
||||||
if (props.isOpen()) {
|
|
||||||
// expand image
|
if (isOpen) {
|
||||||
|
if (desktop.index() < 0 || desktop.cordHist().length === 0) {
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await expandImage()
|
await expandImage()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
void 0
|
setIsOpen(false)
|
||||||
|
setIsAnimating(false)
|
||||||
|
setIsLoading(false)
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// abort controller for cleanup
|
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// minimize image
|
|
||||||
await minimizeImage()
|
await minimizeImage()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
void 0
|
void 0
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line solid/reactivity
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// event listeners and its abort controller
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
const abortSignal = abortController.signal
|
const abortSignal = abortController.signal
|
||||||
window.addEventListener('mousemove', onMouse, {
|
window.addEventListener('mousemove', onMouse, {
|
||||||
passive: true,
|
passive: true,
|
||||||
signal: abortSignal
|
signal: abortSignal
|
||||||
})
|
})
|
||||||
// cleanup isLoading
|
setIsLoading(false)
|
||||||
props.setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -453,7 +213,7 @@ export default function Stage(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="stage" onClick={onClick} onKeyDown={onClick}>
|
<div class="stage" onClick={onClick} onKeyDown={onClick}>
|
||||||
<For each={props.ijs}>
|
<For each={imageState().images}>
|
||||||
{(ij, i) => (
|
{(ij, i) => (
|
||||||
<img
|
<img
|
||||||
ref={imgs[i()]}
|
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 { useImageState } from '../imageState'
|
||||||
import { decrement, increment, type Vector } from '../utils'
|
import { decrement, increment } from '../utils'
|
||||||
|
|
||||||
import type { HistoryItem } from './layout'
|
import { useDesktopState } from './state'
|
||||||
|
|
||||||
export default function StageNav(props: {
|
export default function StageNav(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
prevText: string
|
prevText: string
|
||||||
closeText: string
|
closeText: string
|
||||||
nextText: 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 {
|
}): JSX.Element {
|
||||||
// types
|
// types
|
||||||
type NavItem = (typeof navItems)[number]
|
type NavItem = (typeof navItems)[number]
|
||||||
@@ -29,64 +20,74 @@ export default function StageNav(props: {
|
|||||||
const navItems = [props.prevText, props.closeText, props.nextText] as const
|
const navItems = [props.prevText, props.closeText, props.nextText] as const
|
||||||
|
|
||||||
// states
|
// 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 = () => {
|
const prevImage: () => void = () => {
|
||||||
props.setNavVector('prev')
|
setNavVector('prev')
|
||||||
props.setCordHist((c) =>
|
setCordHist((c) =>
|
||||||
c.map((item) => {
|
c.map((item) => {
|
||||||
return { ...item, i: decrement(item.i, stateLength) }
|
return { ...item, i: decrement(item.i, imageState().length) }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
decIndex()
|
decIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeImage: () => void = () => {
|
const closeImage: () => void = () => {
|
||||||
props.setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextImage: () => void = () => {
|
const nextImage: () => void = () => {
|
||||||
props.setNavVector('next')
|
setNavVector('next')
|
||||||
props.setCordHist((c) =>
|
setCordHist((c) =>
|
||||||
c.map((item) => {
|
c.map((item) => {
|
||||||
return { ...item, i: increment(item.i, stateLength) }
|
return { ...item, i: increment(item.i, imageState().length) }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
incIndex()
|
incIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick: (item: NavItem) => void = (item) => {
|
const handleClick: (item: NavItem) => void = (item) => {
|
||||||
if (!props.isOpen() || props.isAnimating()) return
|
if (!desktop.isOpen() || desktop.isAnimating()) return
|
||||||
if (item === navItems[0]) prevImage()
|
if (item === navItems[0]) prevImage()
|
||||||
else if (item === navItems[1]) closeImage()
|
else if (item === navItems[1]) closeImage()
|
||||||
else nextImage()
|
else nextImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKey: (e: KeyboardEvent) => void = (e) => {
|
const handleKey: (e: KeyboardEvent) => void = (e) => {
|
||||||
if (!props.isOpen() || props.isAnimating()) return
|
if (!desktop.isOpen() || desktop.isAnimating()) return
|
||||||
if (e.key === 'ArrowLeft') prevImage()
|
if (e.key === 'ArrowLeft') prevImage()
|
||||||
else if (e.key === 'Escape') closeImage()
|
else if (e.key === 'Escape') closeImage()
|
||||||
else if (e.key === 'ArrowRight') nextImage()
|
else if (e.key === 'ArrowRight') nextImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (props.isOpen()) {
|
on(desktop.isOpen, (isOpen) => {
|
||||||
controller = new AbortController()
|
|
||||||
const abortSignal = controller.signal
|
|
||||||
window.addEventListener('keydown', handleKey, {
|
|
||||||
passive: true,
|
|
||||||
signal: abortSignal
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
controller?.abort()
|
controller?.abort()
|
||||||
}
|
|
||||||
|
if (isOpen) {
|
||||||
|
controller = new AbortController()
|
||||||
|
const abortSignal = controller.signal
|
||||||
|
window.addEventListener('keydown', handleKey, {
|
||||||
|
passive: true,
|
||||||
|
signal: abortSignal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
controller?.abort()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="navOverlay" classList={{ active: props.active() }}>
|
<div class="navOverlay" classList={{ active: active() }}>
|
||||||
<For each={navItems}>
|
<For each={navItems}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
@@ -94,8 +95,8 @@ export default function StageNav(props: {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleClick(item)
|
handleClick(item)
|
||||||
}}
|
}}
|
||||||
onFocus={() => props.setHoverText(item)}
|
onFocus={() => setHoverText(item)}
|
||||||
onMouseOver={() => props.setHoverText(item)}
|
onMouseOver={() => setHoverText(item)}
|
||||||
tabIndex="-1"
|
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 {
|
import { Match, Show, Switch, createResource, lazy, type JSX } from 'solid-js'
|
||||||
Match,
|
|
||||||
Show,
|
|
||||||
Switch,
|
|
||||||
createEffect,
|
|
||||||
createResource,
|
|
||||||
createSignal,
|
|
||||||
lazy,
|
|
||||||
type JSX
|
|
||||||
} from 'solid-js'
|
|
||||||
import { render } from 'solid-js/web'
|
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 { getImageJSON } from './resources'
|
||||||
import { StateProvider } from './state'
|
|
||||||
|
|
||||||
import '../scss/style.scss'
|
import '../scss/style.scss'
|
||||||
|
|
||||||
@@ -35,6 +29,34 @@ const container = document.getElementsByClassName('container')[0] as Container
|
|||||||
const Desktop = lazy(async () => await import('./desktop/layout'))
|
const Desktop = lazy(async () => await import('./desktop/layout'))
|
||||||
const Mobile = lazy(async () => await import('./mobile/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 {
|
function Main(): JSX.Element {
|
||||||
// variables
|
// variables
|
||||||
const [ijs] = createResource(getImageJSON)
|
const [ijs] = createResource(getImageJSON)
|
||||||
@@ -47,41 +69,20 @@ function Main(): JSX.Element {
|
|||||||
const isWindowsDesktop = /windows nt/.test(ua)
|
const isWindowsDesktop = /windows nt/.test(ua)
|
||||||
const isMobile = isMobileUA || (hasTouchInput && hasTouchLayout && !isWindowsDesktop)
|
const isMobile = isMobileUA || (hasTouchInput && hasTouchLayout && !isWindowsDesktop)
|
||||||
|
|
||||||
// states
|
|
||||||
const [scrollable, setScollable] = createSignal(true)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (scrollable()) {
|
|
||||||
container.classList.remove('disableScroll')
|
|
||||||
} else {
|
|
||||||
container.classList.add('disableScroll')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={ijs.state === 'ready'}>
|
<Show when={ijs.state === 'ready'}>
|
||||||
<StateProvider length={ijs()?.length ?? 0}>
|
<ImageStateProvider images={ijs() ?? []}>
|
||||||
<Switch fallback={<div>Error</div>}>
|
<ConfigStateProvider>
|
||||||
<Match when={isMobile}>
|
<AppContent
|
||||||
<Mobile
|
isMobile={isMobile}
|
||||||
ijs={ijs() ?? []}
|
prevText={container.dataset.prev}
|
||||||
closeText={container.dataset.close}
|
closeText={container.dataset.close}
|
||||||
loadingText={container.dataset.loading}
|
nextText={container.dataset.next}
|
||||||
setScrollable={setScollable}
|
loadingText={container.dataset.loading}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</ConfigStateProvider>
|
||||||
<Match when={!isMobile}>
|
</ImageStateProvider>
|
||||||
<Desktop
|
|
||||||
ijs={ijs() ?? []}
|
|
||||||
prevText={container.dataset.prev}
|
|
||||||
closeText={container.dataset.close}
|
|
||||||
nextText={container.dataset.next}
|
|
||||||
loadingText={container.dataset.loading}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</StateProvider>
|
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import {
|
import { For, createEffect, on, onMount, type JSX } from 'solid-js'
|
||||||
For,
|
|
||||||
createEffect,
|
|
||||||
on,
|
|
||||||
onMount,
|
|
||||||
type Accessor,
|
|
||||||
type JSX,
|
|
||||||
type Setter
|
|
||||||
} from 'solid-js'
|
|
||||||
|
|
||||||
import type { ImageJSON } from '../resources'
|
import { useImageState } from '../imageState'
|
||||||
import { useState } from '../state'
|
|
||||||
|
|
||||||
import type { MobileImage } from './layout'
|
import type { MobileImage } from './layout'
|
||||||
|
import { useMobileState } from './state'
|
||||||
|
|
||||||
function getRandom(min: number, max: number): number {
|
function getRandom(min: number, max: number): number {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
@@ -31,29 +23,26 @@ function onIntersection<T extends HTMLElement>(
|
|||||||
}).observe(element)
|
}).observe(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Collection(props: {
|
export default function Collection(): JSX.Element {
|
||||||
children?: JSX.Element
|
|
||||||
ijs: ImageJSON[]
|
|
||||||
isAnimating: Accessor<boolean>
|
|
||||||
isOpen: Accessor<boolean>
|
|
||||||
setIsOpen: Setter<boolean>
|
|
||||||
}): JSX.Element {
|
|
||||||
// variables
|
// variables
|
||||||
// eslint-disable-next-line solid/reactivity
|
const imageState = useImageState()
|
||||||
const imgs: MobileImage[] = Array<MobileImage>(props.ijs.length)
|
const imgs: MobileImage[] = Array<MobileImage>(imageState().length)
|
||||||
|
|
||||||
// states
|
// states
|
||||||
const [state, { setIndex }] = useState()
|
const [mobile, { setIndex, setIsOpen }] = useMobileState()
|
||||||
|
|
||||||
// helper functions
|
// helper functions
|
||||||
const handleClick: (i: number) => void = (i) => {
|
const handleClick: (i: number) => void = (i) => {
|
||||||
if (props.isAnimating()) return
|
if (mobile.isAnimating()) return
|
||||||
setIndex(i)
|
setIndex(i)
|
||||||
props.setIsOpen(true)
|
setIsOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToActive: () => void = () => {
|
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
|
// effects
|
||||||
@@ -94,11 +83,9 @@ export default function Collection(props: {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
mobile.isOpen,
|
||||||
() => {
|
() => {
|
||||||
props.isOpen()
|
if (!mobile.isOpen()) scrollToActive() // scroll to active when closed
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!props.isOpen()) scrollToActive() // scroll to active when closed
|
|
||||||
},
|
},
|
||||||
{ defer: true }
|
{ defer: true }
|
||||||
)
|
)
|
||||||
@@ -107,7 +94,7 @@ export default function Collection(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
<For each={props.ijs}>
|
<For each={imageState().images}>
|
||||||
{(ij, i) => (
|
{(ij, i) => (
|
||||||
<img
|
<img
|
||||||
ref={imgs[i()]}
|
ref={imgs[i()]}
|
||||||
|
|||||||
@@ -1,209 +1,170 @@
|
|||||||
import { type gsap } from 'gsap'
|
import { type gsap } from 'gsap'
|
||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
untrack,
|
||||||
type Accessor,
|
type JSX
|
||||||
type JSX,
|
|
||||||
type Setter
|
|
||||||
} from 'solid-js'
|
} from 'solid-js'
|
||||||
import { createStore } from 'solid-js/store'
|
import { createStore } from 'solid-js/store'
|
||||||
import { type Swiper } from 'swiper'
|
import { type Swiper } from 'swiper'
|
||||||
import invariant from 'tiny-invariant'
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
import { type ImageJSON } from '../resources'
|
import { useImageState } from '../imageState'
|
||||||
import { useState } from '../state'
|
import { loadGsap, removeDuplicates, type Vector } from '../utils'
|
||||||
import { loadGsap, type Vector } from '../utils'
|
|
||||||
|
|
||||||
import GalleryImage from './galleryImage'
|
import GalleryImage from './galleryImage'
|
||||||
import GalleryNav, { capitalizeFirstLetter } from './galleryNav'
|
import GalleryNav, { capitalizeFirstLetter } from './galleryNav'
|
||||||
|
import { closeGallery, openGallery } from './galleryTransitions'
|
||||||
function removeDuplicates<T>(arr: T[]): T[] {
|
import { getActiveImageIndexes, loadSwiper } from './galleryUtils'
|
||||||
if (arr.length < 2) return arr // optimization
|
import { useMobileState } from './state'
|
||||||
return [...new Set(arr)]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSwiper(): Promise<typeof Swiper> {
|
|
||||||
const s = await import('swiper')
|
|
||||||
return s.Swiper
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Gallery(props: {
|
export default function Gallery(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
ijs: ImageJSON[]
|
|
||||||
closeText: string
|
closeText: string
|
||||||
loadingText: string
|
loadingText: string
|
||||||
isAnimating: Accessor<boolean>
|
|
||||||
setIsAnimating: Setter<boolean>
|
|
||||||
isOpen: Accessor<boolean>
|
|
||||||
setIsOpen: Setter<boolean>
|
|
||||||
setScrollable: Setter<boolean>
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
// variables
|
|
||||||
let _gsap: typeof gsap
|
let _gsap: typeof gsap
|
||||||
let _swiper: Swiper
|
let _swiper: Swiper | undefined
|
||||||
|
let initPromise: Promise<void> | undefined
|
||||||
|
|
||||||
let curtain: HTMLDivElement | undefined
|
let curtain: HTMLDivElement | undefined
|
||||||
let gallery: HTMLDivElement | undefined
|
let gallery: HTMLDivElement | undefined
|
||||||
let galleryInner: HTMLDivElement | undefined
|
let galleryInner: HTMLDivElement | undefined
|
||||||
|
|
||||||
// eslint-disable-next-line solid/reactivity
|
const imageState = useImageState()
|
||||||
const _loadingText = capitalizeFirstLetter(props.loadingText)
|
const [mobile, { setIndex, setIsAnimating, setIsScrollLocked }] = useMobileState()
|
||||||
|
|
||||||
|
const loadingText = createMemo(() => capitalizeFirstLetter(props.loadingText))
|
||||||
|
|
||||||
// states
|
|
||||||
let lastIndex = -1
|
let lastIndex = -1
|
||||||
let mounted = false
|
let mounted = false
|
||||||
let navigateVector: Vector = 'none'
|
let navigateVector: Vector = 'none'
|
||||||
|
|
||||||
const [state, { setIndex }] = useState()
|
|
||||||
const [libLoaded, setLibLoaded] = createSignal(false)
|
const [libLoaded, setLibLoaded] = createSignal(false)
|
||||||
// eslint-disable-next-line solid/reactivity
|
const [swiperReady, setSwiperReady] = createSignal(false)
|
||||||
const [loads, setLoads] = createStore(Array<boolean>(props.ijs.length).fill(false))
|
const [loads, setLoads] = createStore(Array<boolean>(imageState().length).fill(false))
|
||||||
|
|
||||||
// helper functions
|
|
||||||
const slideUp: () => void = () => {
|
const slideUp: () => void = () => {
|
||||||
// isAnimating is prechecked in isOpen effect
|
|
||||||
if (!libLoaded() || !mounted) return
|
if (!libLoaded() || !mounted) return
|
||||||
props.setIsAnimating(true)
|
|
||||||
|
|
||||||
invariant(curtain, 'curtain is not defined')
|
invariant(curtain, 'curtain is not defined')
|
||||||
invariant(gallery, 'gallery is not defined')
|
invariant(gallery, 'gallery is not defined')
|
||||||
|
|
||||||
_gsap.to(curtain, {
|
openGallery({
|
||||||
opacity: 1,
|
gsap: _gsap,
|
||||||
duration: 1
|
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 = () => {
|
const slideDown: () => void = () => {
|
||||||
// isAnimating is prechecked in isOpen effect
|
|
||||||
props.setIsAnimating(true)
|
|
||||||
|
|
||||||
invariant(gallery, 'curtain is not defined')
|
invariant(gallery, 'curtain is not defined')
|
||||||
invariant(curtain, 'gallery is not defined')
|
invariant(curtain, 'gallery is not defined')
|
||||||
|
|
||||||
_gsap.to(gallery, {
|
closeGallery({
|
||||||
y: '100%',
|
gsap: _gsap,
|
||||||
ease: 'power3.inOut',
|
curtain,
|
||||||
duration: 1
|
gallery,
|
||||||
|
setIsAnimating,
|
||||||
|
setIsScrollLocked,
|
||||||
|
onClosed: () => {
|
||||||
|
lastIndex = -1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
_gsap.to(curtain, {
|
|
||||||
opacity: 0,
|
|
||||||
duration: 1.2,
|
|
||||||
delay: 0.4
|
|
||||||
})
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// cleanup
|
|
||||||
props.setScrollable(true)
|
|
||||||
props.setIsAnimating(false)
|
|
||||||
lastIndex = -1
|
|
||||||
}, 1400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const galleryLoadImages: () => void = () => {
|
const galleryLoadImages: () => void = () => {
|
||||||
let activeImagesIndex: number[] = []
|
const currentIndex = mobile.index()
|
||||||
const _state = state()
|
|
||||||
const currentIndex = _state.index
|
setLoads(
|
||||||
const nextIndex = Math.min(currentIndex + 1, _state.length - 1)
|
removeDuplicates(
|
||||||
const prevIndex = Math.max(currentIndex - 1, 0)
|
getActiveImageIndexes(currentIndex, imageState().length, navigateVector)
|
||||||
switch (navigateVector) {
|
),
|
||||||
case 'next':
|
true
|
||||||
activeImagesIndex = [nextIndex]
|
)
|
||||||
break
|
|
||||||
case 'prev':
|
|
||||||
activeImagesIndex = [prevIndex]
|
|
||||||
break
|
|
||||||
case 'none':
|
|
||||||
activeImagesIndex = [currentIndex, nextIndex, prevIndex]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setLoads(removeDuplicates(activeImagesIndex), true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeSlide: (slide: number) => void = (slide) => {
|
const changeSlide: (slide: number) => void = (slide) => {
|
||||||
// we are already in the gallery, don't need to
|
if (!swiperReady() || _swiper === undefined) return
|
||||||
// check mounted or libLoaded
|
|
||||||
galleryLoadImages()
|
galleryLoadImages()
|
||||||
_swiper.slideTo(slide, 0)
|
_swiper.slideTo(slide, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// effects
|
const ensureGalleryReady: () => Promise<void> = async () => {
|
||||||
onMount(() => {
|
if (initPromise !== undefined) return await initPromise
|
||||||
window.addEventListener(
|
|
||||||
'touchstart',
|
initPromise = (async () => {
|
||||||
() => {
|
try {
|
||||||
loadGsap()
|
const [g, S] = await Promise.all([loadGsap(), loadSwiper()])
|
||||||
.then((g) => {
|
|
||||||
_gsap = g
|
_gsap = g
|
||||||
})
|
|
||||||
.catch((e) => {
|
invariant(galleryInner, 'galleryInner is not defined')
|
||||||
console.log(e)
|
_swiper = new S(galleryInner, { spaceBetween: 20 })
|
||||||
})
|
_swiper.on('slideChange', ({ realIndex }) => {
|
||||||
loadSwiper()
|
setIndex(realIndex)
|
||||||
.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)
|
setLibLoaded(true)
|
||||||
},
|
setSwiperReady(true)
|
||||||
{ once: true, passive: 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
|
mounted = true
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => {
|
() => [swiperReady(), mobile.index()] as const,
|
||||||
state()
|
([ready, index]) => {
|
||||||
},
|
if (!ready || index < 0) return
|
||||||
() => {
|
if (index === lastIndex) return
|
||||||
const i = state().index
|
if (lastIndex === -1) navigateVector = 'none'
|
||||||
if (i === lastIndex)
|
else if (index < lastIndex) navigateVector = 'prev'
|
||||||
return // change slide only when index is changed
|
else if (index > lastIndex) navigateVector = 'next'
|
||||||
else if (lastIndex === -1)
|
else navigateVector = 'none'
|
||||||
navigateVector = 'none' // lastIndex before set
|
changeSlide(index)
|
||||||
else if (i < lastIndex)
|
lastIndex = index
|
||||||
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
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => {
|
() => mobile.isOpen(),
|
||||||
props.isOpen()
|
async (isOpen) => {
|
||||||
},
|
if (isOpen && !swiperReady()) {
|
||||||
() => {
|
await ensureGalleryReady()
|
||||||
if (props.isAnimating()) return
|
}
|
||||||
if (props.isOpen()) slideUp()
|
|
||||||
|
if (!libLoaded() || !swiperReady()) return
|
||||||
|
if (mobile.isAnimating()) return
|
||||||
|
if (isOpen) slideUp()
|
||||||
else slideDown()
|
else slideDown()
|
||||||
},
|
},
|
||||||
{ defer: true }
|
{ defer: true }
|
||||||
@@ -215,26 +176,16 @@ export default function Gallery(props: {
|
|||||||
<div ref={gallery} class="gallery">
|
<div ref={gallery} class="gallery">
|
||||||
<div ref={galleryInner} class="galleryInner">
|
<div ref={galleryInner} class="galleryInner">
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
<Show when={libLoaded()}>
|
<For each={imageState().images}>
|
||||||
<For each={props.ijs}>
|
{(ij, i) => (
|
||||||
{(ij, i) => (
|
<div class="swiper-slide">
|
||||||
<div class="swiper-slide">
|
<GalleryImage load={loads[i()]} ij={ij} loadingText={loadingText()} />
|
||||||
<GalleryImage
|
</div>
|
||||||
load={loads[i()]}
|
)}
|
||||||
ij={ij}
|
</For>
|
||||||
loadingText={_loadingText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GalleryNav
|
<GalleryNav closeText={props.closeText} />
|
||||||
closeText={props.closeText}
|
|
||||||
isAnimating={props.isAnimating}
|
|
||||||
setIsOpen={props.setIsOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref={curtain} class="curtain" />
|
<div ref={curtain} class="curtain" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { createEffect, on, onMount, type JSX } from 'solid-js'
|
|||||||
import invariant from 'tiny-invariant'
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
import type { ImageJSON } from '../resources'
|
import type { ImageJSON } from '../resources'
|
||||||
import { useState } from '../state'
|
|
||||||
import { loadGsap } from '../utils'
|
import { loadGsap } from '../utils'
|
||||||
|
|
||||||
|
import { useMobileState } from './state'
|
||||||
|
|
||||||
export default function GalleryImage(props: {
|
export default function GalleryImage(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
load: boolean
|
load: boolean
|
||||||
@@ -19,7 +20,7 @@ export default function GalleryImage(props: {
|
|||||||
let gsapPromise: Promise<typeof gsap> | undefined
|
let gsapPromise: Promise<typeof gsap> | undefined
|
||||||
let revealed = false
|
let revealed = false
|
||||||
|
|
||||||
const [state] = useState()
|
const [mobile] = useMobileState()
|
||||||
|
|
||||||
const revealImage = async (): Promise<void> => {
|
const revealImage = async (): Promise<void> => {
|
||||||
if (revealed) return
|
if (revealed) return
|
||||||
@@ -42,7 +43,7 @@ export default function GalleryImage(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state().index !== props.ij.index) {
|
if (mobile.index() !== props.ij.index) {
|
||||||
_gsap.set(img, { opacity: 1 })
|
_gsap.set(img, { opacity: 1 })
|
||||||
_gsap.set(loadingDiv, { opacity: 0 })
|
_gsap.set(loadingDiv, { opacity: 0 })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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 { expand } from '../utils'
|
||||||
|
|
||||||
|
import { useMobileState } from './state'
|
||||||
|
|
||||||
export function capitalizeFirstLetter(str: string): string {
|
export function capitalizeFirstLetter(str: string): string {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
}
|
}
|
||||||
@@ -10,17 +12,16 @@ export function capitalizeFirstLetter(str: string): string {
|
|||||||
export default function GalleryNav(props: {
|
export default function GalleryNav(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
closeText: string
|
closeText: string
|
||||||
isAnimating: Accessor<boolean>
|
|
||||||
setIsOpen: Setter<boolean>
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
// states
|
// states
|
||||||
const [state] = useState()
|
const imageState = useImageState()
|
||||||
const indexValue = createMemo(() => expand(state().index + 1))
|
const [mobile, { setIsOpen }] = useMobileState()
|
||||||
const indexLength = createMemo(() => expand(state().length))
|
const indexValue = createMemo(() => expand(mobile.index() + 1))
|
||||||
|
const indexLength = createMemo(() => expand(imageState().length))
|
||||||
|
|
||||||
const onClick: () => void = () => {
|
const onClick: () => void = () => {
|
||||||
if (props.isAnimating()) return
|
if (mobile.isAnimating()) return
|
||||||
props.setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +38,14 @@ export default function GalleryNav(props: {
|
|||||||
<span class="num">{indexLength()[2]}</span>
|
<span class="num">{indexLength()[2]}</span>
|
||||||
<span class="num">{indexLength()[3]}</span>
|
<span class="num">{indexLength()[3]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="navClose" onClick={onClick} onKeyDown={onClick}>
|
<div
|
||||||
|
class="navClose"
|
||||||
|
onClick={onClick}
|
||||||
|
onTouchEnd={onClick}
|
||||||
|
onKeyDown={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex="0"
|
||||||
|
>
|
||||||
{capitalizeFirstLetter(props.closeText)}
|
{capitalizeFirstLetter(props.closeText)}
|
||||||
</div>
|
</div>
|
||||||
</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 Collection from './collection'
|
||||||
import Gallery from './gallery'
|
import Gallery from './gallery'
|
||||||
|
import { useMobileState } from './state'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* interfaces
|
* interfaces
|
||||||
@@ -18,34 +19,33 @@ export interface MobileImage extends HTMLImageElement {
|
|||||||
|
|
||||||
export default function Mobile(props: {
|
export default function Mobile(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
ijs: ImageJSON[]
|
|
||||||
closeText: string
|
closeText: string
|
||||||
loadingText: string
|
loadingText: string
|
||||||
setScrollable: Setter<boolean>
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
// states
|
const imageState = useImageState()
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
const [mobile] = useMobileState()
|
||||||
const [isAnimating, setIsAnimating] = createSignal(false)
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={props.ijs.length > 0}>
|
<Show when={imageState().length > 0}>
|
||||||
<Collection
|
<Collection />
|
||||||
ijs={props.ijs}
|
<Gallery closeText={props.closeText} loadingText={props.loadingText} />
|
||||||
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>
|
</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