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: dc6860991c

For 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:
Spedon
2024-02-22 01:18:29 +08:00
committed by GitHub
parent 1acf24a519
commit febbd7d45d
46 changed files with 2402 additions and 1653 deletions

View File

@@ -17,7 +17,3 @@ a,
button {
cursor: pointer;
}
.hide {
display: none;
}

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

@@ -1,3 +0,0 @@
import { Watchable } from '../globalUtils'
export const mounted = new Watchable<boolean>(false)

View File

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

View File

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

View File

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