mirror of
https://github.com/Sped0n/bridget.git
synced 2026-04-14 10:09:31 -07:00
feat: migrate to Solid.js (#282)
* refactor: change hires loader function name * feat: add loading transition animation and improve performance * refactor: refactor gallery creation and update functions * feat: update dependencies, configuration, and input file for solidjs - Update dependencies in package.json - Modify the input file in rollup.config.mjs - Update tsconfig.json with new configuration options * feat: update ESLint config for TypeScript and Solid integration - Add `plugin:solid/typescript` to the ESLint config - Add `prettier`, `@typescript-eslint`, and `solid` plugins to the ESLint config - Remove the `overrides` and `plugins` properties from the ESLint config - Modify the `memberSyntaxSortOrder` property in the ESLint config * feat: update build scripts and configuration for Vite * GitButler Integration Commit This is an integration commit for the virtual branches that GitButler is tracking. Due to GitButler managing multiple virtual branches, you cannot switch back and forth between git branches and virtual branches easily. If you switch to another branch, GitButler will need to be reinitialized. If you commit on this branch, GitButler will throw it away. Here are the branches that are currently applied: - solid (refs/gitbutler/solid) branch head:dc6860991c- .eslintrc.json - assets/ts/main.tsx - assets/ts/desktop/stage.tsx - static/bundled/js/main.js - rollup.config.mjs - pnpm-lock.yaml - vite.config.ts - package.json - tsconfig.json - assets/ts/globalState.ts - assets/ts/mobile/collection.ts - assets/ts/container.ts - assets/ts/mobile/init.ts - assets/ts/mobile/gallery.ts - assets/ts/desktop/customCursor.ts - assets/ts/mobile/state.ts - assets/ts/globalUtils.ts - static/bundled/js/zXhbFx.js - static/bundled/js/EY5BO_.js - static/bundled/js/bBHMTk.js - assets/ts/nav.tsx - assets/ts/utils.ts - assets/ts/desktop/stageNav.ts - assets/ts/mobile/utils.ts - assets/ts/main.ts - assets/ts/desktop/state.ts - assets/ts/desktop/init.ts - assets/ts/state.tsx - assets/ts/desktop/utils.ts - assets/ts/nav.ts - assets/ts/resources.ts - assets/ts/desktop/stage.ts - static/bundled/js/GAHquF.js Your previous branch was: refs/heads/solid The sha for that commit was:dc6860991cFor more information about what we're doing here, check out our docs: https://docs.gitbutler.com/features/virtual-branches/integration-branch * refactor: remove .hide class from _base.scss file * feat: migrate to Solid.js * refactor: change i18n loading text with trailing dots * fix: fix broken pnpm lock file * chore: update eslint configuration for better code organization - Update the eslint plugins array by removing newlines and maintaining plugins order - Disable the rule "@typescript-eslint/non-nullable-type-assertion-style" - Change the configuration of "sort-imports" rule * feat: add tiny-invariant and eslint-plugin-solid to deps * refactor: fix multiple eslint warnings --------- Co-authored-by: GitButler <gitbutler@gitbutler.com>
This commit is contained in:
@@ -17,7 +17,3 @@ a,
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Watchable } from './globalUtils'
|
||||
|
||||
export const scrollable = new Watchable<boolean>(true)
|
||||
|
||||
export let container: Container
|
||||
|
||||
/**
|
||||
* interfaces
|
||||
*/
|
||||
|
||||
export interface Container extends HTMLDivElement {
|
||||
dataset: {
|
||||
next: string
|
||||
close: string
|
||||
prev: string
|
||||
loading: string
|
||||
}
|
||||
}
|
||||
|
||||
export function initContainer(): void {
|
||||
container = document.getElementsByClassName('container').item(0) as Container
|
||||
scrollable.addWatcher((o) => {
|
||||
if (o) {
|
||||
container.classList.remove('disableScroll')
|
||||
} else {
|
||||
container.classList.add('disableScroll')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { container } from '../container'
|
||||
|
||||
import { active } from './state'
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
const cursor = document.createElement('div')
|
||||
const cursorInner = document.createElement('div')
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
function onMouse(e: MouseEvent): void {
|
||||
const x = e.clientX
|
||||
const y = e.clientY
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}
|
||||
|
||||
export function setCustomCursor(text: string): void {
|
||||
cursorInner.innerText = text
|
||||
}
|
||||
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
export function initCustomCursor(): void {
|
||||
// cursor class name
|
||||
cursor.className = 'cursor'
|
||||
// cursor inner class name
|
||||
cursorInner.className = 'cursorInner'
|
||||
// append cursor inner to cursor
|
||||
cursor.append(cursorInner)
|
||||
// append cursor to main
|
||||
container.append(cursor)
|
||||
// bind mousemove event to window
|
||||
window.addEventListener('mousemove', onMouse, { passive: true })
|
||||
// add active callback
|
||||
active.addWatcher((o) => {
|
||||
if (o) {
|
||||
cursor.classList.add('active')
|
||||
} else {
|
||||
cursor.classList.remove('active')
|
||||
}
|
||||
})
|
||||
}
|
||||
52
assets/ts/desktop/customCursor.tsx
Normal file
52
assets/ts/desktop/customCursor.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createSignal, onCleanup, onMount, type Accessor, type JSX } from 'solid-js'
|
||||
|
||||
export function CustomCursor(props: {
|
||||
children?: JSX.Element
|
||||
active: Accessor<boolean>
|
||||
cursorText: Accessor<string>
|
||||
isOpen: Accessor<boolean>
|
||||
}): JSX.Element {
|
||||
// types
|
||||
interface XY {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
// variables
|
||||
let controller: AbortController | undefined
|
||||
|
||||
// states
|
||||
const [xy, setXy] = createSignal<XY>({ x: 0, y: 0 })
|
||||
|
||||
// helper functions
|
||||
const onMouse: (e: MouseEvent) => void = (e) => {
|
||||
const { clientX, clientY } = e
|
||||
setXy({ x: clientX, y: clientY })
|
||||
}
|
||||
|
||||
// effects
|
||||
onMount(() => {
|
||||
controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
window.addEventListener('mousemove', onMouse, {
|
||||
passive: true,
|
||||
signal: abortSignal
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="cursor"
|
||||
classList={{ active: props.active() }}
|
||||
style={{ transform: `translate3d(${xy().x}px, ${xy().y}px, 0)` }}
|
||||
>
|
||||
<div class="cursorInner">{props.cursorText()}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { type ImageJSON } from '../resources'
|
||||
|
||||
import { initCustomCursor } from './customCursor'
|
||||
import { initStage } from './stage'
|
||||
import { initStageNav } from './stageNav'
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
export function initDesktop(ijs: ImageJSON[]): void {
|
||||
initCustomCursor()
|
||||
initStage(ijs)
|
||||
initStageNav()
|
||||
}
|
||||
89
assets/ts/desktop/layout.tsx
Normal file
89
assets/ts/desktop/layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// eslint-disable-next-line sort-imports
|
||||
import { Show, createMemo, createSignal, type JSX } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import type { Vector } from '../utils'
|
||||
|
||||
import { CustomCursor } from './customCursor'
|
||||
import { Nav } from './nav'
|
||||
import { Stage } from './stage'
|
||||
import { StageNav } from './stageNav'
|
||||
|
||||
/**
|
||||
* interfaces and types
|
||||
*/
|
||||
|
||||
export interface DesktopImage extends HTMLImageElement {
|
||||
dataset: {
|
||||
hiUrl: string
|
||||
hiImgH: string
|
||||
hiImgW: string
|
||||
loUrl: string
|
||||
loImgH: string
|
||||
loImgW: string
|
||||
}
|
||||
}
|
||||
|
||||
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 active = createMemo(() => isOpen() && !isAnimating())
|
||||
const cursorText = createMemo(() => (isLoading() ? props.loadingText : hoverText()))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<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} />
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import { decThreshold, incThreshold, state } from './globalState'
|
||||
import { expand } from './globalUtils'
|
||||
import { createEffect } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { expand } from '../utils'
|
||||
|
||||
/**
|
||||
* variables
|
||||
* constants
|
||||
*/
|
||||
|
||||
// threshold div
|
||||
const thresholdDiv = document
|
||||
.getElementsByClassName('threshold')
|
||||
.item(0) as HTMLDivElement
|
||||
|
||||
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')
|
||||
@@ -22,52 +20,24 @@ const decButton = thresholdDiv
|
||||
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[]
|
||||
|
||||
/**
|
||||
* init
|
||||
* helper functions
|
||||
*/
|
||||
|
||||
export function initNav(): void {
|
||||
// add watcher for updating nav text
|
||||
state.addWatcher((o) => {
|
||||
updateIndexText(expand(o.index + 1), expand(o.length))
|
||||
updateThresholdText(expand(o.threshold))
|
||||
})
|
||||
|
||||
// event listeners
|
||||
decButton.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
decThreshold()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
incButton.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
incThreshold()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
// helper
|
||||
|
||||
export function updateThresholdText(thresholdValue: string): void {
|
||||
function updateThresholdText(thresholdValue: string): void {
|
||||
thresholdDispNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
e.innerText = thresholdValue[i]
|
||||
})
|
||||
}
|
||||
|
||||
export function updateIndexText(indexValue: string, indexLength: string): void {
|
||||
function updateIndexText(indexValue: string, indexLength: string): void {
|
||||
indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
if (i < 4) {
|
||||
e.innerText = indexValue[i]
|
||||
@@ -76,3 +46,21 @@ export function updateIndexText(indexValue: string, indexLength: string): void {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav component
|
||||
*/
|
||||
|
||||
export function Nav(): null {
|
||||
const [state, { incThreshold, decThreshold }] = useState()
|
||||
|
||||
createEffect(() => {
|
||||
updateIndexText(expand(state().index + 1), expand(state().length))
|
||||
updateThresholdText(expand(state().threshold))
|
||||
})
|
||||
|
||||
decButton.onclick = decThreshold
|
||||
incButton.onclick = incThreshold
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { type gsap } from 'gsap'
|
||||
|
||||
import { container } from '../container'
|
||||
import { incIndex, isAnimating, navigateVector, state } from '../globalState'
|
||||
import { decrement, increment, loadGsap } from '../globalUtils'
|
||||
import { type ImageJSON } from '../resources'
|
||||
|
||||
import { active, cordHist, isLoading, isOpen } from './state'
|
||||
// eslint-disable-next-line sort-imports
|
||||
import { onMutation, type DesktopImage } from './utils'
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
let imgs: DesktopImage[] = []
|
||||
let last = { x: 0, y: 0 }
|
||||
|
||||
let _gsap: typeof gsap
|
||||
|
||||
/**
|
||||
* state
|
||||
*/
|
||||
|
||||
let gsapLoaded = false
|
||||
|
||||
/**
|
||||
* getter
|
||||
*/
|
||||
|
||||
function getTrailElsIndex(): number[] {
|
||||
return cordHist.get().map((item) => item.i)
|
||||
}
|
||||
|
||||
function getTrailCurrentElsIndex(): number[] {
|
||||
return getTrailElsIndex().slice(-state.get().trailLength)
|
||||
}
|
||||
|
||||
function getTrailInactiveElsIndex(): number[] {
|
||||
const trailCurrentElsIndex = getTrailCurrentElsIndex()
|
||||
return trailCurrentElsIndex.slice(0, trailCurrentElsIndex.length - 1)
|
||||
}
|
||||
|
||||
function getCurrentElIndex(): number {
|
||||
const trailElsIndex = getTrailElsIndex()
|
||||
return trailElsIndex[trailElsIndex.length - 1]
|
||||
}
|
||||
|
||||
function getPrevElIndex(): number {
|
||||
const c = cordHist.get()
|
||||
const s = state.get()
|
||||
return decrement(c[c.length - 1].i, s.length)
|
||||
}
|
||||
|
||||
function getNextElIndex(): number {
|
||||
const c = cordHist.get()
|
||||
const s = state.get()
|
||||
return increment(c[c.length - 1].i, s.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
// on mouse
|
||||
function onMouse(e: MouseEvent): void {
|
||||
if (isOpen.get() || isAnimating.get()) return
|
||||
if (!gsapLoaded) {
|
||||
loadLib()
|
||||
return
|
||||
}
|
||||
const cord = { x: e.clientX, y: e.clientY }
|
||||
const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
|
||||
|
||||
if (travelDist > state.get().threshold) {
|
||||
last = cord
|
||||
incIndex()
|
||||
|
||||
const newHist = { i: state.get().index, ...cord }
|
||||
cordHist.set([...cordHist.get(), newHist].slice(-state.get().length))
|
||||
}
|
||||
}
|
||||
|
||||
// set image position with gsap (in both stage and navigation)
|
||||
function setPositions(): void {
|
||||
const trailElsIndex = getTrailElsIndex()
|
||||
if (trailElsIndex.length === 0 || !gsapLoaded) return
|
||||
|
||||
const elsTrail = getImagesWithIndexArray(trailElsIndex)
|
||||
|
||||
// cached state
|
||||
const _isOpen = isOpen.get()
|
||||
const _cordHist = cordHist.get()
|
||||
const _state = state.get()
|
||||
|
||||
_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 = getImagesWithIndexArray([getCurrentElIndex()])[0]
|
||||
const indexArrayToHires: number[] = []
|
||||
const indexArrayToCleanup: number[] = []
|
||||
switch (navigateVector.get()) {
|
||||
case 'prev':
|
||||
indexArrayToHires.push(getPrevElIndex())
|
||||
indexArrayToCleanup.push(getNextElIndex())
|
||||
break
|
||||
case 'next':
|
||||
indexArrayToHires.push(getNextElIndex())
|
||||
indexArrayToCleanup.push(getPrevElIndex())
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
hires(getImagesWithIndexArray(indexArrayToHires)) // preload
|
||||
_gsap.set(getImagesWithIndexArray(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)
|
||||
}
|
||||
}
|
||||
|
||||
// open image into navigation
|
||||
function expandImage(): void {
|
||||
if (isAnimating.get()) return
|
||||
|
||||
isOpen.set(true)
|
||||
isAnimating.set(true)
|
||||
|
||||
const elcIndex = getCurrentElIndex()
|
||||
const elc = getImagesWithIndexArray([elcIndex])[0]
|
||||
// don't hide here because we want a better transition
|
||||
// elc.classList.add('hide')
|
||||
|
||||
hires(getImagesWithIndexArray([elcIndex, getPrevElIndex(), getNextElIndex()]))
|
||||
setLoaderForHiresImage(elc)
|
||||
|
||||
const tl = _gsap.timeline()
|
||||
const trailInactiveEls = getImagesWithIndexArray(getTrailInactiveElsIndex())
|
||||
// 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
|
||||
tl.then(() => {
|
||||
isAnimating.set(false)
|
||||
}).catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
|
||||
// close navigation and back to stage
|
||||
export function minimizeImage(): void {
|
||||
if (isAnimating.get()) return
|
||||
|
||||
isOpen.set(false)
|
||||
isAnimating.set(true)
|
||||
navigateVector.set('none') // cleanup
|
||||
|
||||
lores(
|
||||
getImagesWithIndexArray([...getTrailInactiveElsIndex(), ...[getCurrentElIndex()]])
|
||||
)
|
||||
|
||||
const tl = _gsap.timeline()
|
||||
const elc = getImagesWithIndexArray([getCurrentElIndex()])[0]
|
||||
const elsTrailInactive = getImagesWithIndexArray(getTrailInactiveElsIndex())
|
||||
// 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.get()[cordHist.get().length - 1].x - window.innerWidth / 2,
|
||||
y: cordHist.get()[cordHist.get().length - 1].y - window.innerHeight / 2
|
||||
})
|
||||
// show trail inactive
|
||||
tl.to(elsTrailInactive, {
|
||||
y: '-=20',
|
||||
ease: 'power3.out',
|
||||
stagger: -0.1,
|
||||
duration: 0.3,
|
||||
opacity: 1
|
||||
})
|
||||
// finished
|
||||
tl.then(() => {
|
||||
isAnimating.set(false)
|
||||
}).catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
|
||||
export function initStage(ijs: ImageJSON[]): void {
|
||||
// create stage element
|
||||
createStage(ijs)
|
||||
// get stage
|
||||
const stage = document.getElementsByClassName('stage').item(0) as HTMLDivElement
|
||||
// get image elements
|
||||
imgs = Array.from(stage.getElementsByTagName('img')) as DesktopImage[]
|
||||
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
|
||||
onMutation(img, (mutation) => {
|
||||
// if open or animating, hold
|
||||
if (isOpen.get() || isAnimating.get()) 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
|
||||
})
|
||||
})
|
||||
// event listeners
|
||||
stage.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
expandImage()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
stage.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
expandImage()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
window.addEventListener('mousemove', onMouse, { passive: true })
|
||||
// watchers
|
||||
isOpen.addWatcher((o) => {
|
||||
active.set(o && !isAnimating.get())
|
||||
})
|
||||
isAnimating.addWatcher((o) => {
|
||||
active.set(isOpen.get() && !o)
|
||||
})
|
||||
cordHist.addWatcher((_) => {
|
||||
setPositions()
|
||||
})
|
||||
// dynamic import
|
||||
window.addEventListener(
|
||||
'mousemove',
|
||||
() => {
|
||||
loadLib()
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* hepler
|
||||
*/
|
||||
|
||||
function createStage(ijs: ImageJSON[]): void {
|
||||
// create container for images
|
||||
const stage: HTMLDivElement = document.createElement('div')
|
||||
stage.className = 'stage'
|
||||
// append images to container
|
||||
for (const ij of ijs) {
|
||||
const e = document.createElement('img') as DesktopImage
|
||||
e.height = ij.loImgH
|
||||
e.width = ij.loImgW
|
||||
// set data attributes
|
||||
e.dataset.hiUrl = ij.hiUrl
|
||||
e.dataset.hiImgH = ij.hiImgH.toString()
|
||||
e.dataset.hiImgW = ij.hiImgW.toString()
|
||||
e.dataset.loUrl = ij.loUrl
|
||||
e.dataset.loImgH = ij.loImgH.toString()
|
||||
e.dataset.loImgW = ij.loImgW.toString()
|
||||
e.alt = ij.alt
|
||||
// append
|
||||
stage.append(e)
|
||||
}
|
||||
container.append(stage)
|
||||
}
|
||||
|
||||
function getImagesWithIndexArray(indexArray: number[]): DesktopImage[] {
|
||||
return indexArray.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 setLoaderForHiresImage(e: HTMLImageElement): void {
|
||||
if (!e.complete) {
|
||||
isLoading.set(true)
|
||||
// abort controller for cleanup
|
||||
const controller = new AbortController()
|
||||
const abortSignal = controller.signal
|
||||
// event listeners
|
||||
e.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
_gsap
|
||||
.to(e, { opacity: 1, ease: 'power3.out', duration: 0.5 })
|
||||
.then(() => {
|
||||
isLoading.set(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
e.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
_gsap
|
||||
.set(e, { opacity: 1 })
|
||||
.then(() => {
|
||||
isLoading.set(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort()
|
||||
})
|
||||
},
|
||||
{ once: true, passive: true, signal: abortSignal }
|
||||
)
|
||||
} else {
|
||||
_gsap
|
||||
.set(e, { opacity: 1 })
|
||||
.then(() => {
|
||||
isLoading.set(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loadLib(): void {
|
||||
loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
gsapLoaded = true
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
476
assets/ts/desktop/stage.tsx
Normal file
476
assets/ts/desktop/stage.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { type gsap } from 'gsap'
|
||||
import {
|
||||
For,
|
||||
createEffect,
|
||||
on,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useState, type State } from '../state'
|
||||
import { decrement, increment, loadGsap, type Vector } from '../utils'
|
||||
|
||||
import type { DesktopImage, HistoryItem } from './layout'
|
||||
|
||||
/**
|
||||
* 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 function Stage(props: {
|
||||
ijs: ImageJSON[]
|
||||
setIsLoading: Setter<boolean>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
isAnimating: Accessor<boolean>
|
||||
setIsAnimating: Setter<boolean>
|
||||
cordHist: Accessor<HistoryItem[]>
|
||||
setCordHist: Setter<HistoryItem[]>
|
||||
navVector: Accessor<Vector>
|
||||
setNavVector: Setter<Vector>
|
||||
}): JSX.Element {
|
||||
// variables
|
||||
let _gsap: typeof gsap
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const imgs: DesktopImage[] = Array<DesktopImage>(props.ijs.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)
|
||||
|
||||
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 = () => {
|
||||
!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()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
gsapLoaded = true
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
{ passive: true, once: true }
|
||||
)
|
||||
// event listeners
|
||||
abortController = new AbortController()
|
||||
const abortSignal = abortController.signal
|
||||
window.addEventListener('mousemove', onMouse, {
|
||||
passive: true,
|
||||
signal: abortSignal
|
||||
})
|
||||
// mounted
|
||||
mounted = true
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.cordHist(),
|
||||
() => {
|
||||
setPosition()
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isOpen(),
|
||||
async () => {
|
||||
if (props.isAnimating()) return
|
||||
if (props.isOpen()) {
|
||||
// expand image
|
||||
await expandImage()
|
||||
.catch(() => {
|
||||
void 0
|
||||
})
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
.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)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="stage" onClick={onClick} onKeyDown={onClick}>
|
||||
<For each={props.ijs}>
|
||||
{(ij, i) => (
|
||||
<img
|
||||
ref={imgs[i()]}
|
||||
height={ij.loImgH}
|
||||
width={ij.loImgW}
|
||||
data-hi-url={ij.hiUrl}
|
||||
data-hi-img-h={ij.hiImgH}
|
||||
data-hi-img-w={ij.hiImgW}
|
||||
data-lo-url={ij.loUrl}
|
||||
data-lo-img-h={ij.loImgH}
|
||||
data-lo-img-w={ij.loImgW}
|
||||
alt={ij.alt}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { container } from '../container'
|
||||
import { decIndex, incIndex, isAnimating, navigateVector, state } from '../globalState'
|
||||
import { decrement, increment } from '../globalUtils'
|
||||
|
||||
import { setCustomCursor } from './customCursor'
|
||||
import { minimizeImage } from './stage'
|
||||
import { active, cordHist, isLoading, isOpen } from './state'
|
||||
|
||||
/**
|
||||
* types
|
||||
*/
|
||||
|
||||
type NavItem = (typeof navItems)[number]
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
const navItems = [
|
||||
container.dataset.prev,
|
||||
container.dataset.close,
|
||||
container.dataset.next
|
||||
] as const
|
||||
const loadingText = container.dataset.loading + '...'
|
||||
let loadedText = ''
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
function handleClick(type: NavItem): void {
|
||||
if (type === navItems[0]) {
|
||||
prevImage()
|
||||
} else if (type === navItems[1]) {
|
||||
minimizeImage()
|
||||
} else {
|
||||
nextImage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent): void {
|
||||
if (isOpen.get() || isAnimating.get()) return
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
prevImage()
|
||||
break
|
||||
case 'Escape':
|
||||
minimizeImage()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
nextImage()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
|
||||
export function initStageNav(): void {
|
||||
// isLoading
|
||||
isLoading.addWatcher((o) => {
|
||||
if (o) setCustomCursor(loadingText)
|
||||
else setCustomCursor(loadedText)
|
||||
})
|
||||
// navOverlay
|
||||
const navOverlay = document.createElement('div')
|
||||
navOverlay.className = 'navOverlay'
|
||||
for (const [index, navItem] of navItems.entries()) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'overlay'
|
||||
const isClose = index === 1
|
||||
// close
|
||||
if (isClose) {
|
||||
overlay.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
handleCloseClick(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
handleCloseClick(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'mouseover',
|
||||
() => {
|
||||
handleCloseHover(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'focus',
|
||||
() => {
|
||||
handleCloseHover(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
// prev and next
|
||||
else {
|
||||
overlay.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
handlePNClick(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
handlePNClick(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'mouseover',
|
||||
() => {
|
||||
handlePNHover(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
overlay.addEventListener(
|
||||
'focus',
|
||||
() => {
|
||||
handlePNHover(navItem)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
navOverlay.append(overlay)
|
||||
}
|
||||
active.addWatcher(() => {
|
||||
if (active.get()) {
|
||||
navOverlay.classList.add('active')
|
||||
} else {
|
||||
navOverlay.classList.remove('active')
|
||||
}
|
||||
})
|
||||
container.append(navOverlay)
|
||||
window.addEventListener('keydown', handleKey, { passive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* hepler
|
||||
*/
|
||||
|
||||
function nextImage(): void {
|
||||
if (isAnimating.get()) return
|
||||
navigateVector.set('next')
|
||||
cordHist.set(
|
||||
cordHist.get().map((item) => {
|
||||
return { ...item, i: increment(item.i, state.get().length) }
|
||||
})
|
||||
)
|
||||
|
||||
incIndex()
|
||||
}
|
||||
|
||||
function prevImage(): void {
|
||||
if (isAnimating.get()) return
|
||||
navigateVector.set('prev')
|
||||
cordHist.set(
|
||||
cordHist.get().map((item) => {
|
||||
return { ...item, i: decrement(item.i, state.get().length) }
|
||||
})
|
||||
)
|
||||
|
||||
decIndex()
|
||||
}
|
||||
|
||||
function handleCloseClick(navItem: NavItem): void {
|
||||
handleClick(navItem)
|
||||
isLoading.set(false)
|
||||
}
|
||||
|
||||
function handleCloseHover(navItem: NavItem): void {
|
||||
loadedText = navItem
|
||||
setCustomCursor(navItem)
|
||||
}
|
||||
|
||||
function handlePNClick(navItem: NavItem): void {
|
||||
if (!isLoading.get()) handleClick(navItem)
|
||||
}
|
||||
|
||||
function handlePNHover(navItem: NavItem): void {
|
||||
loadedText = navItem
|
||||
if (isLoading.get()) setCustomCursor(loadingText)
|
||||
else setCustomCursor(navItem)
|
||||
}
|
||||
92
assets/ts/desktop/stageNav.tsx
Normal file
92
assets/ts/desktop/stageNav.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { For, type Accessor, type JSX, type Setter } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { decrement, increment, type Vector } from '../utils'
|
||||
|
||||
import type { HistoryItem } from './layout'
|
||||
|
||||
export 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]
|
||||
|
||||
// variables
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const navItems = [props.prevText, props.closeText, props.nextText] as const
|
||||
|
||||
// states
|
||||
const [state, { incIndex, decIndex }] = useState()
|
||||
|
||||
const stateLength = state().length
|
||||
|
||||
const prevImage: () => void = () => {
|
||||
props.setNavVector('prev')
|
||||
props.setCordHist((c) =>
|
||||
c.map((item) => {
|
||||
return { ...item, i: decrement(item.i, stateLength) }
|
||||
})
|
||||
)
|
||||
decIndex()
|
||||
}
|
||||
|
||||
const closeImage: () => void = () => {
|
||||
props.setIsOpen(false)
|
||||
}
|
||||
|
||||
const nextImage: () => void = () => {
|
||||
props.setNavVector('next')
|
||||
props.setCordHist((c) =>
|
||||
c.map((item) => {
|
||||
return { ...item, i: increment(item.i, stateLength) }
|
||||
})
|
||||
)
|
||||
incIndex()
|
||||
}
|
||||
|
||||
const handleClick: (item: NavItem) => void = (item) => {
|
||||
if (!props.isOpen() || props.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 (e.key === 'ArrowLeft') prevImage()
|
||||
else if (e.key === 'Escape') closeImage()
|
||||
else if (e.key === 'ArrowRight') nextImage()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="navOverlay" classList={{ active: props.active() }}>
|
||||
<For each={navItems}>
|
||||
{(item) => (
|
||||
<div
|
||||
class="overlay"
|
||||
onClick={() => {
|
||||
handleClick(item)
|
||||
}}
|
||||
onKeyDown={handleKey}
|
||||
onFocus={() => props.setHoverText(item)}
|
||||
onMouseOver={() => props.setHoverText(item)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Watchable } from '../globalUtils'
|
||||
|
||||
/**
|
||||
* types
|
||||
*/
|
||||
|
||||
export interface HistoryItem {
|
||||
i: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
export const cordHist = new Watchable<HistoryItem[]>([])
|
||||
export const isOpen = new Watchable<boolean>(false)
|
||||
export const active = new Watchable<boolean>(false)
|
||||
export const isLoading = new Watchable<boolean>(false)
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* interfaces
|
||||
*/
|
||||
|
||||
export interface DesktopImage extends HTMLImageElement {
|
||||
dataset: {
|
||||
hiUrl: string
|
||||
hiImgH: string
|
||||
hiImgW: string
|
||||
loUrl: string
|
||||
loImgH: string
|
||||
loImgW: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* utils
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
Watchable,
|
||||
decrement,
|
||||
getThresholdSessionIndex,
|
||||
increment
|
||||
} from './globalUtils'
|
||||
|
||||
/**
|
||||
* types
|
||||
*/
|
||||
|
||||
export type State = typeof defaultState
|
||||
export type NavVec = 'next' | 'none' | 'prev'
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
const thresholds = [
|
||||
{ threshold: 20, trailLength: 20 },
|
||||
{ threshold: 40, trailLength: 10 },
|
||||
{ threshold: 80, trailLength: 5 },
|
||||
{ threshold: 140, trailLength: 5 },
|
||||
{ threshold: 200, trailLength: 5 }
|
||||
]
|
||||
|
||||
const defaultState = {
|
||||
index: -1,
|
||||
length: 0,
|
||||
threshold: thresholds[getThresholdSessionIndex()].threshold,
|
||||
trailLength: thresholds[getThresholdSessionIndex()].trailLength
|
||||
}
|
||||
|
||||
export const state = new Watchable<State>(defaultState, false)
|
||||
export const isAnimating = new Watchable<boolean>(false)
|
||||
export const navigateVector = new Watchable<NavVec>('none')
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
export function initState(length: number): void {
|
||||
const s = state.get()
|
||||
s.length = length
|
||||
updateThreshold(s, 0)
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
export function setIndex(index: number): void {
|
||||
const s = state.get()
|
||||
s.index = index
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
export function incIndex(): void {
|
||||
const s = state.get()
|
||||
s.index = increment(s.index, s.length)
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
export function decIndex(): void {
|
||||
const s = state.get()
|
||||
s.index = decrement(s.index, s.length)
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
export function incThreshold(): void {
|
||||
let s = state.get()
|
||||
s = updateThreshold(s, 1)
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
export function decThreshold(): void {
|
||||
let s = state.get()
|
||||
s = updateThreshold(s, -1)
|
||||
state.set(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* helper
|
||||
*/
|
||||
|
||||
function updateThreshold(state: State, inc: number): State {
|
||||
const i = thresholds.findIndex((t) => state.threshold === t.threshold) + inc
|
||||
// out of bounds
|
||||
if (i < 0 || i >= thresholds.length) return state
|
||||
// storage the index so we can restore it even if we go to another page
|
||||
sessionStorage.setItem('thresholdsIndex', i.toString())
|
||||
const newItems = thresholds[i]
|
||||
return { ...state, ...newItems }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { initContainer } from './container'
|
||||
import { initState } from './globalState'
|
||||
import { initNav } from './nav'
|
||||
import { initResources } from './resources'
|
||||
|
||||
// this is the main entry point for the app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
main().catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
async function main(): Promise<void> {
|
||||
initContainer()
|
||||
const ijs = await initResources()
|
||||
initState(ijs.length)
|
||||
initNav()
|
||||
|
||||
if (ijs.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: it seems firefox and chromnium don't like top layer await
|
||||
// so we are using import then instead
|
||||
if (!isMobile()) {
|
||||
await import('./desktop/init')
|
||||
.then((d) => {
|
||||
d.initDesktop(ijs)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
} else {
|
||||
await import('./mobile/init')
|
||||
.then((m) => {
|
||||
m.initMobile(ijs)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* hepler
|
||||
*/
|
||||
|
||||
function isMobile(): boolean {
|
||||
return window.matchMedia('(hover: none)').matches
|
||||
}
|
||||
81
assets/ts/main.tsx
Normal file
81
assets/ts/main.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
lazy,
|
||||
type JSX
|
||||
} from 'solid-js'
|
||||
import { render } from 'solid-js/web'
|
||||
|
||||
import { getImageJSON } from './resources'
|
||||
import { StateProvider } from './state'
|
||||
|
||||
/**
|
||||
* interfaces
|
||||
*/
|
||||
|
||||
export interface Container extends HTMLDivElement {
|
||||
dataset: {
|
||||
next: string
|
||||
close: string
|
||||
prev: string
|
||||
loading: string
|
||||
}
|
||||
}
|
||||
|
||||
// container
|
||||
const container = document.getElementsByClassName('container')[0] as Container
|
||||
|
||||
// lazy components
|
||||
const Desktop = lazy(async () => await import('./desktop/layout'))
|
||||
const Mobile = lazy(async () => await import('./mobile/layout'))
|
||||
|
||||
function Main(): JSX.Element {
|
||||
// variables
|
||||
const [ijs] = createResource(getImageJSON)
|
||||
const isMobile = window.matchMedia('(hover: none)').matches
|
||||
|
||||
// states
|
||||
const [scrollable, setScollable] = createSignal(true)
|
||||
|
||||
createEffect(() => {
|
||||
if (scrollable()) {
|
||||
container.classList.remove('disableScroll')
|
||||
} else {
|
||||
container.classList.add('disableScroll')
|
||||
}
|
||||
})
|
||||
|
||||
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() ?? []}
|
||||
prevText={container.dataset.prev}
|
||||
closeText={container.dataset.close}
|
||||
nextText={container.dataset.next}
|
||||
loadingText={container.dataset.loading}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</StateProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <Main />, container)
|
||||
@@ -1,103 +0,0 @@
|
||||
import { container } from '../container'
|
||||
import { setIndex } from '../globalState'
|
||||
import { type ImageJSON } from '../resources'
|
||||
|
||||
import { slideUp } from './gallery'
|
||||
import { mounted } from './state'
|
||||
// eslint-disable-next-line sort-imports
|
||||
import { getRandom, onIntersection, type MobileImage } from './utils'
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
export let imgs: MobileImage[] = []
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
function handleClick(i: number): void {
|
||||
setIndex(i)
|
||||
slideUp()
|
||||
}
|
||||
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
|
||||
export function initCollection(ijs: ImageJSON[]): void {
|
||||
createCollection(ijs)
|
||||
// get container
|
||||
const collection = document
|
||||
.getElementsByClassName('collection')
|
||||
.item(0) as HTMLDivElement
|
||||
// add watcher
|
||||
mounted.addWatcher((o) => {
|
||||
if (o) {
|
||||
collection.classList.remove('hidden')
|
||||
} else {
|
||||
collection.classList.add('hidden')
|
||||
}
|
||||
})
|
||||
// get image elements
|
||||
imgs = Array.from(collection.getElementsByTagName('img')) as MobileImage[]
|
||||
// add event listeners
|
||||
imgs.forEach((img, i) => {
|
||||
// preload first 5 images on page load
|
||||
if (i < 5) {
|
||||
img.src = img.dataset.src
|
||||
}
|
||||
// event listeners
|
||||
img.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
handleClick(i)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
img.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
handleClick(i)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
// preload
|
||||
onIntersection(img, (entry) => {
|
||||
// no intersection, hold
|
||||
if (entry.intersectionRatio <= 0) return false
|
||||
// preload the i + 5th image, if it exists
|
||||
if (i + 5 < imgs.length) {
|
||||
imgs[i + 5].src = imgs[i + 5].dataset.src
|
||||
}
|
||||
// triggered
|
||||
return true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* helper
|
||||
*/
|
||||
|
||||
function createCollection(ijs: ImageJSON[]): void {
|
||||
// create container for images
|
||||
const _collection: HTMLDivElement = document.createElement('div')
|
||||
_collection.className = 'collection'
|
||||
// append images to container
|
||||
for (const [i, ij] of ijs.entries()) {
|
||||
// random x and y
|
||||
const x = i !== 0 ? getRandom(-25, 25) : 0
|
||||
const y = i !== 0 ? getRandom(-30, 30) : 0
|
||||
// element
|
||||
const e = document.createElement('img') as MobileImage
|
||||
e.dataset.src = ij.loUrl
|
||||
e.height = ij.loImgH
|
||||
e.width = ij.loImgW
|
||||
e.alt = ij.alt
|
||||
e.style.transform = `translate3d(${x}%, ${y - 50}%, 0)`
|
||||
_collection.append(e)
|
||||
}
|
||||
container.append(_collection)
|
||||
}
|
||||
133
assets/ts/mobile/collection.tsx
Normal file
133
assets/ts/mobile/collection.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
For,
|
||||
createEffect,
|
||||
on,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
import { useState } from '../state'
|
||||
|
||||
import type { MobileImage } from './layout'
|
||||
|
||||
function getRandom(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function onIntersection<T extends HTMLElement>(
|
||||
element: T,
|
||||
trigger: (arg0: IntersectionObserverEntry) => boolean
|
||||
): void {
|
||||
new IntersectionObserver((entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (trigger(entry)) {
|
||||
observer.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}).observe(element)
|
||||
}
|
||||
|
||||
export function Collection(props: {
|
||||
children?: JSX.Element
|
||||
ijs: ImageJSON[]
|
||||
isAnimating: Accessor<boolean>
|
||||
isOpen: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
// variables
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const imgs: MobileImage[] = Array<MobileImage>(props.ijs.length)
|
||||
|
||||
// states
|
||||
const [state, { setIndex }] = useState()
|
||||
|
||||
// helper functions
|
||||
const handleClick: (i: number) => void = (i) => {
|
||||
if (props.isAnimating()) return
|
||||
setIndex(i)
|
||||
props.setIsOpen(true)
|
||||
}
|
||||
|
||||
const scrollToActive: () => void = () => {
|
||||
imgs[state().index].scrollIntoView({ behavior: 'auto', block: 'center' })
|
||||
}
|
||||
|
||||
// effects
|
||||
onMount(() => {
|
||||
imgs.forEach((img, i) => {
|
||||
// preload first 5 images on page load
|
||||
if (i < 5) {
|
||||
img.src = img.dataset.src
|
||||
}
|
||||
// event listeners
|
||||
img.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
handleClick(i)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
img.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
handleClick(i)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
// preload
|
||||
onIntersection(img, (entry) => {
|
||||
// no intersection, hold
|
||||
if (entry.intersectionRatio <= 0) return false
|
||||
// preload the i + 5th image, if it exists
|
||||
if (i + 5 < imgs.length) {
|
||||
imgs[i + 5].src = imgs[i + 5].dataset.src
|
||||
}
|
||||
// triggered
|
||||
return true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
props.isOpen()
|
||||
},
|
||||
() => {
|
||||
if (!props.isOpen()) scrollToActive() // scroll to active when closed
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="collection">
|
||||
<For each={props.ijs}>
|
||||
{(ij, i) => (
|
||||
<img
|
||||
ref={imgs[i()]}
|
||||
height={ij.loImgH}
|
||||
width={ij.loImgW}
|
||||
data-src={ij.loUrl}
|
||||
alt={ij.alt}
|
||||
style={{
|
||||
transform: `translate3d(${i() !== 0 ? getRandom(-25, 25) : 0}%, ${i() !== 0 ? getRandom(-30, 30) : 0}%, 0)`
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClick(i())
|
||||
}}
|
||||
onKeyDown={() => {
|
||||
handleClick(i())
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { type gsap } from 'gsap'
|
||||
import { type Swiper } from 'swiper'
|
||||
|
||||
import { container, scrollable } from '../container'
|
||||
import { isAnimating, navigateVector, setIndex, state } from '../globalState'
|
||||
import { createDivWithClass, expand, loadGsap, removeDuplicates } from '../globalUtils'
|
||||
import { type ImageJSON } from '../resources'
|
||||
|
||||
import { mounted } from './state'
|
||||
// eslint-disable-next-line sort-imports
|
||||
import { capitalizeFirstLetter, loadSwiper, type MobileImage } from './utils'
|
||||
|
||||
/**
|
||||
* variables
|
||||
*/
|
||||
|
||||
let galleryInner: HTMLDivElement
|
||||
let gallery: HTMLDivElement
|
||||
let curtain: HTMLDivElement
|
||||
let indexDiv: HTMLDivElement
|
||||
let navDiv: HTMLDivElement
|
||||
let indexDispNums: HTMLSpanElement[] = []
|
||||
let galleryImages: MobileImage[] = []
|
||||
let collectionImages: MobileImage[] = []
|
||||
|
||||
let _gsap: typeof gsap
|
||||
let _swiper: Swiper
|
||||
|
||||
/**
|
||||
* state
|
||||
*/
|
||||
|
||||
let lastIndex = -1
|
||||
let libLoaded = false
|
||||
|
||||
/**
|
||||
* main functions
|
||||
*/
|
||||
|
||||
export function slideUp(): void {
|
||||
if (isAnimating.get() || !libLoaded) return
|
||||
isAnimating.set(true)
|
||||
|
||||
_gsap.to(curtain, {
|
||||
opacity: 1,
|
||||
duration: 1
|
||||
})
|
||||
|
||||
_gsap.to(gallery, {
|
||||
y: 0,
|
||||
ease: 'power3.inOut',
|
||||
duration: 1,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
// cleanup
|
||||
scrollable.set(false)
|
||||
isAnimating.set(false)
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
function slideDown(): void {
|
||||
if (isAnimating.get()) return
|
||||
isAnimating.set(true)
|
||||
scrollToActive()
|
||||
|
||||
_gsap.to(gallery, {
|
||||
y: '100%',
|
||||
ease: 'power3.inOut',
|
||||
duration: 1
|
||||
})
|
||||
|
||||
_gsap.to(curtain, {
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
delay: 0.4
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
// cleanup
|
||||
scrollable.set(true)
|
||||
isAnimating.set(false)
|
||||
lastIndex = -1
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
|
||||
export function initGallery(ijs: ImageJSON[]): void {
|
||||
// create gallery
|
||||
constructGallery(ijs)
|
||||
// get elements
|
||||
indexDispNums = Array.from(
|
||||
indexDiv.getElementsByClassName('num') ?? []
|
||||
) as HTMLSpanElement[]
|
||||
galleryImages = Array.from(gallery.getElementsByTagName('img')) as MobileImage[]
|
||||
collectionImages = Array.from(
|
||||
document
|
||||
.getElementsByClassName('collection')
|
||||
.item(0)
|
||||
?.getElementsByTagName('img') ?? []
|
||||
) as MobileImage[]
|
||||
// state watcher
|
||||
state.addWatcher((o) => {
|
||||
if (o.index === lastIndex)
|
||||
return // change slide only when index is changed
|
||||
else if (lastIndex === -1)
|
||||
navigateVector.set('none') // lastIndex before set
|
||||
else if (o.index < lastIndex)
|
||||
navigateVector.set('prev') // set navigate vector for galleryLoadImages
|
||||
else if (o.index > lastIndex)
|
||||
navigateVector.set('next') // set navigate vector for galleryLoadImages
|
||||
else navigateVector.set('none') // default
|
||||
changeSlide(o.index) // change slide to new index
|
||||
updateIndexText() // update index text
|
||||
lastIndex = o.index // update last index
|
||||
})
|
||||
// mounted watcher
|
||||
mounted.addWatcher((o) => {
|
||||
if (!o) return
|
||||
scrollable.set(true)
|
||||
})
|
||||
// dynamic import
|
||||
window.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
loadSwiper()
|
||||
.then((S) => {
|
||||
_swiper = new S(galleryInner, { spaceBetween: 20 })
|
||||
_swiper.on('slideChange', ({ realIndex }) => {
|
||||
setIndex(realIndex)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
libLoaded = true
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
// mounted
|
||||
mounted.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* helper
|
||||
*/
|
||||
|
||||
function changeSlide(slide: number): void {
|
||||
galleryLoadImages()
|
||||
_swiper.slideTo(slide, 0)
|
||||
}
|
||||
|
||||
function scrollToActive(): void {
|
||||
collectionImages[state.get().index].scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'auto'
|
||||
})
|
||||
}
|
||||
|
||||
function updateIndexText(): void {
|
||||
const indexValue: string = expand(state.get().index + 1)
|
||||
const indexLength: string = expand(state.get().length)
|
||||
indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
if (i < 4) {
|
||||
e.innerText = indexValue[i]
|
||||
} else {
|
||||
e.innerText = indexLength[i - 4]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function galleryLoadImages(): void {
|
||||
let activeImagesIndex: number[] = []
|
||||
const currentIndex = state.get().index
|
||||
const nextIndex = Math.min(currentIndex + 1, state.get().length - 1)
|
||||
const prevIndex = Math.max(currentIndex - 1, 0)
|
||||
switch (navigateVector.get()) {
|
||||
case 'next':
|
||||
activeImagesIndex = [nextIndex]
|
||||
break
|
||||
case 'prev':
|
||||
activeImagesIndex = [prevIndex]
|
||||
break
|
||||
case 'none':
|
||||
activeImagesIndex = [currentIndex, nextIndex, prevIndex]
|
||||
break
|
||||
}
|
||||
removeDuplicates(activeImagesIndex).forEach((i) => {
|
||||
const e = galleryImages[i]
|
||||
if (e.src === e.dataset.src) return // already loaded
|
||||
e.src = e.dataset.src
|
||||
})
|
||||
}
|
||||
|
||||
function constructGalleryNav(): void {
|
||||
// index
|
||||
indexDiv = document.createElement('div')
|
||||
indexDiv.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
`<span class="num"></span><span class="num"></span><span class="num"></span><span class="num"></span>
|
||||
<span>/</span>
|
||||
<span class="num"></span><span class="num"></span><span class="num"></span><span class="num"></span>`
|
||||
)
|
||||
// close
|
||||
const closeDiv = document.createElement('div')
|
||||
closeDiv.innerText = capitalizeFirstLetter(container.dataset.close)
|
||||
closeDiv.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
slideDown()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
closeDiv.addEventListener(
|
||||
'keydown',
|
||||
() => {
|
||||
slideDown()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
// nav
|
||||
navDiv = createDivWithClass('nav')
|
||||
navDiv.append(indexDiv, closeDiv)
|
||||
}
|
||||
|
||||
function constructGalleryInner(ijs: ImageJSON[]): void {
|
||||
// swiper wrapper
|
||||
const swiperWrapper = createDivWithClass('swiper-wrapper')
|
||||
// loading text
|
||||
const loadingText = container.dataset.loading + '...'
|
||||
for (const ij of ijs) {
|
||||
// swiper slide
|
||||
const swiperSlide = createDivWithClass('swiper-slide')
|
||||
// loading indicator
|
||||
const l = createDivWithClass('loadingText')
|
||||
l.innerText = loadingText
|
||||
// img
|
||||
const e = document.createElement('img') as MobileImage
|
||||
e.dataset.src = ij.hiUrl
|
||||
e.height = ij.hiImgH
|
||||
e.width = ij.hiImgW
|
||||
e.alt = ij.alt
|
||||
e.style.opacity = '0'
|
||||
// load event
|
||||
e.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
if (state.get().index !== ij.index) {
|
||||
_gsap.set(e, { opacity: 1 })
|
||||
_gsap.set(l, { opacity: 0 })
|
||||
} else {
|
||||
_gsap.to(e, { opacity: 1, delay: 0.5, duration: 0.5, ease: 'power3.out' })
|
||||
_gsap.to(l, { opacity: 0, duration: 0.5, ease: 'power3.in' })
|
||||
}
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
// parent container
|
||||
const p = createDivWithClass('slideContainer')
|
||||
// append
|
||||
p.append(e, l)
|
||||
swiperSlide.append(p)
|
||||
swiperWrapper.append(swiperSlide)
|
||||
}
|
||||
// swiper node
|
||||
galleryInner = createDivWithClass('galleryInner')
|
||||
galleryInner.append(swiperWrapper)
|
||||
}
|
||||
|
||||
function constructGallery(ijs: ImageJSON[]): void {
|
||||
/**
|
||||
* gallery
|
||||
* |- galleryInner
|
||||
* |- swiper-wrapper
|
||||
* |- swiper-slide
|
||||
* |- img
|
||||
* |- swiper-slide
|
||||
* |- img
|
||||
* |- ...
|
||||
* |- nav
|
||||
* |- index
|
||||
* |- close
|
||||
*/
|
||||
// gallery
|
||||
gallery = createDivWithClass('gallery')
|
||||
constructGalleryInner(ijs)
|
||||
constructGalleryNav()
|
||||
gallery.append(galleryInner, navDiv)
|
||||
|
||||
/**
|
||||
* curtain
|
||||
*/
|
||||
curtain = createDivWithClass('curtain')
|
||||
|
||||
/**
|
||||
* container
|
||||
* |- gallery
|
||||
* |- curtain
|
||||
*/
|
||||
container.append(gallery, curtain)
|
||||
}
|
||||
268
assets/ts/mobile/gallery.tsx
Normal file
268
assets/ts/mobile/gallery.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { type gsap } from 'gsap'
|
||||
import {
|
||||
createEffect,
|
||||
For,
|
||||
on,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter
|
||||
} from 'solid-js'
|
||||
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 { capitalizeFirstLetter, GalleryNav } from './galleryNav'
|
||||
import type { MobileImage } from './layout'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export 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
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const imgs: MobileImage[] = Array<MobileImage>(props.ijs.length)
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const loadingDivs: HTMLDivElement[] = Array<HTMLDivElement>(props.ijs.length)
|
||||
let curtain: HTMLDivElement | undefined
|
||||
let gallery: HTMLDivElement | undefined
|
||||
let galleryInner: HTMLDivElement | undefined
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const _loadingText = capitalizeFirstLetter(props.loadingText)
|
||||
|
||||
// states
|
||||
let lastIndex = -1
|
||||
let libLoaded = false
|
||||
let mounted = false
|
||||
let navigateVector: Vector = 'none'
|
||||
|
||||
const [state, { setIndex }] = useState()
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
_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)
|
||||
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
|
||||
}
|
||||
removeDuplicates(activeImagesIndex).forEach((i) => {
|
||||
const e = imgs[i]
|
||||
if (e.src === e.dataset.src) return // already loaded
|
||||
e.src = e.dataset.src
|
||||
})
|
||||
}
|
||||
|
||||
const changeSlide: (slide: number) => void = (slide) => {
|
||||
// we are already in the gallery, don't need to
|
||||
// check mounted or libLoaded
|
||||
galleryLoadImages()
|
||||
_swiper.slideTo(slide, 0)
|
||||
}
|
||||
|
||||
// effects
|
||||
onMount(() => {
|
||||
imgs.forEach((img, i) => {
|
||||
const loadingDiv = loadingDivs[i]
|
||||
img.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
if (state().index !== parseInt(img.dataset.index)) {
|
||||
_gsap.set(img, { opacity: 1 })
|
||||
_gsap.set(loadingDiv, { opacity: 0 })
|
||||
} else {
|
||||
_gsap.to(img, { opacity: 1, delay: 0.5, duration: 0.5, ease: 'power3.out' })
|
||||
_gsap.to(loadingDiv, { opacity: 0, duration: 0.5, ease: 'power3.in' })
|
||||
}
|
||||
},
|
||||
{ once: true, passive: true }
|
||||
)
|
||||
})
|
||||
window.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
loadGsap()
|
||||
.then((g) => {
|
||||
_gsap = g
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
loadSwiper()
|
||||
.then((S) => {
|
||||
invariant(galleryInner, 'galleryInner is not defined')
|
||||
_swiper = new S(galleryInner, { spaceBetween: 20 })
|
||||
_swiper.on('slideChange', ({ realIndex }) => {
|
||||
setIndex(realIndex)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
libLoaded = true
|
||||
},
|
||||
{ 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
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
props.isOpen()
|
||||
},
|
||||
() => {
|
||||
if (props.isAnimating()) return
|
||||
if (props.isOpen()) slideUp()
|
||||
else slideDown()
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={gallery} class="gallery">
|
||||
<div ref={galleryInner} class="galleryInner">
|
||||
<div class="swiper-wrapper">
|
||||
<For each={props.ijs}>
|
||||
{(ij, i) => (
|
||||
<div class="swiper-slide">
|
||||
<div class="slideContainer">
|
||||
<img
|
||||
ref={imgs[i()]}
|
||||
height={ij.hiImgH}
|
||||
width={ij.hiImgW}
|
||||
data-src={ij.hiUrl}
|
||||
data-index={ij.index}
|
||||
alt={ij.alt}
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
<div ref={loadingDivs[i()]} class="loadingText">
|
||||
{_loadingText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<GalleryNav
|
||||
closeText={props.closeText}
|
||||
isAnimating={props.isAnimating}
|
||||
setIsOpen={props.setIsOpen}
|
||||
/>
|
||||
</div>
|
||||
<div ref={curtain} class="curtain" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
assets/ts/mobile/galleryNav.tsx
Normal file
74
assets/ts/mobile/galleryNav.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createEffect, on, type Accessor, type JSX, type Setter } from 'solid-js'
|
||||
|
||||
import { useState } from '../state'
|
||||
import { expand } from '../utils'
|
||||
|
||||
export function capitalizeFirstLetter(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
export function GalleryNav(props: {
|
||||
children?: JSX.Element
|
||||
closeText: string
|
||||
isAnimating: Accessor<boolean>
|
||||
setIsOpen: Setter<boolean>
|
||||
}): JSX.Element {
|
||||
// variables
|
||||
const indexNums: HTMLSpanElement[] = Array<HTMLSpanElement>(8)
|
||||
|
||||
// states
|
||||
const [state] = useState()
|
||||
const stateLength = state().length
|
||||
|
||||
// helper functions
|
||||
const updateIndexText: () => void = () => {
|
||||
const indexValue: string = expand(state().index + 1)
|
||||
const indexLength: string = expand(stateLength)
|
||||
indexNums.forEach((e: HTMLSpanElement, i: number) => {
|
||||
if (i < 4) {
|
||||
e.innerText = indexValue[i]
|
||||
} else {
|
||||
e.innerText = indexLength[i - 4]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onClick: () => void = () => {
|
||||
if (props.isAnimating()) return
|
||||
props.setIsOpen(false)
|
||||
}
|
||||
|
||||
// effects
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
state()
|
||||
},
|
||||
() => {
|
||||
updateIndexText()
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="nav">
|
||||
<div>
|
||||
<span ref={indexNums[0]} class="num" />
|
||||
<span ref={indexNums[1]} class="num" />
|
||||
<span ref={indexNums[2]} class="num" />
|
||||
<span ref={indexNums[3]} class="num" />
|
||||
<span>/</span>
|
||||
<span ref={indexNums[4]} class="num" />
|
||||
<span ref={indexNums[5]} class="num" />
|
||||
<span ref={indexNums[6]} class="num" />
|
||||
<span ref={indexNums[7]} class="num" />
|
||||
</div>
|
||||
<div onClick={onClick} onKeyDown={onClick}>
|
||||
{capitalizeFirstLetter(props.closeText)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { type ImageJSON } from '../resources'
|
||||
|
||||
import { initCollection } from './collection'
|
||||
import { initGallery } from './gallery'
|
||||
|
||||
export function initMobile(ijs: ImageJSON[]): void {
|
||||
initCollection(ijs)
|
||||
initGallery(ijs)
|
||||
}
|
||||
50
assets/ts/mobile/layout.tsx
Normal file
50
assets/ts/mobile/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createSignal, type JSX, type Setter } from 'solid-js'
|
||||
|
||||
import type { ImageJSON } from '../resources'
|
||||
|
||||
import { Collection } from './collection'
|
||||
import { Gallery } from './gallery'
|
||||
|
||||
/**
|
||||
* interfaces
|
||||
*/
|
||||
|
||||
export interface MobileImage extends HTMLImageElement {
|
||||
dataset: {
|
||||
src: string
|
||||
index: string
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Watchable } from '../globalUtils'
|
||||
|
||||
export const mounted = new Watchable<boolean>(false)
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type Swiper } from 'swiper'
|
||||
|
||||
/**
|
||||
* interfaces
|
||||
*/
|
||||
|
||||
export interface MobileImage extends HTMLImageElement {
|
||||
dataset: {
|
||||
src: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* utils
|
||||
*/
|
||||
|
||||
export function getRandom(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
export function onIntersection<T extends HTMLElement>(
|
||||
element: T,
|
||||
trigger: (arg0: IntersectionObserverEntry) => boolean
|
||||
): void {
|
||||
new IntersectionObserver((entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (trigger(entry)) {
|
||||
observer.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}).observe(element)
|
||||
}
|
||||
|
||||
export function capitalizeFirstLetter(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
export async function loadSwiper(): Promise<typeof Swiper> {
|
||||
const s = await import('swiper')
|
||||
return s.Swiper
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface ImageJSON {
|
||||
hiImgW: number
|
||||
}
|
||||
|
||||
export async function initResources(): Promise<ImageJSON[]> {
|
||||
export async function getImageJSON(): Promise<ImageJSON[]> {
|
||||
if (document.title.split(' | ')[0] === '404') {
|
||||
return [] // no images on 404 page
|
||||
}
|
||||
|
||||
136
assets/ts/state.tsx
Normal file
136
assets/ts/state.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { type gsap } from 'gsap'
|
||||
|
||||
/**
|
||||
* types
|
||||
*/
|
||||
|
||||
export type Vector = 'prev' | 'next' | 'none'
|
||||
|
||||
/**
|
||||
* utils
|
||||
*/
|
||||
@@ -31,39 +37,3 @@ export function removeDuplicates<T>(arr: T[]): T[] {
|
||||
if (arr.length < 2) return arr // optimization
|
||||
return [...new Set(arr)]
|
||||
}
|
||||
|
||||
export function createDivWithClass(className: string): HTMLDivElement {
|
||||
const div = document.createElement('div')
|
||||
if (className === '') return div // optimization
|
||||
div.classList.add(className)
|
||||
return div
|
||||
}
|
||||
|
||||
/**
|
||||
* custom "reactive" object
|
||||
*/
|
||||
|
||||
export class Watchable<T> {
|
||||
constructor(
|
||||
private obj: T,
|
||||
private readonly lazy: boolean = true
|
||||
) {}
|
||||
|
||||
private readonly watchers: Array<(arg0: T) => void> = []
|
||||
|
||||
get(): T {
|
||||
return this.obj
|
||||
}
|
||||
|
||||
set(e: T): void {
|
||||
if (e === this.obj && this.lazy) return
|
||||
this.obj = e
|
||||
this.watchers.forEach((watcher) => {
|
||||
watcher(this.obj)
|
||||
})
|
||||
}
|
||||
|
||||
addWatcher(watcher: (arg0: T) => void): void {
|
||||
this.watchers.push(watcher)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user