Merge pull request #611 from Sped0n/fix-432

Fix 432
This commit is contained in:
Ryan
2026-03-22 20:16:50 +08:00
committed by GitHub
20 changed files with 1233 additions and 913 deletions

91
assets/ts/configState.tsx Normal file
View 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
}

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 onMouse: (e: MouseEvent) => void = (e) => {
if (props.isOpen() || props.isAnimating() || !gsapLoaded || !mounted) return
const cord = { x: e.clientX, y: e.clientY }
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
const ensureGsapReady: () => Promise<void> = async () => {
if (gsapPromise !== undefined) return await gsapPromise
if (travelDist > state().threshold) {
last = cord
incIndex()
const _state = state()
const newHist = { i: _state.index, ...cord }
props.setCordHist((prev) => [...prev, newHist].slice(-stateLength))
}
}
const onClick: () => void = () => {
if (!props.isAnimating()) props.setIsOpen(true)
}
const setPosition: () => void = () => {
if (!mounted) return
if (imgs.length === 0) return
const _cordHist = props.cordHist()
const trailElsIndex = getTrailElsIndex(_cordHist)
if (trailElsIndex.length === 0) return
const elsTrail = getImagesFromIndexes(imgs, trailElsIndex)
const _isOpen = props.isOpen()
const _state = state()
_gsap.set(elsTrail, {
x: (i: number) => _cordHist[i].x - window.innerWidth / 2,
y: (i: number) => _cordHist[i].y - window.innerHeight / 2,
opacity: (i: number) =>
Math.max(
(i + 1 + _state.trailLength <= _cordHist.length ? 0 : 1) - (_isOpen ? 1 : 0),
0
),
zIndex: (i: number) => i,
scale: 0.6
})
if (_isOpen) {
const elc = getImagesFromIndexes(imgs, [getCurrentElIndex(_cordHist)])[0]
const indexArrayToHires: number[] = []
const indexArrayToCleanup: number[] = []
switch (props.navVector()) {
case 'prev':
indexArrayToHires.push(getPrevElIndex(_cordHist, _state))
indexArrayToCleanup.push(getNextElIndex(_cordHist, _state))
break
case 'next':
indexArrayToHires.push(getNextElIndex(_cordHist, _state))
indexArrayToCleanup.push(getPrevElIndex(_cordHist, _state))
break
default:
break
}
hires(getImagesFromIndexes(imgs, indexArrayToHires)) // preload
_gsap.set(getImagesFromIndexes(imgs, indexArrayToCleanup), { opacity: 0 })
_gsap.set(elc, { x: 0, y: 0, scale: 1 }) // set current to center
setLoaderForHiresImage(elc) // set loader, if loaded set current opacity to 1
} else {
lores(elsTrail)
}
}
const expandImage: () => Promise<
gsap.core.Omit<gsap.core.Timeline, 'then'>
> = async () => {
// isAnimating is prechecked in isOpen effect
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
props.setIsAnimating(true)
const _cordHist = props.cordHist()
const _state = state()
const elcIndex = getCurrentElIndex(_cordHist)
const elc = imgs[elcIndex]
// don't hide here because we want a better transition
hires(
getImagesFromIndexes(imgs, [
elcIndex,
getPrevElIndex(_cordHist, _state),
getNextElIndex(_cordHist, _state)
])
)
setLoaderForHiresImage(elc)
const tl = _gsap.timeline()
const trailInactiveEls = getImagesFromIndexes(
imgs,
getTrailInactiveElsIndex(_cordHist, _state)
)
// move down and hide trail inactive
tl.to(trailInactiveEls, {
y: '+=20',
ease: 'power3.in',
stagger: 0.075,
duration: 0.3,
delay: 0.1,
opacity: 0
})
// current move to center
tl.to(elc, {
x: 0,
y: 0,
ease: 'power3.inOut',
duration: 0.7,
delay: 0.3
})
// current expand
tl.to(elc, {
delay: 0.1,
scale: 1,
ease: 'power3.inOut'
})
// finished
// eslint-disable-next-line solid/reactivity
return await tl.then(() => {
props.setIsAnimating(false)
})
}
const minimizeImage: () => Promise<
gsap.core.Omit<gsap.core.Timeline, 'then'>
> = async () => {
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
props.setIsAnimating(true)
props.setNavVector('none') // cleanup
const _cordHist = props.cordHist()
const _state = state()
const elcIndex = getCurrentElIndex(_cordHist)
const elsTrailInactiveIndexes = getTrailInactiveElsIndex(_cordHist, _state)
lores(getImagesFromIndexes(imgs, [...elsTrailInactiveIndexes, elcIndex]))
const tl = _gsap.timeline()
const elc = getImagesFromIndexes(imgs, [elcIndex])[0]
const elsTrailInactive = getImagesFromIndexes(imgs, elsTrailInactiveIndexes)
// shrink current
tl.to(elc, {
scale: 0.6,
duration: 0.6,
ease: 'power3.inOut'
})
// move current to original position
tl.to(elc, {
delay: 0.3,
duration: 0.7,
ease: 'power3.inOut',
x: _cordHist.slice(-1)[0].x - window.innerWidth / 2,
y: _cordHist.slice(-1)[0].y - window.innerHeight / 2
})
// show trail inactive
tl.to(elsTrailInactive, {
y: '-=20',
ease: 'power3.out',
stagger: -0.1,
duration: 0.3,
opacity: 1
})
// eslint-disable-next-line solid/reactivity
return await tl.then(() => {
props.setIsAnimating(false)
})
}
function setLoaderForHiresImage(img: DesktopImage): void {
if (!mounted || !gsapLoaded) return
if (!img.complete) {
props.setIsLoading(true)
// abort controller for cleanup
const controller = new AbortController()
const abortSignal = controller.signal
// event listeners
img.addEventListener(
'load',
() => {
_gsap
.to(img, { opacity: 1, ease: 'power3.out', duration: 0.5 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
.finally(() => {
controller.abort()
})
},
{ once: true, passive: true, signal: abortSignal }
)
img.addEventListener(
'error',
() => {
_gsap
.set(img, { opacity: 1 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
.finally(() => {
controller.abort()
})
},
{ once: true, passive: true, signal: abortSignal }
)
} else {
_gsap
.set(img, { opacity: 1 })
// eslint-disable-next-line solid/reactivity
.then(() => {
props.setIsLoading(false)
})
.catch((e) => {
console.log(e)
})
}
}
onMount(() => {
// preload logic
imgs.forEach((img, i) => {
// preload first 5 images on page load
if (i < 5) {
img.src = img.dataset.loUrl
}
// lores preloader for rest of the images
// eslint-disable-next-line solid/reactivity
onMutation(img, (mutation) => {
// if open or animating, hold
if (props.isOpen() || props.isAnimating()) return false
// if mutation is not about style attribute, hold
if (mutation.attributeName !== 'style') return false
const opacity = parseFloat(img.style.opacity)
// if opacity is not 1, hold
if (opacity !== 1) return false
// preload the i + 5th image, if it exists
if (i + 5 < imgs.length) {
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
}
// triggered
return true
})
})
// load gsap on mousemove
window.addEventListener(
'mousemove',
() => {
loadGsap()
gsapPromise = loadGsap()
.then((g) => {
_gsap = g
gsapLoaded = true
})
.catch((e) => {
gsapPromise = undefined
console.log(e)
})
},
{ passive: true, once: true }
)
// event listeners
await gsapPromise
}
const onMouse: (e: MouseEvent) => void = (e) => {
if (desktop.isOpen() || desktop.isAnimating() || !gsapLoaded || !mounted) return
const length = imageState().length
if (length <= 0) return
const cord = { x: e.clientX, y: e.clientY }
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
if (travelDist > config().threshold) {
const nextIndex = increment(desktop.index(), length)
last = cord
setIndex(nextIndex)
setCordHist((prev) => [...prev, { i: nextIndex, ...cord }].slice(-length))
}
}
const onClick: () => Promise<void> = async () => {
if (!gsapLoaded) {
await ensureGsapReady()
}
if (desktop.isAnimating() || !gsapLoaded) return
if (desktop.index() < 0 || desktop.cordHist().length === 0) return
setIsOpen(true)
}
const setPosition: () => void = () => {
syncStagePosition({
gsap: _gsap,
imgs,
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
length: imageState().length,
isOpen: desktop.isOpen(),
navVector: desktop.navVector(),
mounted,
setIsLoading
})
}
const expandImage: () => Promise<void> = async () => {
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
await expandStage({
gsap: _gsap,
imgs,
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
length: imageState().length,
mounted,
setIsLoading,
setIsAnimating
})
}
const minimizeImage: () => Promise<void> = async () => {
if (!mounted || !gsapLoaded) throw new Error('not mounted or gsap not loaded')
setNavVector('none')
await minimizeStage({
gsap: _gsap,
imgs,
cordHist: desktop.cordHist(),
trailLength: config().trailLength,
mounted,
setIsAnimating
})
}
onMount(() => {
imgs.forEach((img, i) => {
if (i < 5) {
img.src = img.dataset.loUrl
}
onMutation(img, (mutation) => {
if (desktop.isOpen() || desktop.isAnimating()) return false
if (mutation.attributeName !== 'style') return false
const opacity = parseFloat(img.style.opacity)
if (opacity !== 1) return false
if (i + 5 < imgs.length) {
imgs[i + 5].src = imgs[i + 5].dataset.loUrl
}
return true
})
})
window.addEventListener('pointermove', () => void ensureGsapReady(), {
passive: true,
once: true
})
window.addEventListener('pointerdown', () => void ensureGsapReady(), {
passive: true,
once: true
})
window.addEventListener('click', () => void ensureGsapReady(), {
passive: true,
once: true
})
abortController = new AbortController()
const abortSignal = abortController.signal
window.addEventListener('mousemove', onMouse, {
passive: true,
signal: abortSignal
})
// mounted
mounted = true
})
createEffect(
on(
() => props.cordHist(),
() => desktop.cordHist(),
() => {
setPosition()
},
@@ -413,36 +171,38 @@ export default function Stage(props: {
createEffect(
on(
() => props.isOpen(),
async () => {
if (props.isAnimating()) return
if (props.isOpen()) {
// expand image
desktop.isOpen,
async (isOpen) => {
if (desktop.isAnimating()) return
if (isOpen) {
if (desktop.index() < 0 || desktop.cordHist().length === 0) {
setIsOpen(false)
return
}
await expandImage()
.catch(() => {
void 0
setIsOpen(false)
setIsAnimating(false)
setIsLoading(false)
})
.then(() => {
// abort controller for cleanup
abortController?.abort()
})
} else {
// minimize image
await minimizeImage()
.catch(() => {
void 0
})
// eslint-disable-next-line solid/reactivity
.then(() => {
// event listeners and its abort controller
abortController = new AbortController()
const abortSignal = abortController.signal
window.addEventListener('mousemove', onMouse, {
passive: true,
signal: abortSignal
})
// cleanup isLoading
props.setIsLoading(false)
setIsLoading(false)
})
}
},
@@ -453,7 +213,7 @@ export default function Stage(props: {
return (
<>
<div class="stage" onClick={onClick} onKeyDown={onClick}>
<For each={props.ijs}>
<For each={imageState().images}>
{(ij, i) => (
<img
ref={imgs[i()]}

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()) {
createEffect(
on(desktop.isOpen, (isOpen) => {
controller?.abort()
if (isOpen) {
controller = new AbortController()
const abortSignal = controller.signal
window.addEventListener('keydown', handleKey, {
passive: true,
signal: abortSignal
})
} else {
controller?.abort()
}
})
)
onCleanup(() => {
controller?.abort()
})
return (
<>
<div class="navOverlay" classList={{ active: props.active() }}>
<div class="navOverlay" classList={{ active: active() }}>
<For each={navItems}>
{(item) => (
<div
@@ -94,8 +95,8 @@ export default function StageNav(props: {
onClick={() => {
handleClick(item)
}}
onFocus={() => props.setHoverText(item)}
onMouseOver={() => props.setHoverText(item)}
onFocus={() => setHoverText(item)}
onMouseOver={() => setHoverText(item)}
tabIndex="-1"
/>
)}

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
}

41
assets/ts/imageState.tsx Normal file
View 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
}

View File

@@ -1,17 +1,11 @@
import {
Match,
Show,
Switch,
createEffect,
createResource,
createSignal,
lazy,
type JSX
} from 'solid-js'
import { Match, Show, Switch, createResource, lazy, type JSX } from 'solid-js'
import { render } from 'solid-js/web'
import { ConfigStateProvider } from './configState'
import { DesktopStateProvider } from './desktop/state'
import { ImageStateProvider } from './imageState'
import { MobileStateProvider } from './mobile/state'
import { getImageJSON } from './resources'
import { StateProvider } from './state'
import '../scss/style.scss'
@@ -35,48 +29,60 @@ const container = document.getElementsByClassName('container')[0] as Container
const Desktop = lazy(async () => await import('./desktop/layout'))
const Mobile = lazy(async () => await import('./mobile/layout'))
function AppContent(props: {
isMobile: boolean
prevText: string
closeText: string
nextText: string
loadingText: string
}): JSX.Element {
return (
<Switch fallback={<div>Error</div>}>
<Match when={props.isMobile}>
<MobileStateProvider>
<Mobile closeText={props.closeText} loadingText={props.loadingText} />
</MobileStateProvider>
</Match>
<Match when={!props.isMobile}>
<DesktopStateProvider>
<Desktop
prevText={props.prevText}
closeText={props.closeText}
nextText={props.nextText}
loadingText={props.loadingText}
/>
</DesktopStateProvider>
</Match>
</Switch>
)
}
function Main(): JSX.Element {
// variables
const [ijs] = createResource(getImageJSON)
const isMobile =
window.matchMedia('(hover: none)').matches &&
!window.navigator.userAgent.includes('Win')
// states
const [scrollable, setScollable] = createSignal(true)
createEffect(() => {
if (scrollable()) {
container.classList.remove('disableScroll')
} else {
container.classList.add('disableScroll')
}
})
const ua = window.navigator.userAgent.toLowerCase()
const hasTouchInput = 'ontouchstart' in window || window.navigator.maxTouchPoints > 0
const hasTouchLayout =
window.matchMedia('(pointer: coarse)').matches ||
window.matchMedia('(hover: none)').matches
const isMobileUA = /android|iphone|ipad|ipod|mobile/.test(ua)
const isWindowsDesktop = /windows nt/.test(ua)
const isMobile = isMobileUA || (hasTouchInput && hasTouchLayout && !isWindowsDesktop)
return (
<>
<Show when={ijs.state === 'ready'}>
<StateProvider length={ijs()?.length ?? 0}>
<Switch fallback={<div>Error</div>}>
<Match when={isMobile}>
<Mobile
ijs={ijs() ?? []}
closeText={container.dataset.close}
loadingText={container.dataset.loading}
setScrollable={setScollable}
/>
</Match>
<Match when={!isMobile}>
<Desktop
ijs={ijs() ?? []}
<ImageStateProvider images={ijs() ?? []}>
<ConfigStateProvider>
<AppContent
isMobile={isMobile}
prevText={container.dataset.prev}
closeText={container.dataset.close}
nextText={container.dataset.next}
loadingText={container.dataset.loading}
/>
</Match>
</Switch>
</StateProvider>
</ConfigStateProvider>
</ImageStateProvider>
</Show>
</>
)

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
})
_gsap.to(curtain, {
opacity: 0,
duration: 1.2,
delay: 0.4
})
setTimeout(() => {
// cleanup
props.setScrollable(true)
props.setIsAnimating(false)
closeGallery({
gsap: _gsap,
curtain,
gallery,
setIsAnimating,
setIsScrollLocked,
onClosed: () => {
lastIndex = -1
}, 1400)
}
})
}
const galleryLoadImages: () => void = () => {
let activeImagesIndex: number[] = []
const _state = state()
const currentIndex = _state.index
const nextIndex = Math.min(currentIndex + 1, _state.length - 1)
const prevIndex = Math.max(currentIndex - 1, 0)
switch (navigateVector) {
case 'next':
activeImagesIndex = [nextIndex]
break
case 'prev':
activeImagesIndex = [prevIndex]
break
case 'none':
activeImagesIndex = [currentIndex, nextIndex, prevIndex]
break
}
setLoads(removeDuplicates(activeImagesIndex), true)
const currentIndex = mobile.index()
setLoads(
removeDuplicates(
getActiveImageIndexes(currentIndex, imageState().length, navigateVector)
),
true
)
}
const changeSlide: (slide: number) => void = (slide) => {
// we are already in the gallery, don't need to
// check mounted or libLoaded
if (!swiperReady() || _swiper === undefined) return
galleryLoadImages()
_swiper.slideTo(slide, 0)
}
// effects
onMount(() => {
window.addEventListener(
'touchstart',
() => {
loadGsap()
.then((g) => {
const ensureGalleryReady: () => Promise<void> = async () => {
if (initPromise !== undefined) return await initPromise
initPromise = (async () => {
try {
const [g, S] = await Promise.all([loadGsap(), loadSwiper()])
_gsap = g
})
.catch((e) => {
console.log(e)
})
loadSwiper()
.then((S) => {
invariant(galleryInner, 'galleryInner is not defined')
_swiper = new S(galleryInner, { spaceBetween: 20 })
_swiper.on('slideChange', ({ realIndex }) => {
setIndex(realIndex)
})
})
.catch((e) => {
console.log(e)
})
setLibLoaded(true)
},
{ once: true, passive: true }
)
setSwiperReady(true)
const initialIndex = untrack(mobile.index)
if (initialIndex >= 0) {
changeSlide(initialIndex)
lastIndex = initialIndex
}
} catch (e) {
initPromise = undefined
setSwiperReady(false)
console.log(e)
}
})()
await initPromise
}
onMount(() => {
window.addEventListener('touchstart', () => void ensureGalleryReady(), {
once: true,
passive: true
})
mounted = true
})
createEffect(
on(
() => {
state()
},
() => {
const i = state().index
if (i === lastIndex)
return // change slide only when index is changed
else if (lastIndex === -1)
navigateVector = 'none' // lastIndex before set
else if (i < lastIndex)
navigateVector = 'prev' // set navigate vector for galleryLoadImages
else if (i > lastIndex)
navigateVector = 'next' // set navigate vector for galleryLoadImages
else navigateVector = 'none' // default
changeSlide(i) // change slide to new index
lastIndex = i // update last index
() => [swiperReady(), mobile.index()] as const,
([ready, index]) => {
if (!ready || index < 0) return
if (index === lastIndex) return
if (lastIndex === -1) navigateVector = 'none'
else if (index < lastIndex) navigateVector = 'prev'
else if (index > lastIndex) navigateVector = 'next'
else navigateVector = 'none'
changeSlide(index)
lastIndex = index
}
)
)
createEffect(
on(
() => {
props.isOpen()
},
() => {
if (props.isAnimating()) return
if (props.isOpen()) slideUp()
() => mobile.isOpen(),
async (isOpen) => {
if (isOpen && !swiperReady()) {
await ensureGalleryReady()
}
if (!libLoaded() || !swiperReady()) return
if (mobile.isAnimating()) return
if (isOpen) slideUp()
else slideDown()
},
{ defer: true }
@@ -215,26 +176,16 @@ export default function Gallery(props: {
<div ref={gallery} class="gallery">
<div ref={galleryInner} class="galleryInner">
<div class="swiper-wrapper">
<Show when={libLoaded()}>
<For each={props.ijs}>
<For each={imageState().images}>
{(ij, i) => (
<div class="swiper-slide">
<GalleryImage
load={loads[i()]}
ij={ij}
loadingText={_loadingText}
/>
<GalleryImage load={loads[i()]} ij={ij} loadingText={loadingText()} />
</div>
)}
</For>
</Show>
</div>
</div>
<GalleryNav
closeText={props.closeText}
isAnimating={props.isAnimating}
setIsOpen={props.setIsOpen}
/>
<GalleryNav closeText={props.closeText} />
</div>
<div ref={curtain} class="curtain" />
</>

View File

@@ -1,10 +1,12 @@
import { onMount, type JSX } from 'solid-js'
import { type gsap } from 'gsap'
import { createEffect, on, onMount, type JSX } from 'solid-js'
import invariant from 'tiny-invariant'
import type { ImageJSON } from '../resources'
import { useState } from '../state'
import { loadGsap } from '../utils'
import { useMobileState } from './state'
export default function GalleryImage(props: {
children?: JSX.Element
load: boolean
@@ -14,27 +16,39 @@ export default function GalleryImage(props: {
let img: HTMLImageElement | undefined
let loadingDiv: HTMLDivElement | undefined
let _gsap: typeof gsap
let _gsap: typeof gsap | undefined
let gsapPromise: Promise<typeof gsap> | undefined
let revealed = false
const [state] = useState()
const [mobile] = useMobileState()
const revealImage = async (): Promise<void> => {
if (revealed) return
revealed = true
onMount(() => {
loadGsap()
.then((g) => {
_gsap = g
})
.catch((e) => {
console.log(e)
})
img?.addEventListener(
'load',
() => {
invariant(img, 'ref must be defined')
invariant(loadingDiv, 'loadingDiv must be defined')
if (state().index !== props.ij.index) {
gsapPromise ??= loadGsap()
try {
_gsap ??= await gsapPromise
} catch (e) {
console.log(e)
}
if (_gsap === undefined) {
img.style.opacity = '1'
loadingDiv.style.opacity = '0'
return
}
if (mobile.index() !== props.ij.index) {
_gsap.set(img, { opacity: 1 })
_gsap.set(loadingDiv, { opacity: 0 })
} else {
return
}
_gsap.to(img, {
opacity: 1,
delay: 0.5,
@@ -43,11 +57,42 @@ export default function GalleryImage(props: {
})
_gsap.to(loadingDiv, { opacity: 0, duration: 0.5, ease: 'power3.in' })
}
onMount(() => {
gsapPromise = loadGsap()
.then((g) => {
_gsap = g
return g
})
.catch((e) => {
console.log(e)
throw e
})
img?.addEventListener(
'load',
() => {
void revealImage()
},
{ once: true, passive: true }
)
if (props.load && img?.complete && img.currentSrc !== '') {
void revealImage()
}
})
createEffect(
on(
() => props.load,
(load) => {
if (!load || img === undefined || !img.complete || img.currentSrc === '') return
void revealImage()
},
{ defer: true }
)
)
return (
<>
<div class="slideContainer">

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
}

View File

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