Skip to content

Instantly share code, notes, and snippets.

@pijng
Forked from zerobias/h.ts
Created September 30, 2019 11:07
Show Gist options
  • Save pijng/cfa798c1a029754ca34d3a4ea7e404f1 to your computer and use it in GitHub Desktop.
Save pijng/cfa798c1a029754ca34d3a4ea7e404f1 to your computer and use it in GitHub Desktop.
Declarative stack-based DOM api

H

Declarative stack-based DOM api

Usage

import {createStore, createEvent} from 'effector'
import {using, DOMData, DOMText, DOMHandler, h} from './h'

using(document.body, () => {
  const addLine = createEvent()
  const code = createStore('let foo = 0;')
    .on(addLine, code => `${code}\nfoo += ${Math.random()}`)
  
  h('section', () => {
    DOMData({section: 'controls'})
    h('button', () => {
      DOMHandler({click: addLine})
      DOMText('Add line')
    })
  })
  h('section', () => {
    DOMData({vizion: 'code'})
    VizionSectionHeader('code')
    h('pre', () => {
      DOMText(code)
    })
  })
})

Component is just a plain function

function VizionSectionHeader(text) {
  h('header', () => {
    DOMData({vizionSectionHeader: true})
    h('h4', () => {
      DOMText(text)
    })
  })
}
import {
createStore,
createEvent,
is,
clearNode,
forward
} from 'effector'
const USE_RAF_FOR_DOM = false
const nodeStack = []
export function h(tag, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
if (opts === undefined) opts = {}
const {
type = 'html',
attr = {},
data = {},
child = [],
noAppend = false
} = opts
const node =
type === 'svg'
? document.createElementNS('http://www.w3.org/2000/svg', tag)
: document.createElement(tag)
for (const key in attr) {
if (!isFalse(attr[key])) {
node.setAttribute(key, attr[key])
}
}
for (const key in data) {
if (!isFalse(data[key])) {
node.dataset[key] = data[key]
}
}
appendChild(node, child)
if (!noAppend) {
if (cb) using(node, () => cb(node))
if (nodeStack.length > 0) {
planAppendChild(node)
}
}
return node
}
export function g({data = {}, attr = {}, child = []} = {}) {
return h('g', {type: 'svg', data, attr, child})
}
export function using(node, cb) {
nodeStack.push({node, append: []})
try {
return cb()
} finally {
appendBatch(nodeStack.pop())
}
}
export function appendBatch({node, append}) {
if (append.length === 0) return
const frag = document.createDocumentFragment()
for (let i = 0; i < append.length; i++) {
frag.appendChild(append[i])
}
node.appendChild(frag)
}
function planAppendChild(node) {
stackAppendHead().push(node)
}
function stackAppendHead() {
return nodeStack[nodeStack.length - 1].append
}
function stackHead() {
return nodeStack[nodeStack.length - 1].node
}
function appendChild(node, child) {
if (child == null) return
if (Array.isArray(child)) {
for (const item of child) {
appendChild(node, item)
}
return
}
switch (typeof child) {
case 'string':
case 'number':
case 'boolean':
node.appendChild(new Text(child.toString()))
return
}
node.appendChild(child)
}
export function DOMData(abortSignal, dataset) {
return updateDOMDataset(stackHead(), abortSignal, dataset)
}
export function DOMVisible(abortSignal, visible) {
return updateDOMVisibility(
stackHead(),
nodeStack[nodeStack.length - 2].node,
abortSignal,
visible
)
}
export function DOMAttr(abortSignal, map) {
return updateDOMAttrMap(stackHead(), abortSignal, map)
}
export function DOMText(abortSignal, store) {
return updateDOMText(stackHead(), abortSignal, store)
}
export function DOMArray(listStore, opts, item) {
if (item === undefined && typeof opts === 'function') {
item = opts
opts = {item}
} else if (typeof item === 'function') {
opts.item = item
}
return arrayDOMChange(stackHead(), listStore, opts)
}
export function DOMHandler(options, map) {
if (map === undefined) {
map = options
options = {}
}
const node = stackHead()
const {passive = true, handleTouchStart = true, abort = null} = options
const handlerOptions = {passive}
const addTouchstart =
handleTouchStart && 'click' in map && !('touchstart' in map)
for (const key in map) {
node.addEventListener(key, map[key], handlerOptions)
}
let touchStartWatcher
if (addTouchstart) {
touchStartWatcher = map.click.watch(e => {
if (e.type === 'touchstart') e.preventDefault()
})
node.addEventListener('touchstart', map.click, {passive: false})
}
if (abort) {
const stop = abort.watch(() => {
stop()
for (const key in map) {
node.removeEventListener(key, map[key], handlerOptions)
}
if (touchStartWatcher) {
touchStartWatcher()
node.removeEventListener('touchstart', map.click, {passive: false})
}
})
}
return abort
}
function updateDOMText(node, abortSignal, store) {
if (store === undefined) {
store = abortSignal
abortSignal = null
}
const setText = text => {
const textNode = new Text(`${text}`)
const firstChild = node.firstChild
if (firstChild) {
firstChild.replaceWith(textNode)
} else {
node.appendChild(textNode)
}
}
if (is.unit(store)) {
return addViewWatcher(node, abortSignal, store, setText)
} else {
setText(store)
return abortSignal
}
}
function updateDOMDataset(node, abortSignal, dataset) {
if (dataset === undefined) {
dataset = abortSignal
abortSignal = null
}
if (!abortSignal) {
abortSignal = createEvent()
}
const setField = (field, value) => {
if (isFalse(value)) {
delete node.dataset[field]
} else {
node.dataset[field] = `${value}`
}
}
for (const field in dataset) {
if (is.unit(dataset[field])) {
addViewWatcher(node, abortSignal, dataset[field], value => {
setField(field, value)
})
} else {
setField(field, dataset[field])
}
}
return abortSignal
}
function updateDOMVisibility(node, parent, abortSignal, visible) {
if (visible === undefined) {
visible = abortSignal
abortSignal = null
}
return addViewWatcher(node, abortSignal, visible, visible => {
if (visible) {
if (!parent.contains(node)) {
parent.appendChild(node)
}
} else {
node.remove()
}
})
}
function arrayDOMChange(
node,
listStore,
{
item,
text,
attr = {},
data = {},
visible,
signal = createEvent()
}
) {
const nodeToSignal = new Map()
const parentSignal = signal
const size = listStore.map(list => list.length)
if (typeof item === 'string') {
const tag = item
item = () => h(tag)
}
let additional = 0
const foundStackRec = nodeStack.find(item => item.node === node)
if (foundStackRec) additional = foundStackRec.append.length
const initialLength = node.childNodes.length
let executedInitial = false
return addViewWatcher(node, signal, size, () => {
const list = listStore.getState()
let childNodesLength = node.childNodes.length - (initialLength + additional)
if (!executedInitial) {
executedInitial = true
const foundStackRecCurrent = nodeStack.find(item => item.node === node)
if (foundStackRec === foundStackRecCurrent) {
childNodesLength += additional
}
}
while (childNodesLength > list.length) {
childNodesLength -= 1
const child = node.lastChild
if (!child) continue
child.remove()
const signal = nodeToSignal.get(child)
nodeToSignal.delete(child)
if (signal) signal()
}
if (childNodesLength >= list.length) return
noStack(() => {
const appended = []
for (let i = childNodesLength; i < list.length; i++) {
const signal = createEvent()
const childStore = listStore.map(list => list[i])
const firstState = childStore.getState()
const child = item({
signal,
value: firstState,
store: childStore,
index: i,
list
})
const childAttr = {}
const childDataset = {}
const owned = []
for (const key in attr) {
const value = attr[key]
if (typeof value !== 'function') {
child.setAttribute(key, value)
} else {
childAttr[key] = childStore.map(value)
owned.push(childAttr[key])
}
}
for (const key in data) {
const value = data[key]
if (typeof value !== 'function') {
child.dataset[key] = value
} else {
childDataset[key] = childStore.map(value)
owned.push(childDataset[key])
}
}
if (text) {
const content = childStore.map(text)
updateDOMText(child, signal, content)
owned.push(content)
}
owned.push(childStore)
updateDOMDataset(child, signal, childDataset)
updateDOMAttrMap(child, signal, childAttr)
if (visible) {
const visibility = childStore.map(visible)
owned.push(visibility)
updateDOMVisibility(child, node, signal, visibility)
} else {
appended.push(child)
}
forward({
from: parentSignal,
to: signal
})
signal.watch(() => {
for (let i = 0; i < owned.length; i++) {
clearNode(owned[i])
}
clearNode(signal)
})
nodeToSignal.set(child, signal)
}
appendBatch({node, append: appended})
})
})
}
function updateDOMAttrMap(node, abortSignal, map) {
if (map === undefined) {
map = abortSignal
abortSignal = createEvent()
}
for (const attr in map) {
addViewWatcher(node, abortSignal, map[attr], value => {
node.setAttribute(attr, `${value}`)
})
}
return abortSignal
}
function addViewWatcher(node, abortSignal, source, cb) {
const DOM_TAG = '__ABORT_SIGNALS__'
if (!abortSignal) {
abortSignal = createEvent()
}
if (!(DOM_TAG in node)) {
node[DOM_TAG] = new Map()
}
if (!node[DOM_TAG].has(abortSignal)) {
const watcher = abortSignal.watch(() => {
watcher()
node[DOM_TAG].delete(abortSignal)
})
node[DOM_TAG].set(abortSignal, [watcher])
}
node[DOM_TAG]
.get(abortSignal)
.push(debounceRaf(abortSignal, source, cb))
return abortSignal
}
function debounceRaf(abortSignal, source, cb) {
let rafActive = false
let nextUpdate
let id
const fn = () => {
rafActive = false
cb(nextUpdate)
}
const stopSignal = abortSignal.watch(() => {
stopSignal()
stop()
rafActive = false
nextUpdate = null
cancelAnimationFrame(id)
})
const updater = USE_RAF_FOR_DOM
? data => {
nextUpdate = data
if (rafActive) return
rafActive = true
id = requestAnimationFrame(fn)
}
: cb
const stop = source.watch(updater)
return stopSignal
}
function isFalse(val) {
return val === false || val === undefined || val === null
}
function noStack(cb) {
const savedStack = [...nodeStack]
try {
cb()
} finally {
nodeStack.length = 0
nodeStack.push(...savedStack)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment