Created
July 5, 2019 17:41
-
-
Save natew/9f59140f51b6a3cca92be69bf8ef9044 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { always, AppDefinition, AppIcon, createUsableStore, ensure, getAppDefinition, react, shallow, Templates, useReaction } from '@o/kit' | |
import { AppBit } from '@o/models' | |
import { Card, CardProps, fuzzyFilter, idFn, Row, useIntersectionObserver, useNodeSize, useParentNodeSize, useTheme, View } from '@o/ui' | |
import { numberBounder, numberScaler } from '@o/utils' | |
import React, { createRef, memo, useEffect, useLayoutEffect, useRef, useState } from 'react' | |
import { to, useSpring, useSprings } from 'react-spring' | |
import { useGesture } from 'react-use-gesture' | |
import { om, useOm } from '../../om/om' | |
import { queryStore } from '../../om/stores' | |
import { OrbitApp, whenIdle } from './OrbitApp' | |
import { appsDrawerStore } from './OrbitAppsDrawer' | |
class OrbitAppsCarouselStore { | |
props: { | |
apps: AppBit[] | |
setCarouselSprings: Function | |
setScrollSpring: Function | |
rowWidth: number | |
} = { | |
apps: [], | |
setCarouselSprings: idFn, | |
setScrollSpring: idFn, | |
rowWidth: 0, | |
} | |
// this is only the state that matters for animating | |
state = shallow({ | |
index: 0, | |
zoomedOut: true, | |
isDragging: false, | |
}) | |
get zoomedIn() { | |
return !this.state.zoomedOut | |
} | |
zoomIntoNextApp = false | |
nextFocusedIndex = -1 | |
focusedIndex = 0 | |
isScrolling = false | |
isZooming = false | |
rowNode: HTMLElement = null | |
rowRef = createRef<HTMLElement>() | |
setRowNode = (next: HTMLElement) => { | |
this.rowNode = next | |
// @ts-ignore | |
this.rowRef.current = next | |
} | |
get isAnimating() { | |
return this.isScrolling || this.isZooming | |
} | |
get apps() { | |
return this.props.apps | |
} | |
get searchableApps() { | |
return this.apps.map(x => ({ | |
name: `${x.name} ${x.identifier}`, | |
id: x.id, | |
})) | |
} | |
updateAnimation = react( | |
() => always(this.state), | |
() => this.props.setCarouselSprings(this.getSpring), | |
{ | |
log: false, | |
}, | |
) | |
zoomIntoCurrentApp() { | |
this.scrollToIndex(Math.round(this.state.index), true) | |
} | |
get currentNode(): HTMLElement | null { | |
if (this.rowNode && this.focusedIndex > -1) { | |
const elements = Array.from(this.rowNode.children) as HTMLElement[] | |
return elements[this.focusedIndex] || null | |
} | |
return null | |
} | |
ensureScrollLeftOnResize = react( | |
() => this.zoomedIn, | |
(zoomedIn, { useEffect }) => { | |
ensure('zoomedIn', zoomedIn) | |
useEffect(() => { | |
const onResize = () => { | |
const x = this.currentNode.offsetLeft | |
this.props.setScrollSpring({ x, config: { duration: 0 } }) | |
} | |
window.addEventListener('resize', onResize, { passive: true }) | |
return () => { | |
window.removeEventListener('resize', onResize) | |
} | |
}) | |
}, | |
) | |
// listen for pane movement | |
// doing it with nextPane allows us to load in apps later | |
scrollToIndex = (index: number, shouldZoomIn?: boolean) => { | |
if (shouldZoomIn) { | |
this.zoomIntoNextApp = true | |
} | |
this.nextFocusedIndex = index | |
} | |
updateScrollPane = react( | |
() => [this.nextFocusedIndex, this.zoomIntoNextApp], | |
async ([index], { when }) => { | |
await when(() => !!this.apps.length) | |
ensure('valid index', !!this.apps[index]) | |
this.animateAndScrollTo(index) | |
if (this.zoomIntoNextApp) { | |
this.setZoomedOut(false) | |
} | |
this.zoomIntoNextApp = false | |
this.nextFocusedIndex = -1 | |
}, | |
) | |
shouldZoomIn() { | |
this.zoomIntoNextApp = true | |
} | |
undoShouldZoomOnZoomChange = react( | |
() => this.state.zoomedOut, | |
() => { | |
this.zoomIntoNextApp = false | |
}, | |
) | |
scrollToSearchedApp = react( | |
() => queryStore.queryInstant, | |
async (query, { sleep }) => { | |
await sleep(40) | |
ensure('not on drawer', !appsDrawerStore.isOpen) | |
ensure('has apps', !!this.apps.length) | |
ensure('zoomed out', this.state.zoomedOut) | |
ensure('not zooming into next app', !this.zoomIntoNextApp) | |
if (query.indexOf(' ') > -1) { | |
// searching within app | |
const [_, firstWord] = query.split(' ') | |
if (firstWord.trim().length) { | |
this.state.zoomedOut = false | |
} | |
} else { | |
// searching apps | |
const searchedApp = fuzzyFilter(query, this.searchableApps)[0] | |
const curId = searchedApp ? searchedApp.id : this.apps[0].id | |
const appIndex = this.apps.findIndex(x => x.id === curId) | |
this.setFocusedAppIndex(appIndex, true) | |
} | |
}, | |
) | |
forceScrollToPane = react( | |
() => this.focusedIndex, | |
async (_, { sleep }) => { | |
await sleep(1000) | |
this.animateAndScrollTo(Math.round(this.state.index)) | |
}, | |
) | |
setFocusedAppIndex(next: number, forceScroll = false) { | |
if (!this.apps[next]) { | |
console.warn('no app at index', next) | |
return | |
} | |
if (next !== this.focusedIndex) { | |
this.focusedIndex = next | |
// update url | |
const id = `${this.apps[next].id}` | |
om.actions.router.showAppPage({ | |
id, | |
replace: true, | |
}) | |
if (forceScroll) { | |
this.animateAndScrollTo(this.focusedIndex) | |
} | |
} | |
} | |
setZoomedOut(next: boolean = true) { | |
this.state.zoomedOut = next | |
this.zoomIntoNextApp = false | |
} | |
right() { | |
if (this.focusedIndex < this.apps.length - 1) { | |
this.setFocusedAppIndex(this.focusedIndex + 1, true) | |
} | |
} | |
left() { | |
if (this.focusedIndex > 0) { | |
this.setFocusedAppIndex(this.focusedIndex - 1, true) | |
} | |
} | |
animateCardsTo = (index: number) => { | |
if (this.state.index !== index) { | |
const paneIndex = Math.round(index) | |
if (paneIndex !== this.focusedIndex) { | |
this.setFocusedAppIndex(paneIndex) | |
} | |
this.state.index = index | |
} | |
} | |
animateAndScrollTo = (index: number) => { | |
if (Math.round(index) !== this.focusedIndex) { | |
this.setFocusedAppIndex(index) | |
} | |
const x = this.props.rowWidth * index | |
this.props.setScrollSpring({ x }) | |
this.animateCardsTo(index) | |
} | |
isControlled = false | |
animateTo = (index: number) => { | |
this.isControlled = true | |
this.animateCardsTo(index) | |
} | |
// after scroll, select focused card | |
finishScroll = () => { | |
this.setFocusedAppIndex(Math.round(this.state.index)) | |
this.updateScrollPositionToIndex(this.state.index) | |
} | |
updateScrollPositionToIndex = (index: number) => { | |
this.props.setScrollSpring({ x: index * this.props.rowWidth, config: { duration: 0 } }) | |
} | |
outScaler = numberScaler(0, 1, 0.8, 0.9) | |
inScaler = numberScaler(0, 1, 0.9, 1) | |
boundRotation = numberBounder(-10, 10) | |
getSpring = (i: number) => { | |
const importance = Math.min(1, Math.max(0, 1 - Math.abs(this.state.index - i))) | |
const scaler = this.zoomedIn ? this.inScaler : this.outScaler | |
// zoom all further out of non-focused apps when zoomed in (so you cant see them behind transparent focused apps) | |
const scale = this.zoomedIn && importance !== 1 ? 0.25 : scaler(importance) | |
const ry = this.boundRotation((this.state.index - i) * 10) | |
return { | |
x: 0, | |
y: 0, | |
scale: scale * (this.state.isDragging ? 0.95 : 1), | |
ry, | |
} | |
} | |
onDrag = next => { | |
if (!this.state.zoomedOut) return | |
this.state.isDragging = next.dragging | |
const dx = -next.velocity * next.direction[0] * 30 | |
// console.log('next', next) | |
// avoid easy presses | |
if (Math.abs(dx) < 0.5) return | |
if (this.state.isDragging) { | |
const dI = dx / this.props.rowWidth | |
const nextI = Math.min(Math.max(0, this.state.index + dI), this.apps.length - 1) | |
this.animateAndScrollTo(nextI) | |
} else { | |
const paneIndex = Math.round(this.state.index) | |
this.animateAndScrollTo(paneIndex) | |
} | |
} | |
onFinishScroll = () => { | |
this.isScrolling = false | |
} | |
onStartScroll = () => { | |
this.isScrolling = true | |
} | |
onFinishZoom = () => { | |
this.isZooming = false | |
} | |
onStartZoom = () => { | |
this.isZooming = true | |
} | |
} | |
export const appsCarouselStore = createUsableStore(OrbitAppsCarouselStore) | |
export const useAppsCarousel = appsCarouselStore.useStore | |
window['appsCarousel'] = appsCarouselStore | |
export const OrbitAppsCarousel = memo(() => { | |
const { state } = useOm() | |
const rowRef = appsCarouselStore.rowRef | |
const apps = state.apps.activeClientApps | |
const frameRef = useRef<HTMLElement>(null) | |
const frameSize = useNodeSize({ ref: frameRef }) | |
const rowSize = useParentNodeSize({ ref: rowRef }) | |
const [scrollSpring, setScrollSpring] = useSpring(() => ({ | |
x: 0, | |
onRest: appsCarouselStore.onFinishScroll, | |
onStart: appsCarouselStore.onStartScroll, | |
})) | |
const [springs, setCarouselSprings] = useSprings(apps.length, i => ({ | |
...appsCarouselStore.getSpring(i), | |
config: { mass: 1, tension: 300, friction: 30 }, | |
onRest: appsCarouselStore.onFinishZoom, | |
onStart: appsCarouselStore.onStartZoom, | |
})) | |
useEffect(() => { | |
if (rowSize.width) { | |
appsCarouselStore.setProps({ | |
apps, | |
setCarouselSprings, | |
setScrollSpring, | |
rowWidth: rowSize.width, | |
}) | |
} | |
}, [apps, setScrollSpring, setCarouselSprings, rowSize]) | |
const bind = useGesture({ | |
onDrag: appsCarouselStore.onDrag, | |
}) | |
const [scrollable, isDisabled] = useReaction( | |
() => [ | |
appsCarouselStore.isScrolling || appsCarouselStore.state.zoomedOut ? ('x' as const) : false, | |
appsCarouselStore.state.zoomedOut === true, | |
], | |
async (next, { when, sleep }) => { | |
await when(() => !appsCarouselStore.isAnimating) | |
await sleep(100) | |
return next | |
}, | |
{ | |
defaultValue: [false, true], | |
delay: 50, | |
}, | |
) | |
useLayoutEffect(() => { | |
rowRef.current.scrollLeft = scrollSpring.x.getValue() | |
}, [scrollable]) | |
return ( | |
<View width="100%" height="100%" overflow="hidden" ref={frameRef}> | |
<Row | |
flex={1} | |
alignItems="center" | |
justifyContent="flex-start" | |
scrollable={scrollable} | |
overflow={scrollable ? undefined : 'hidden'} | |
onWheel={() => { | |
if (appsCarouselStore.state.zoomedOut) { | |
appsCarouselStore.animateTo(rowRef.current.scrollLeft / rowSize.width) | |
} | |
appsCarouselStore.finishScroll() | |
}} | |
scrollLeft={scrollSpring.x} | |
animated | |
ref={appsCarouselStore.setRowNode} | |
perspective="600px" | |
{...bind()} | |
> | |
{apps.map((app, index) => ( | |
<OrbitAppCard | |
key={app.id} | |
index={index} | |
app={app} | |
definition={getAppDefinition(app.identifier)} | |
isDisabled={isDisabled} | |
width={frameSize.width} | |
height={frameSize.height} | |
springs={springs} | |
/> | |
))} | |
</Row> | |
</View> | |
) | |
}) | |
/** | |
* Handles visibility of the app as it moves in and out of viewport | |
*/ | |
type OrbitAppCardProps = CardProps & { | |
isDisabled: boolean | |
springs: any | |
index: number | |
app: AppBit | |
definition: AppDefinition | |
} | |
const OrbitAppCard = memo( | |
({ app, definition, index, isDisabled, springs, ...cardProps }: OrbitAppCardProps) => { | |
const spring = springs[index] | |
const [renderApp, setRenderApp] = useState(false) | |
const theme = useTheme() | |
const isFocused = useReaction(() => index === appsCarouselStore.focusedIndex, { delay: 40 }, [ | |
index, | |
]) | |
const isFocusZoomed = useReaction( | |
() => index === appsCarouselStore.focusedIndex && !appsCarouselStore.state.zoomedOut, | |
{ | |
delay: 40, | |
}, | |
[index], | |
) | |
const cardRef = useRef(null) | |
/** | |
* These next hooks handle loading the app when not animating | |
*/ | |
const shouldRender = useRef(false) | |
const lastIntersection = useRef(null) | |
useReaction( | |
() => appsCarouselStore.isAnimating, | |
isAnimating => { | |
if (isAnimating) { | |
shouldRender.current = false | |
} else { | |
if (lastIntersection.current) { | |
setRenderApp(true) | |
} | |
} | |
}, | |
) | |
useIntersectionObserver({ | |
ref: cardRef, | |
options: { | |
threshold: 1, | |
}, | |
onChange(x) { | |
const isIntersecting = x.length && x[0].isIntersecting | |
lastIntersection.current = isIntersecting | |
if (isIntersecting && !renderApp) { | |
shouldRender.current = true | |
whenIdle().then(() => { | |
setTimeout(() => { | |
if (shouldRender.current) { | |
setRenderApp(true) | |
} | |
}, 50) | |
}) | |
} else { | |
shouldRender.current = false | |
} | |
}, | |
}) | |
return ( | |
<Card | |
data-is="OrbitAppCard" | |
ref={cardRef} | |
borderWidth={0} | |
background={isFocusZoomed ? theme.sidebarBackgroundTransparent : theme.backgroundStronger} | |
overflow="hidden" | |
borderRadius={isFocusZoomed ? 0 : 12} | |
onClick={() => { | |
appsCarouselStore.setFocusedAppIndex(index, true) | |
}} | |
onDoubleClick={() => { | |
appsCarouselStore.scrollToIndex(index, true) | |
}} | |
{...(isFocused | |
? { | |
boxShadow: [ | |
[0, 0, 0, 3, theme.alternates.selected['background']], | |
[0, 0, 30, [0, 0, 0, 0.5]], | |
], | |
} | |
: { | |
boxShadow: [[0, 0, 10, [0, 0, 0, 0.5]]], | |
})} | |
transition="box-shadow 200ms ease, background 300ms ease" | |
zIndex={isFocused ? 2 : 1} | |
animated | |
transform={to( | |
Object.keys(spring).map(k => spring[k]), | |
(x, y, scale, ry) => `translate3d(${x}px,${y}px,0) scale(${scale}) rotateY(${ry}deg)`, | |
)} | |
{...cardProps} | |
> | |
<AppLoadingScreen definition={definition} app={app} visible={!renderApp} /> | |
<OrbitApp | |
id={app.id} | |
isDisabled={isDisabled} | |
identifier={definition.id} | |
appDef={definition} | |
renderApp={renderApp} | |
/> | |
</Card> | |
) | |
}, | |
) | |
type AppLoadingScreenProps = { | |
visible: boolean | |
app: AppBit | |
definition: AppDefinition | |
} | |
const AppLoadingScreen = memo((props: AppLoadingScreenProps) => { | |
return ( | |
<Templates.Message | |
title={props.app.name} | |
subTitle={props.definition.id} | |
icon={<AppIcon identifier={props.definition.id} colors={props.app.colors} />} | |
opacity={props.visible ? 1 : 0} | |
transform={{ | |
y: props.visible ? 0 : 50, | |
}} | |
transition="all ease 200ms" | |
position="absolute" | |
top={0} | |
left={0} | |
right={0} | |
bottom={0} | |
/> | |
) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment