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

@@ -4,7 +4,6 @@ export default function CustomCursor(props: {
children?: JSX.Element
active: Accessor<boolean>
cursorText: Accessor<string>
isOpen: Accessor<boolean>
}): JSX.Element {
// types
interface XY {

View File

@@ -1,12 +1,12 @@
import { Show, createMemo, createSignal, type JSX } from 'solid-js'
import { Show, createMemo, type JSX } from 'solid-js'
import type { ImageJSON } from '../resources'
import type { Vector } from '../utils'
import { useImageState } from '../imageState'
import CustomCursor from './customCursor'
import Nav from './nav'
import Stage from './stage'
import StageNav from './stageNav'
import { useDesktopState } from './state'
/**
* interfaces and types
@@ -23,65 +23,36 @@ export interface DesktopImage extends HTMLImageElement {
}
}
export interface HistoryItem {
i: number
x: number
y: number
}
/**
* components
*/
export default function Desktop(props: {
children?: JSX.Element
ijs: ImageJSON[]
prevText: string
closeText: string
nextText: string
loadingText: string
}): JSX.Element {
const [cordHist, setCordHist] = createSignal<HistoryItem[]>([])
const [isLoading, setIsLoading] = createSignal(false)
const [isOpen, setIsOpen] = createSignal(false)
const [isAnimating, setIsAnimating] = createSignal(false)
const [hoverText, setHoverText] = createSignal('')
const [navVector, setNavVector] = createSignal<Vector>('none')
const imageState = useImageState()
const [desktop] = useDesktopState()
const active = createMemo(() => isOpen() && !isAnimating())
const cursorText = createMemo(() => (isLoading() ? props.loadingText : hoverText()))
const active = createMemo(() => desktop.isOpen() && !desktop.isAnimating())
const cursorText = createMemo(() =>
desktop.isLoading() ? props.loadingText : desktop.hoverText()
)
return (
<>
<Nav />
<Show when={props.ijs.length > 0}>
<Stage
ijs={props.ijs}
setIsLoading={setIsLoading}
isOpen={isOpen}
setIsOpen={setIsOpen}
isAnimating={isAnimating}
setIsAnimating={setIsAnimating}
cordHist={cordHist}
setCordHist={setCordHist}
navVector={navVector}
setNavVector={setNavVector}
/>
<Show when={isOpen()}>
<CustomCursor cursorText={cursorText} active={active} isOpen={isOpen} />
<Show when={imageState().length > 0}>
<Stage />
<Show when={desktop.isOpen()}>
<CustomCursor cursorText={cursorText} active={active} />
<StageNav
prevText={props.prevText}
closeText={props.closeText}
nextText={props.nextText}
loadingText={props.loadingText}
active={active}
isAnimating={isAnimating}
setCordHist={setCordHist}
isOpen={isOpen}
setIsOpen={setIsOpen}
setHoverText={setHoverText}
navVector={navVector}
setNavVector={setNavVector}
/>
</Show>
</Show>

View File

@@ -1,66 +1,68 @@
import { createEffect } from 'solid-js'
import { createEffect, onCleanup, onMount } from 'solid-js'
import { useState } from '../state'
import { useConfigState } from '../configState'
import { useImageState } from '../imageState'
import { expand } from '../utils'
/**
* constants
*/
// threshold div
const thresholdDiv = document.getElementsByClassName('threshold')[0] as HTMLDivElement
// threshold nums span
const thresholdDispNums = Array.from(
thresholdDiv.getElementsByClassName('num')
) as HTMLSpanElement[]
// threshold buttons
const decButton = thresholdDiv
.getElementsByClassName('dec')
.item(0) as HTMLButtonElement
const incButton = thresholdDiv
.getElementsByClassName('inc')
.item(0) as HTMLButtonElement
// index div
const indexDiv = document.getElementsByClassName('index').item(0) as HTMLDivElement
// index nums span
const indexDispNums = Array.from(
indexDiv.getElementsByClassName('num')
) as HTMLSpanElement[]
/**
* helper functions
*/
function updateThresholdText(thresholdValue: string): void {
thresholdDispNums.forEach((e: HTMLSpanElement, i: number) => {
e.innerText = thresholdValue[i]
})
}
function updateIndexText(indexValue: string, indexLength: string): void {
indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
if (i < 4) {
e.innerText = indexValue[i]
} else {
e.innerText = indexLength[i - 4]
}
})
}
/**
* Nav component
*/
import { useDesktopState } from './state'
export default function Nav(): null {
const [state, { incThreshold, decThreshold }] = useState()
let thresholdNums: HTMLSpanElement[] = []
let indexNums: HTMLSpanElement[] = []
let decButton: HTMLButtonElement | undefined
let incButton: HTMLButtonElement | undefined
let controller: AbortController | undefined
createEffect(() => {
updateIndexText(expand(state().index + 1), expand(state().length))
updateThresholdText(expand(state().threshold))
const imageState = useImageState()
const [config, { incThreshold, decThreshold }] = useConfigState()
const [desktop] = useDesktopState()
const updateThresholdText = (thresholdValue: string): void => {
thresholdNums.forEach((element, i) => {
element.innerText = thresholdValue[i]
})
}
const updateIndexText = (indexValue: string, indexLength: string): void => {
indexNums.forEach((element, i) => {
if (i < 4) {
element.innerText = indexValue[i]
} else {
element.innerText = indexLength[i - 4]
}
})
}
onMount(() => {
const thresholdDiv = document.getElementsByClassName(
'threshold'
)[0] as HTMLDivElement
const indexDiv = document.getElementsByClassName('index').item(0) as HTMLDivElement
thresholdNums = Array.from(
thresholdDiv.getElementsByClassName('num')
) as HTMLSpanElement[]
indexNums = Array.from(indexDiv.getElementsByClassName('num')) as HTMLSpanElement[]
decButton = thresholdDiv.getElementsByClassName('dec').item(0) as HTMLButtonElement
incButton = thresholdDiv.getElementsByClassName('inc').item(0) as HTMLButtonElement
controller = new AbortController()
const signal = controller.signal
decButton.addEventListener('click', decThreshold, { signal })
incButton.addEventListener('click', incThreshold, { signal })
})
decButton.onclick = decThreshold
incButton.onclick = incThreshold
createEffect(() => {
if (thresholdNums.length === 0 || indexNums.length === 0) return
updateIndexText(expand(desktop.index() + 1), expand(imageState().length))
updateThresholdText(expand(config().threshold))
})
onCleanup(() => {
controller?.abort()
})
return null
}

View File

@@ -1,409 +1,167 @@
import { type gsap } from 'gsap'
import {
For,
createEffect,
on,
onMount,
type Accessor,
type JSX,
type Setter
} from 'solid-js'
import { For, createEffect, on, onMount, type JSX } from 'solid-js'
import type { ImageJSON } from '../resources'
import { useState, type State } from '../state'
import { decrement, increment, loadGsap, type Vector } from '../utils'
import { useConfigState } from '../configState'
import { useImageState } from '../imageState'
import { increment, loadGsap } from '../utils'
import type { DesktopImage, HistoryItem } from './layout'
import type { DesktopImage } from './layout'
import { expandStage, minimizeStage, syncStagePosition } from './stageAnimations'
import { onMutation } from './stageUtils'
import { useDesktopState } from './state'
/**
* helper functions
*/
function getTrailElsIndex(cordHistValue: HistoryItem[]): number[] {
return cordHistValue.map((el) => el.i)
}
function getTrailCurrentElsIndex(
cordHistValue: HistoryItem[],
stateValue: State
): number[] {
return getTrailElsIndex(cordHistValue).slice(-stateValue.trailLength)
}
function getTrailInactiveElsIndex(
cordHistValue: HistoryItem[],
stateValue: State
): number[] {
return getTrailCurrentElsIndex(cordHistValue, stateValue).slice(0, -1)
}
function getCurrentElIndex(cordHistValue: HistoryItem[]): number {
return getTrailElsIndex(cordHistValue).slice(-1)[0]
}
function getPrevElIndex(cordHistValue: HistoryItem[], stateValue: State): number {
return decrement(cordHistValue.slice(-1)[0].i, stateValue.length)
}
function getNextElIndex(cordHistValue: HistoryItem[], stateValue: State): number {
return increment(cordHistValue.slice(-1)[0].i, stateValue.length)
}
function getImagesFromIndexes(imgs: DesktopImage[], indexes: number[]): DesktopImage[] {
return indexes.map((i) => imgs[i])
}
function hires(imgs: DesktopImage[]): void {
imgs.forEach((img) => {
if (img.src === img.dataset.hiUrl) return
img.src = img.dataset.hiUrl
img.height = parseInt(img.dataset.hiImgH)
img.width = parseInt(img.dataset.hiImgW)
})
}
function lores(imgs: DesktopImage[]): void {
imgs.forEach((img) => {
if (img.src === img.dataset.loUrl) return
img.src = img.dataset.loUrl
img.height = parseInt(img.dataset.loImgH)
img.width = parseInt(img.dataset.loImgW)
})
}
function onMutation<T extends HTMLElement>(
element: T,
trigger: (arg0: MutationRecord) => boolean,
observeOptions: MutationObserverInit = { attributes: true }
): void {
new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
if (trigger(mutation)) {
observer.disconnect()
break
}
}
}).observe(element, observeOptions)
}
/**
* Stage component
*/
export default function Stage(props: {
ijs: ImageJSON[]
setIsLoading: Setter<boolean>
isOpen: Accessor<boolean>
setIsOpen: Setter<boolean>
isAnimating: Accessor<boolean>
setIsAnimating: Setter<boolean>
cordHist: Accessor<HistoryItem[]>
setCordHist: Setter<HistoryItem[]>
navVector: Accessor<Vector>
setNavVector: Setter<Vector>
}): JSX.Element {
// variables
export default function Stage(): JSX.Element {
let _gsap: typeof gsap
let gsapPromise: Promise<void> | undefined
// eslint-disable-next-line solid/reactivity
const imgs: DesktopImage[] = Array<DesktopImage>(props.ijs.length)
const imageState = useImageState()
const [config] = useConfigState()
const [
desktop,
{ setIndex, setCordHist, setIsOpen, setIsAnimating, setIsLoading, setNavVector }
] = useDesktopState()
const imgs: DesktopImage[] = Array<DesktopImage>(imageState().length)
let last = { x: 0, y: 0 }
let abortController: AbortController | undefined
// states
let gsapLoaded = false
const [state, { incIndex }] = useState()
const stateLength = state().length
let mounted = false
const 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) => {
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 travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
if (travelDist > state().threshold) {
last = cord
incIndex()
if (travelDist > config().threshold) {
const nextIndex = increment(desktop.index(), length)
const _state = state()
const newHist = { i: _state.index, ...cord }
props.setCordHist((prev) => [...prev, newHist].slice(-stateLength))
last = cord
setIndex(nextIndex)
setCordHist((prev) => [...prev, { i: nextIndex, ...cord }].slice(-length))
}
}
const onClick: () => void = () => {
if (!props.isAnimating()) props.setIsOpen(true)
const onClick: () => Promise<void> = async () => {
if (!gsapLoaded) {
await ensureGsapReady()
}
if (desktop.isAnimating() || !gsapLoaded) return
if (desktop.index() < 0 || desktop.cordHist().length === 0) return
setIsOpen(true)
}
const setPosition: () => void = () => {
if (!mounted) return
if (imgs.length === 0) return
const _cordHist = props.cordHist()
const trailElsIndex = getTrailElsIndex(_cordHist)
if (trailElsIndex.length === 0) return
const elsTrail = getImagesFromIndexes(imgs, trailElsIndex)
const _isOpen = props.isOpen()
const _state = state()
_gsap.set(elsTrail, {
x: (i: number) => _cordHist[i].x - window.innerWidth / 2,
y: (i: number) => _cordHist[i].y - window.innerHeight / 2,
opacity: (i: number) =>
Math.max(
(i + 1 + _state.trailLength <= _cordHist.length ? 0 : 1) - (_isOpen ? 1 : 0),
0
),
zIndex: (i: number) => i,
scale: 0.6
})
if (_isOpen) {
const elc = getImagesFromIndexes(imgs, [getCurrentElIndex(_cordHist)])[0]
const indexArrayToHires: number[] = []
const indexArrayToCleanup: number[] = []
switch (props.navVector()) {
case 'prev':
indexArrayToHires.push(getPrevElIndex(_cordHist, _state))
indexArrayToCleanup.push(getNextElIndex(_cordHist, _state))
break
case 'next':
indexArrayToHires.push(getNextElIndex(_cordHist, _state))
indexArrayToCleanup.push(getPrevElIndex(_cordHist, _state))
break
default:
break
}
hires(getImagesFromIndexes(imgs, indexArrayToHires)) // preload
_gsap.set(getImagesFromIndexes(imgs, indexArrayToCleanup), { opacity: 0 })
_gsap.set(elc, { x: 0, y: 0, scale: 1 }) // set current to center
setLoaderForHiresImage(elc) // set loader, if loaded set current opacity to 1
} else {
lores(elsTrail)
}
}
const expandImage: () => Promise<
gsap.core.Omit<gsap.core.Timeline, 'then'>
> = async () => {
// isAnimating is prechecked in isOpen effect
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
props.setIsAnimating(true)
const _cordHist = props.cordHist()
const _state = state()
const elcIndex = getCurrentElIndex(_cordHist)
const elc = imgs[elcIndex]
// don't hide here because we want a better transition
hires(
getImagesFromIndexes(imgs, [
elcIndex,
getPrevElIndex(_cordHist, _state),
getNextElIndex(_cordHist, _state)
])
)
setLoaderForHiresImage(elc)
const tl = _gsap.timeline()
const trailInactiveEls = getImagesFromIndexes(
syncStagePosition({
gsap: _gsap,
imgs,
getTrailInactiveElsIndex(_cordHist, _state)
)
// move down and hide trail inactive
tl.to(trailInactiveEls, {
y: '+=20',
ease: 'power3.in',
stagger: 0.075,
duration: 0.3,
delay: 0.1,
opacity: 0
})
// current move to center
tl.to(elc, {
x: 0,
y: 0,
ease: 'power3.inOut',
duration: 0.7,
delay: 0.3
})
// current expand
tl.to(elc, {
delay: 0.1,
scale: 1,
ease: 'power3.inOut'
})
// finished
// eslint-disable-next-line solid/reactivity
return await tl.then(() => {
props.setIsAnimating(false)
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
length: imageState().length,
isOpen: desktop.isOpen(),
navVector: desktop.navVector(),
mounted,
setIsLoading
})
}
const minimizeImage: () => Promise<
gsap.core.Omit<gsap.core.Timeline, 'then'>
> = async () => {
const expandImage: () => Promise<void> = async () => {
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
props.setIsAnimating(true)
props.setNavVector('none') // cleanup
const _cordHist = props.cordHist()
const _state = state()
const elcIndex = getCurrentElIndex(_cordHist)
const elsTrailInactiveIndexes = getTrailInactiveElsIndex(_cordHist, _state)
lores(getImagesFromIndexes(imgs, [...elsTrailInactiveIndexes, elcIndex]))
const tl = _gsap.timeline()
const elc = getImagesFromIndexes(imgs, [elcIndex])[0]
const elsTrailInactive = getImagesFromIndexes(imgs, elsTrailInactiveIndexes)
// shrink current
tl.to(elc, {
scale: 0.6,
duration: 0.6,
ease: 'power3.inOut'
})
// move current to original position
tl.to(elc, {
delay: 0.3,
duration: 0.7,
ease: 'power3.inOut',
x: _cordHist.slice(-1)[0].x - window.innerWidth / 2,
y: _cordHist.slice(-1)[0].y - window.innerHeight / 2
})
// show trail inactive
tl.to(elsTrailInactive, {
y: '-=20',
ease: 'power3.out',
stagger: -0.1,
duration: 0.3,
opacity: 1
})
// eslint-disable-next-line solid/reactivity
return await tl.then(() => {
props.setIsAnimating(false)
await expandStage({
gsap: _gsap,
imgs,
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
length: imageState().length,
mounted,
setIsLoading,
setIsAnimating
})
}
function setLoaderForHiresImage(img: DesktopImage): void {
if (!mounted || !gsapLoaded) return
if (!img.complete) {
props.setIsLoading(true)
// abort controller for cleanup
const controller = new AbortController()
const abortSignal = controller.signal
// event listeners
img.addEventListener(
'load',
() => {
_gsap
.to(img, { opacity: 1, ease: 'power3.out', duration: 0.5 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
.finally(() => {
controller.abort()
})
},
{ once: true, passive: true, signal: abortSignal }
)
img.addEventListener(
'error',
() => {
_gsap
.set(img, { opacity: 1 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
.finally(() => {
controller.abort()
})
},
{ once: true, passive: true, signal: abortSignal }
)
} else {
_gsap
.set(img, { opacity: 1 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
}
const minimizeImage: () => Promise<void> = async () => {
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
setNavVector('none')
await minimizeStage({
gsap: _gsap,
imgs,
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
mounted,
setIsAnimating
})
}
onMount(() => {
// preload logic
imgs.forEach((img, i) => {
// preload first 5 images on page load
if (i < 5) {
img.src = img.dataset.loUrl
}
// lores preloader for rest of the images
// eslint-disable-next-line solid/reactivity
onMutation(img, (mutation) => {
// if open or animating, hold
if (props.isOpen() || props.isAnimating()) return false
// if mutation is not about style attribute, hold
if (desktop.isOpen() || desktop.isAnimating()) return false
if (mutation.attributeName !== 'style') return false
const opacity = parseFloat(img.style.opacity)
// if opacity is not 1, hold
if (opacity !== 1) return false
// preload the i + 5th image, if it exists
if (i + 5 < imgs.length) {
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
}
// triggered
return true
})
})
// load gsap on mousemove
window.addEventListener(
'mousemove',
() => {
loadGsap()
.then((g) => {
_gsap = g
gsapLoaded = true
})
.catch((e) => {
console.log(e)
})
},
{ passive: true, once: true }
)
// event listeners
window.addEventListener('pointermove', () => void ensureGsapReady(), {
passive: true,
once: true
})
window.addEventListener('pointerdown', () => void ensureGsapReady(), {
passive: true,
once: true
})
window.addEventListener('click', () => void ensureGsapReady(), {
passive: true,
once: true
})
abortController = new AbortController()
const abortSignal = abortController.signal
window.addEventListener('mousemove', onMouse, {
passive: true,
signal: abortSignal
})
// mounted
mounted = true
})
createEffect(
on(
() => props.cordHist(),
() => desktop.cordHist(),
() => {
setPosition()
},
@@ -413,36 +171,38 @@ export default function Stage(props: {
createEffect(
on(
() => props.isOpen(),
async () => {
if (props.isAnimating()) return
if (props.isOpen()) {
// expand image
desktop.isOpen,
async (isOpen) => {
if (desktop.isAnimating()) return
if (isOpen) {
if (desktop.index() < 0 || desktop.cordHist().length === 0) {
setIsOpen(false)
return
}
await expandImage()
.catch(() => {
void 0
setIsOpen(false)
setIsAnimating(false)
setIsLoading(false)
})
.then(() => {
// abort controller for cleanup
abortController?.abort()
})
} else {
// minimize image
await minimizeImage()
.catch(() => {
void 0
})
// eslint-disable-next-line solid/reactivity
.then(() => {
// event listeners and its abort controller
abortController = new AbortController()
const abortSignal = abortController.signal
window.addEventListener('mousemove', onMouse, {
passive: true,
signal: abortSignal
})
// cleanup isLoading
props.setIsLoading(false)
setIsLoading(false)
})
}
},
@@ -453,7 +213,7 @@ export default function Stage(props: {
return (
<>
<div class="stage" onClick={onClick} onKeyDown={onClick}>
<For each={props.ijs}>
<For each={imageState().images}>
{(ij, i) => (
<img
ref={imgs[i()]}

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

View File

@@ -1,24 +1,15 @@
import { For, createEffect, type Accessor, type JSX, type Setter } from 'solid-js'
import { For, createEffect, createMemo, on, onCleanup, type JSX } from 'solid-js'
import { useState } from '../state'
import { decrement, increment, type Vector } from '../utils'
import { useImageState } from '../imageState'
import { decrement, increment } from '../utils'
import type { HistoryItem } from './layout'
import { useDesktopState } from './state'
export default function StageNav(props: {
children?: JSX.Element
prevText: string
closeText: string
nextText: string
loadingText: string
active: Accessor<boolean>
isAnimating: Accessor<boolean>
setCordHist: Setter<HistoryItem[]>
isOpen: Accessor<boolean>
setIsOpen: Setter<boolean>
setHoverText: Setter<string>
navVector: Accessor<Vector>
setNavVector: Setter<Vector>
}): JSX.Element {
// types
type NavItem = (typeof navItems)[number]
@@ -29,64 +20,74 @@ export default function StageNav(props: {
const navItems = [props.prevText, props.closeText, props.nextText] as const
// states
const [state, { incIndex, decIndex }] = useState()
const imageState = useImageState()
const [
desktop,
{ incIndex, decIndex, setCordHist, setHoverText, setIsOpen, setNavVector }
] = useDesktopState()
const stateLength = state().length
const active = createMemo(() => desktop.isOpen() && !desktop.isAnimating())
const prevImage: () => void = () => {
props.setNavVector('prev')
props.setCordHist((c) =>
setNavVector('prev')
setCordHist((c) =>
c.map((item) => {
return { ...item, i: decrement(item.i, stateLength) }
return { ...item, i: decrement(item.i, imageState().length) }
})
)
decIndex()
}
const closeImage: () => void = () => {
props.setIsOpen(false)
setIsOpen(false)
}
const nextImage: () => void = () => {
props.setNavVector('next')
props.setCordHist((c) =>
setNavVector('next')
setCordHist((c) =>
c.map((item) => {
return { ...item, i: increment(item.i, stateLength) }
return { ...item, i: increment(item.i, imageState().length) }
})
)
incIndex()
}
const handleClick: (item: NavItem) => void = (item) => {
if (!props.isOpen() || props.isAnimating()) return
if (!desktop.isOpen() || desktop.isAnimating()) return
if (item === navItems[0]) prevImage()
else if (item === navItems[1]) closeImage()
else nextImage()
}
const handleKey: (e: KeyboardEvent) => void = (e) => {
if (!props.isOpen() || props.isAnimating()) return
if (!desktop.isOpen() || desktop.isAnimating()) return
if (e.key === 'ArrowLeft') prevImage()
else if (e.key === 'Escape') closeImage()
else if (e.key === 'ArrowRight') nextImage()
}
createEffect(() => {
if (props.isOpen()) {
controller = new AbortController()
const abortSignal = controller.signal
window.addEventListener('keydown', handleKey, {
passive: true,
signal: abortSignal
})
} else {
createEffect(
on(desktop.isOpen, (isOpen) => {
controller?.abort()
}
if (isOpen) {
controller = new AbortController()
const abortSignal = controller.signal
window.addEventListener('keydown', handleKey, {
passive: true,
signal: abortSignal
})
}
})
)
onCleanup(() => {
controller?.abort()
})
return (
<>
<div class="navOverlay" classList={{ active: props.active() }}>
<div class="navOverlay" classList={{ active: active() }}>
<For each={navItems}>
{(item) => (
<div
@@ -94,8 +95,8 @@ export default function StageNav(props: {
onClick={() => {
handleClick(item)
}}
onFocus={() => props.setHoverText(item)}
onMouseOver={() => props.setHoverText(item)}
onFocus={() => setHoverText(item)}
onMouseOver={() => setHoverText(item)}
tabIndex="-1"
/>
)}

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

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