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:
Sped0n
2026-03-22 19:45:05 +08:00
parent 1c386386f3
commit f25b71a858
20 changed files with 1165 additions and 894 deletions

View File

@@ -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()]}

View File

@@ -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
closeGallery({
gsap: _gsap,
curtain,
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 = () => {
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) => {
_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)
})
const ensureGalleryReady: () => Promise<void> = async () => {
if (initPromise !== undefined) return await initPromise
initPromise = (async () => {
try {
const [g, S] = await Promise.all([loadGsap(), loadSwiper()])
_gsap = g
invariant(galleryInner, 'galleryInner is not defined')
_swiper = new S(galleryInner, { spaceBetween: 20 })
_swiper.on('slideChange', ({ realIndex }) => {
setIndex(realIndex)
})
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}>
{(ij, i) => (
<div class="swiper-slide">
<GalleryImage
load={loads[i()]}
ij={ij}
loadingText={_loadingText}
/>
</div>
)}
</For>
</Show>
<For each={imageState().images}>
{(ij, i) => (
<div class="swiper-slide">
<GalleryImage load={loads[i()]} ij={ij} loadingText={loadingText()} />
</div>
)}
</For>
</div>
</div>
<GalleryNav
closeText={props.closeText}
isAnimating={props.isAnimating}
setIsOpen={props.setIsOpen}
/>
<GalleryNav closeText={props.closeText} />
</div>
<div ref={curtain} class="curtain" />
</>

View File

@@ -3,9 +3,10 @@ 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
@@ -19,7 +20,7 @@ export default function GalleryImage(props: {
let gsapPromise: Promise<typeof gsap> | undefined
let revealed = false
const [state] = useState()
const [mobile] = useMobileState()
const revealImage = async (): Promise<void> => {
if (revealed) return
@@ -42,7 +43,7 @@ export default function GalleryImage(props: {
return
}
if (state().index !== props.ij.index) {
if (mobile.index() !== props.ij.index) {
_gsap.set(img, { opacity: 1 })
_gsap.set(loadingDiv, { opacity: 0 })
return

View File

@@ -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>

View 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)
}

View 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]
}
}

View File

@@ -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
View 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
}