Skip to content

Instantly share code, notes, and snippets.

@fuunnx
Last active October 11, 2017 08:12
Show Gist options
  • Save fuunnx/1cf4d830585d61f9ef8ce3db21ec065e to your computer and use it in GitHub Desktop.
Save fuunnx/1cf4d830585d61f9ef8ce3db21ec065e to your computer and use it in GitHub Desktop.
Cycle Imperative DOM driver
import fromEvent from 'xstream/extra/fromEvent'
import xs from 'xstream'
export default function jqueryDriver () {
return (instruction$) => {
instruction$.addListener({
next: fn => {
if (typeof fn === 'function') {fn()}
else {fn.call()}
},
error: err => {throw err},
complete: () => {},
})
const attachListeners$ = instruction$
.filter(({reAttachListeners}) => !!reAttachListeners)
.startWith('')
return api({path: '', selector: '', root: document})
function api ({path = '', selector = '', root = document}) {
function getElements (selector_) {
if(!selector_) return [root]
return [...root.querySelectorAll(selector_)]
}
return {
scope: (selector_, fn) => {
const sel = (selector + ' ' + selector_).trim()
const path_ = (path + ' ' + sel).trim()
return xs.merge(
...getElements(sel).map(x => fn(api({selector: '', root: x, path: path_})))
)
},
select: (selector_) => {
const sel = (selector + ' ' + selector_).trim()
const path_ = (path + ' ' + sel).trim()
return api({selector: sel, root, path: path_})
},
events: (event) => {
return attachListeners$
.map(() => getElements(selector))
.map(els => els.map(el => fromEvent(el, event)))
.map(ev$s => xs.merge(...ev$s))
.flatten()
},
elements: () => attachListeners$.map(() => getElements(selector)),
element: () => attachListeners$.map(() => getElements(selector)[0]),
window: () => ({
element: () => xs.of(window),
elements: () => xs.of([window]),
events: (event) => fromEvent(window, event),
}),
}
}
}
}
import RowSlider from './rowSlider'
import xs from 'xstream'
export function main (sources) {
// select every [data-rowslider] and run an instance of RowSlider on them
const rowSliders$ = sources.DOM.scope('[data-rowslider]',
DOM => RowSlider({...sources, DOM}).DOM
)
return {
DOM: rowSliders$,
}
}
export default main
import concat from 'xstream/extra/concat'
import actions from './actions'
import intent from './intent'
import model from './model'
import xs from 'xstream'
export function RowSlider ({DOM, Time}) {
const element$ = DOM.element()
const row$ = DOM.select('[data-rowslider-row]').element()
const rows$ = DOM.select('[data-rowslider-row]').elements()
const scroller$ = DOM.select('[data-rowslider-scroller]').element()
const receptacle$ = DOM.select('[data-rowslider-receptacle]').element()
const intents = intent({DOM, Time})
const position$ = model(intents)
const rowWidth$ = intents.resize$.startWith({})
.mapTo(row$)
.flatten()
.map(({clientWidth}) => clientWidth)
const init$ = xs.combine(row$, scroller$)
.take(1)
.map(actions.init)
const autoScroll$ = xs.combine(
intents.isScrollable$,
position$,
rows$,
rowWidth$,
)
.filter(([isScrollable]) => isScrollable)
.map(([, ...rest]) => rest)
.map(actions.autoScroll)
const scrollLoop$ = xs.combine(
intents.isScrollable$,
rowWidth$,
intents.scrollLeft$,
scroller$,
)
.filter(([isScrollable, rowWidth, scrollLeft]) => isScrollable && scrollLeft > rowWidth || scrollLeft < rowWidth)
.map(([, ...rest]) => rest)
.map(actions.scrollLoop)
const displayInfos$ = xs.combine(
receptacle$,
intents.displayInfos$,
)
.map(actions.toggleFocus)
const makeScrollable$ = xs.combine(
element$,
intents.isScrollable$,
)
.map(actions.toggleScrollable)
const resetScroll$ = intents.isScrollable$
.filter(x => !x)
.map(() => xs.combine(rows$, scroller$)).flatten()
.map(actions.resetScroll)
return {
DOM: concat(init$, xs.merge(
makeScrollable$,
displayInfos$,
resetScroll$,
autoScroll$,
scrollLoop$,
)),
}
}
export default RowSlider
import xs from 'xstream'
export function intent ({Time, DOM}) {
const {speed$: defaultAcceleration$} = params(DOM)
const scroller = DOM.select('[data-rowslider-scroller]')
const scroller$ = scroller.element()
const row$ = DOM.select('[data-rowslider-row]').element()
const logos = DOM.select('[data-rowslider-element]')
const mouseenter$ = logos.events('mouseenter')
const mouseleave$ = logos.events('mouseleave')
const mouseTrigger$ = xs.merge(
mouseenter$.mapTo(true),
mouseleave$.mapTo(false),
)
.compose(Time.debounce(100))
.startWith(false)
const focus$ = logos.events('focus')
const blur$ = logos.events('blur')
const focusTrigger$ = xs.merge(
focus$.map(x => x.target),
blur$.mapTo(false),
)
.startWith(false)
const scrollStart$ = scroller.events('scroll')
.compose(Time.throttleAnimation)
const scrollEnd$ = scrollStart$.compose(Time.debounce(25))
const isScrolling$ = xs.merge(
scrollStart$.mapTo(true),
scrollEnd$.mapTo(false),
)
.startWith(false)
const pause$ = xs.combine(
mouseTrigger$,
focusTrigger$,
isScrolling$,
)
.map(([mouse, focus, scrolling]) => mouse || focus || scrolling)
.startWith(false)
const resize$ = DOM.window().events('resize')
const displayInfos$ = focusTrigger$
const slideLeft$ = slideButtonIntent({Time, DOM}, '[data-rowslider-slide-left]')
const slideRight$ = slideButtonIntent({Time, DOM}, '[data-rowslider-slide-right]')
const scrollLeft$ = scrollStart$.map(x => x.target.scrollLeft)
const deltaTime$ = Time.animationFrames().map(({delta}) => delta)
const normalizedDeltaTime$ = Time.animationFrames().map(({normalizedDelta}) => normalizedDelta)
const isScrollable$ = resize$.startWith({})
.compose(Time.throttleAnimation)
.map(() => xs.combine(scroller$, row$)).flatten()
.map(([scroller, row]) => {
const {paddingLeft, paddingRight} = window.getComputedStyle(scroller)
const innerWidth = scroller.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight)
return row.clientWidth > innerWidth
})
return {
normalizedDeltaTime$,
defaultAcceleration$,
displayInfos$,
isScrollable$,
scrollLeft$,
slideRight$,
slideLeft$,
deltaTime$,
resize$,
pause$,
}
}
export default intent
function slideButtonIntent ({Time, DOM}, selector) {
return xs.merge(
DOM.select(selector)
.events('click').mapTo(true),
DOM.select(selector)
.events('mousedown').mapTo(true),
DOM.select(selector)
.events('mouseup').compose(Time.delay(1)).mapTo(false),
DOM.select(selector)
.events('mouseleave').mapTo(false),
DOM.select(selector)
.events('blur').mapTo(false),
)
.startWith(false)
}
function params (DOMElement) {
const params$ = DOMElement.element()
.map(x => x.getAttribute('data-rowslider'))
.map(x => JSON.parse(x) || {})
return {
speed$: params$.map(x => parseFloat(x.speed))
.map(x => (!x && x != 0) ? 1 : x), // default to 1 if x is not a number
}
}
import sampleCombine from 'xstream/extra/sampleCombine'
import xs from 'xstream'
export function model ({pause$, slideRight$, slideLeft$, normalizedDeltaTime$, deltaTime$, defaultAcceleration$}) {
const friction$ = pause$.map(paused =>
paused ? 1.25 : 1.01
)
const acceleration$ = xs.combine(
defaultAcceleration$,
slideRight$,
slideLeft$,
)
.map(([defaultAcceleration, slideRight, slideLeft]) => {
if (slideRight && slideLeft) return 0
if (slideRight) return 150
if (slideLeft) return -150
return defaultAcceleration
})
.map(x => x / 1000)
const speed$ = normalizedDeltaTime$.compose(sampleCombine(
friction$,
acceleration$,
))
.fold(function (currentSpeed, [normalizedDelta, friction, acceleration]) {
return Math.min((currentSpeed + acceleration / normalizedDelta) / friction, 3)
}, 0)
.map(x => x * 10) // magic number so it's ± between 0 and 1
.map(x => x * 15 / 1000) // max normal speed, magic number again
.map(x => (Math.abs(x) < 2 /1000) ? 0 : x) // prevents to move when speed is really low, magic number again
const position$ = xs.combine(deltaTime$, speed$)
.fold((pos, [delta, speed]) => pos + speed * delta, 0)
return position$
}
export default model
export function init ([$row, $scroller]) {
return {
call: () => {
$scroller.appendChild($row.cloneNode(true))
$scroller.appendChild($row.cloneNode(true))
$scroller.appendChild($row.cloneNode(true))
$scroller.appendChild($row.cloneNode(true))
},
reAttachListeners: true,
}
}
export function autoScroll ([pos, $rows, rowWidth]) {
return () => $rows.forEach($row => {
$row.style.transform = `translateX(-${rowWidth + pos % rowWidth}px)`
})
}
export function scrollLoop ([rowWidth, scrollLeft, $scroller]) {
return () => $scroller.scrollLeft = rowWidth + scrollLeft % rowWidth
}
export function toggleFocus ([$receptacle, $focusedElement]) {
return () => {
emptyNode($receptacle)
if ($focusedElement) {
const $clone = $focusedElement.cloneNode(true)
const {left, width} = $focusedElement.getBoundingClientRect()
$receptacle.classList.add('-visible')
$receptacle.style.transform = `translateX(${left + width / 2}px)`
$clone.classList.add('-extended')
$clone.style.width = width
$receptacle.appendChild($clone)
}
if (!$focusedElement) {
$receptacle.classList.remove('-visible')
}
}
}
export function toggleScrollable ([$element, isScrollable]) {
return () => {
if (isScrollable) {
$element.classList.add('-scrollable')
$element.classList.remove('-largeenough')
} else {
$element.classList.remove('-scrollable')
$element.classList.add('-largeenough')
}
}
}
export function resetScroll ([$rows, $scroller]) {
return () => {
$rows.forEach($row => {
$row.style.transform = `translateX(0px)`
})
$scroller.scrollLeft = 0
}
}
export default {init, autoScroll, scrollLoop, toggleFocus, toggleScrollable, resetScroll}
function emptyNode (nodeElement) {
let firstChild = nodeElement.firstChild
while(firstChild) {
nodeElement.removeChild(firstChild)
firstChild = nodeElement.firstChild
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment