Skip to content

Instantly share code, notes, and snippets.

@zerobias
Last active Apr 18, 2022
Embed
What would you like to do?
Declarative stack-based DOM api

H

Declarative stack-based DOM api

Usage

import {createStore, createEvent} from 'effector'
import {using, Data, Text, Handler, h} from './h'

using(document.body, () => {
  const addLine = createEvent()
  const code = createStore('let foo = 0;')
    .on(addLine, code => `${code}\nfoo += ${Math.random()}`)
  
  
  h('section', () => {
    Data({timelineNames: true})
    List(timelineRows, ({store}) => {
      h('div', () => {
        Data({timelineName: true})
        Text(store)
      })
    })
  })
  h('section', () => {
    Data({section: 'controls'})
    h('button', () => {
      Handler({click: addLine})
      Text('Add line')
    })
  })
})

Component is just a plain function

function VizionSectionHeader(text) {
  h('header', () => {
    Data({vizionSectionHeader: true})
    h('h4', () => {
      Text(text)
    })
  })
}
import {
createStore,
createEvent,
is,
clearNode,
forward,
sample,
Store,
Event,
launch
} from 'effector'
import {beginMark, endMark} from './mark'
import {
planTaskOnce,
debounceRaf,
noDebounceRaf,
planTask,
deleteTask,
startTime
} from './renderer'
import {
NSType,
PropertyMap,
HElement,
KeyedRecord,
TransformMap,
StoreOrData,
StylePropertyMap
} from './index.h'
import {
nodeStack,
signalStack,
typeStack,
svgRootStack,
stackAppendHead,
getSignal,
stackHead
} from './stack'
function isFalse(val) {
return val === false || val === undefined || val === null
}
export function using(node: any, cb: () => any): void
export function using(node, cb) {
nodeStack.push({node, append: []})
try {
return cb()
} finally {
appendBatch(nodeStack.pop())
}
}
function usingWithSignal(node: any, signal: Event<void>, cb: () => any) {
nodeStack.push({node, append: []})
signalStack.push(signal)
try {
return cb()
} finally {
appendBatch(nodeStack.pop())
signalStack.pop()
}
}
function appendBatch({node, append, reverse = false}) {
if (append.length === 0) return
const frag = document.createDocumentFragment()
if (!reverse) {
for (let i = 0; i < append.length; i++) {
frag.appendChild(append[i])
}
node.appendChild(frag)
} else {
for (let i = append.length - 1; i >= 0; i--) {
frag.appendChild(append[i])
}
node.prepend(frag)
}
}
export function h(tag: string, cb: (node: HElement) => void): HElement
export function h(
tag: string,
opts: {type?: 'svg'; noAppend?: boolean},
cb?: (node: HElement) => void
): HElement
export function h(tag, opts, cb?: any) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
if (opts === undefined) opts = {}
const {noAppend = false} = opts
const parentNS = typeStack[typeStack.length - 1]
let type = 'html'
if ('type' in opts) {
type = opts.type
} else {
type = parentNS === 'svg' ? 'svg' : 'html'
}
if (tag === 'svg') type = 'svg'
const node =
type === 'svg'
? document.createElementNS('http://www.w3.org/2000/svg', tag)
: document.createElement(tag)
let needToPop = false
let isSvgRoot = false
if (parentNS === 'foreignObject') {
node.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
typeStack.push('html')
needToPop = true
} else if (tag === 'svg') {
node.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
typeStack.push('svg')
needToPop = true
svgRootStack.push(node)
isSvgRoot = true
} else if (tag === 'foreignObject') {
typeStack.push('foreignObject')
needToPop = true
}
node.__SIGNAL__ = createSignal()
if (cb) {
try {
usingWithSignal(node, node.__SIGNAL__, () => cb(node))
} finally {
if (needToPop) typeStack.pop()
if (isSvgRoot) svgRootStack.pop()
}
}
if (!noAppend) {
planAppendChild(node)
}
return node
}
function createSignal() {
const signal = createEvent()
signal.watch(() => {
planTaskOnce(signal, clearNode)
})
const parent = getSignal()
if (parent) {
forward({
from: parent,
to: signal
})
}
return signal
}
function planAppendChild(node) {
if (nodeStack.length > 0) {
stackAppendHead().push(node)
}
}
function domOperation(
immediate: boolean,
signal: Event<void>,
data: any,
handler: (data: any) => void
) {
if (is.unit(data)) {
;(immediate ? noDebounceRaf : debounceRaf)(signal, data, handler)
} else {
handler(data)
}
}
export function Handler(map: {[event: string]: Event<globalThis.Event>}): void
export function Handler(
options: {passive?: boolean; capture?: boolean},
map: {[event: string]: Event<globalThis.Event>}
): void
export function Handler(options, map?: any) {
if (map === undefined) {
map = options
options = {}
}
const node = stackHead()
const {passive = true, capture = false} = options
const handlerOptions = {passive, capture}
for (const key in map) {
node.addEventListener(key, map[key], handlerOptions)
}
const stop = getSignal().watch(() => {
stop()
for (const key in map) {
node.removeEventListener(key, map[key], handlerOptions)
}
})
}
export function Data(dataset: PropertyMap): void
export function Data(dataset: PropertyMap) {
const node = stackHead()
const signal = getSignal()
for (const field in dataset) {
domOperation(false, signal, dataset[field], value => {
if (isFalse(value)) {
delete node.dataset[field]
} else {
node.dataset[field] = `${value}`
}
})
}
}
export function Visible(visible: Store<boolean>): void
export function Visible(visible: any) {
const node = stackHead()
const parent = nodeStack[nodeStack.length - 2].node
debounceRaf(getSignal(), visible, visible => {
if (visible) {
if (!parent.contains(node)) {
parent.appendChild(node)
}
} else {
node.remove()
}
})
}
export function Style({
prop = {},
val = {}
}: {
prop?: StylePropertyMap
val?: PropertyMap
}) {
const node = stackHead() as any
const signal = getSignal()
const style: CSSStyleDeclaration = node.style
for (const propName in prop) {
domOperation(false, signal, prop[propName], value => {
if (isFalse(value)) {
style.removeProperty(propName)
} else {
style[propName] = value
}
})
}
for (const varShortName in val) {
const variableName = `--${varShortName}`
domOperation(false, signal, val[varShortName], value => {
if (isFalse(value)) {
style.removeProperty(variableName)
} else {
style.setProperty(variableName, value)
}
})
}
}
export function Attr(map: PropertyMap): void
export function Attr(map: PropertyMap) {
const node = stackHead()
const signal = getSignal()
for (const attr in map) {
domOperation(attr !== 'value', signal, map[attr], value => {
if (isFalse(value)) {
node.removeAttribute(attr)
} else {
node.setAttribute(attr, `${value}`)
}
})
}
}
function applyTransform<T>(
svg: SVGSVGElement,
signal: Event<void>,
transformList: SVGTransformList,
data: StoreOrData<T>,
handler: (data: T, transform: SVGTransform) => void
) {
const transform = svg.createSVGTransform()
domOperation(false, signal, data, function transformHandler(data) {
handler(data, transform)
})
transformList.appendItem(transform)
}
const transformResolvers = {
translate({x = 0, y = 0}: {x?: number; y?: number}, transform: SVGTransform) {
transform.setTranslate(x, y)
},
scale({x = 0, y = 0}: {x?: number; y?: number}, transform: SVGTransform) {
transform.setScale(x, y)
},
rotate(
data:
| number
| {
angle?: number
x?: number
y?: number
},
transform: SVGTransform
) {
if (typeof data === 'number') {
transform.setRotate(data, 0, 0)
} else {
transform.setRotate(data.angle || 0, data.x || 0, data.y || 0)
}
},
skewX(angle: number, transform: SVGTransform) {
transform.setSkewX(angle)
},
skewY(angle: number, transform: SVGTransform) {
transform.setSkewY(angle)
}
}
export function Transform(operations: Partial<TransformMap>) {
const node = stackHead()
const signal = getSignal()
const transformList: SVGTransformList = node.transform.baseVal
const svg = svgRootStack[svgRootStack.length - 1]
for (const key in operations) {
applyTransform(
svg,
signal,
transformList,
operations[key],
transformResolvers[key]
)
}
}
function setText(node, text) {
const textNode = new Text(`${text}`)
const firstChild = node.firstChild
if (firstChild) {
firstChild.replaceWith(textNode)
} else {
node.appendChild(textNode)
}
}
function DOMText(
value: Store<string | number> | Event<string | number> | string | number
): void
function DOMText(store: any) {
const node = stackHead()
if (is.unit(store)) {
debounceRaf(getSignal(), store, setText.bind(null, node))
} else {
setText(node, store)
}
}
function planRemoveKeyedNode({nodes, signal}: KeyedRecord<any>) {
for (let i = 0; i < nodes.length; i++) {
nodes[i].remove()
}
clearNode(signal, {deep: true})
}
function withSavedStack(context: ListContext, list: KeyedRecord<any>[]) {
const {savedTypeStack, savedSVGStack, signal, shortName} = context
beginMark('append [' + shortName + ']')
const currentNodeStack = [...nodeStack]
const currentTypeStack = [...typeStack]
const currentSignalStack = [...signalStack]
const currentSvgStack = [...svgRootStack]
nodeStack.length = 0
typeStack.length = 0
signalStack.length = 0
svgRootStack.length = 0
typeStack.push(...savedTypeStack)
svgRootStack.push(...savedSVGStack)
signalStack.push(signal)
try {
return appendNewKeyedRecords(context, list)
} finally {
nodeStack.push(...currentNodeStack)
typeStack.length = 0
typeStack.push(...currentTypeStack)
signalStack.length = 0
signalStack.push(...currentSignalStack)
svgRootStack.length = 0
svgRootStack.push(...currentSvgStack)
endMark('append [' + shortName + ']')
}
}
function appendNewKeyedRecords(
{parentNode, cb, reverse}: ListContext,
list: KeyedRecord<any>[]
) {
const nodes: HElement[] = []
const appended: HElement[] = []
nodeStack.push({
node: parentNode,
append: appended
})
let ret = -1
for (let i = 0; i < list.length; i++) {
const item: KeyedRecord<any> = list[i]
if (!item.active) continue
if (Date.now() - startTime >= 5) {
ret = i
break
}
cb(item)
for (let j = 0; j < appended.length; j++) {
const child = appended[j]
forward({
from: item.signal,
to: child.__SIGNAL__
})
item.nodes.push(child)
nodes.push(child)
}
appended.length = 0
}
nodeStack.pop()
planTaskOnce(
{
node: parentNode,
append: nodes,
reverse
},
appendBatch
)
return ret
}
function getInitialUpdate<T>(
signal,
update,
source
): {
newRecords: KeyedRecord<T>[]
resultRecords: KeyedRecord<T>[]
} {
signalStack.push(signal)
try {
return update([], source.getState())
} finally {
signalStack.pop()
}
}
function applyNewRecords<T>(context: ListContext, list: KeyedRecord<T>[]) {
if (list.length === 0) return
const continueFrom = withSavedStack(context, list)
if (continueFrom !== -1) {
planTaskOnce(list.slice(continueFrom), applyNewRecords.bind(null, context))
}
}
type ListContext = {
parentNode: Element
cb: (opts: any) => void
shortName: string
reverse: boolean
savedTypeStack: NSType[]
savedSVGStack: SVGSVGElement[]
signal: Event<void>
}
export function List<T>(
source: Store<T[]>,
cb: (opts: {store: Store<T>; index: number; signal: Event<void>}) => void
): void
export function List<T, K extends keyof T>(
{
key,
source,
reverse
}: {
key: T[K] extends (string | number | symbol) ? K : never
source: Store<T[]>
reverse?: boolean
},
cb: (opts: {store: Store<T>; key: T[K]; signal: Event<void>}) => void
): void
export function List<T>(opts, cb: (opts: any) => void) {
let source
let reverse = false
let getID: (item: T, i: number) => string | number | symbol
if (is.store(opts)) {
getID = (item, i) => i
source = opts
} else {
const key = opts.key
getID = (item, i) => item[key]
source = opts.source
reverse = !!opts.reverse
}
const parentNode: Element = stackHead()
const parentSignal = createSignal()
const savedTypeStack = [...typeStack]
const savedSVGStack = [...svgRootStack]
const triggerUpdate = createEvent<T[]>()
const {taskID, updater} = planTask(triggerUpdate)
const updates = createStore(getInitialUpdate<T>(parentSignal, update, source))
const resultRecords = updates.map(({resultRecords}) => resultRecords)
const newRecords = updates.map(({newRecords}) => newRecords)
own(parentSignal, [updates, resultRecords, newRecords, triggerUpdate])
const stop = source.updates.watch(updater)
parentSignal.watch(() => {
stop()
deleteTask(taskID)
})
sample({
source: resultRecords,
clock: triggerUpdate,
fn: update,
target: updates
})
const context: ListContext = {
parentNode,
cb,
shortName: source.shortName,
reverse,
savedTypeStack,
savedSVGStack,
signal: parentSignal
}
newRecords.watch(applyNewRecords.bind(null, context))
function update(records: Array<KeyedRecord<T>>, input: T[]) {
beginMark('list [' + source.shortName + ']')
const skipNode: boolean[] = Array(input.length).fill(false)
const keys = input.map(getID)
const newRecords: Array<KeyedRecord<T>> = []
const resultRecords: Array<KeyedRecord<T>> = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
const index = keys.indexOf(record.key)
if (index !== -1) {
resultRecords.push(record)
skipNode[index] = true
if (record.store.getState() !== input[index])
//@ts-ignore
launch(record.store, input[index], true)
} else {
//@ts-ignore
launch(record.signal)
}
}
for (let i = 0; i < input.length; i++) {
if (skipNode[i]) continue
const item = input[i]
const store = createStore(item)
const signal = createSignal()
own(signal, [store])
signal.watch(() => {
keyed.active = false
planTaskOnce(keyed, planRemoveKeyedNode)
})
const id = getID(item, i)
const keyed: KeyedRecord<T> = {
key: id,
index: id,
store,
signal,
nodes: [],
active: true
}
newRecords.push(keyed)
resultRecords.push(keyed)
}
endMark('list [' + source.shortName + ']')
return {
newRecords,
resultRecords
}
}
}
export {DOMText as Text}
function own(ownerUnit, ownedUnits) {
const owner = getGraph(ownerUnit)
for (let i = 0; i < ownedUnits.length; i++) {
const link = getGraph(ownedUnits[i])
link.family.type = 'crosslink'
const owners = getOwners(link)
const links = getLinks(owner)
if (!owners.includes(owner)) owners.push(owner)
if (!links.includes(link)) links.push(link)
}
}
const getGraph = unit => unit.graphite || unit
const getOwners = node => node.family.owners
const getLinks = node => node.family.links
import {Store, Event} from 'effector'
export type DOMProperty = string | number | null | boolean
export type PropertyMap = {[field: string]: Store<DOMProperty> | DOMProperty}
export type StylePropertyMap = Partial<{
[K in keyof CSSStyleDeclaration]: Store<DOMProperty> | DOMProperty
}>
export type NSType = 'html' | 'svg' | 'foreignObject'
export type StackRecord = {
node: any
append: any[]
}
export type HElement = Element & {__SIGNAL__: Event<void>}
export type KeyedRecord<T> = {
key: string | number | symbol
index: string | number | symbol
signal: Event<void>
store: Store<T>
nodes: HElement[]
active: boolean
}
export type StoreOrData<T> = Store<T> | T
export type TransformMap = {
translate: StoreOrData<{
x?: number
y?: number
}>
scale: StoreOrData<{
x?: number
y?: number
}>
rotate: StoreOrData<
| {
angle?: number
x?: number
y?: number
}
| number
>
skewX: StoreOrData<number>
skewY: StoreOrData<number>
}
function formatMarkName(markName: string) {
return '☄️ ' + markName
}
export function beginMark(markName: string) {
performance.mark(formatMarkName('' + markName + ' start'))
}
export function endMark(label: string) {
const formattedMarkName = formatMarkName('' + label + ' start')
const formattedLabel = formatMarkName(label)
try {
performance.measure(formattedLabel, formattedMarkName)
} catch (err) {} // Clear marks immediately to avoid growing buffer.
performance.clearMarks(formattedMarkName)
performance.clearMeasures(formattedLabel)
}
import {createEvent, launch, Subscription, Event} from 'effector'
import {beginMark, endMark} from './mark'
const addTask = createEvent()
let isBatched = false
function batchRAFrs(rs) {
isBatched = false
rs()
}
function batchRAF(rs) {
requestAnimationFrame(batchRAFrs.bind(null, rs))
}
const batchWindow = () => new Promise(batchRAF).then(executeTasks)
addTask.watch(() => {
if (isBatched) return
isBatched = true
batchWindow()
})
const taskCBs = new Map<number, (data: any) => any>()
const tasks = new Map<number, any>()
export let startTime = Date.now()
function executeTasks() {
if (tasks.size === 0) return
startTime = Date.now()
beginMark('task')
for (const [id, data] of tasks) {
if (Date.now() - startTime >= 5) {
launch(addTask, undefined)
break
}
tasks.delete(id)
const cb = taskCBs.get(id)
cb(data)
}
endMark('task')
}
let nextTaskID = -1
function RafUpdater(taskID: number, data: any) {
tasks.set(taskID, data)
if (!isBatched) {
//@ts-ignore
launch(addTask, undefined, true)
}
}
function RafBatchUpdater(taskID: number, data: any) {
const list = tasks.get(taskID)
if (list) {
list.push(data)
} else {
tasks.set(taskID, [data])
}
if (!isBatched) {
//@ts-ignore
launch(addTask, undefined, true)
}
}
function batchExec<T>(cb: (data: T) => any, taskID: number, list: T[]) {
const start = Date.now()
beginMark('batch')
for (let i = 0; i < list.length; i++) {
if (Date.now() - start > 5) {
tasks.set(taskID, list.slice(i))
endMark('batch')
return
}
cb(list[i])
}
endMark('batch')
}
export function planBatchTask<T>(
cb: (data: T) => any
): {
taskID: number
updater: (data: T) => any
} {
const taskID = ++nextTaskID
taskCBs.set(taskID, batchExec.bind(null, cb, taskID))
return {
taskID,
updater: RafBatchUpdater.bind(null, taskID)
}
}
export function planTask<T>(
cb: (data: T) => any
): {taskID: number; updater: (data: T) => any} {
const taskID = ++nextTaskID
taskCBs.set(taskID, cb)
return {
taskID,
updater: RafUpdater.bind(null, taskID)
}
}
export function deleteTask(taskID: number) {
tasks.delete(taskID)
taskCBs.delete(taskID)
}
export function planTaskOnce<T>(data: T, cb: (data: T) => any) {
const taskID = ++nextTaskID
taskCBs.set(taskID, data => {
taskCBs.delete(taskID)
cb(data)
})
tasks.set(taskID, data)
if (!isBatched) {
//@ts-ignore
launch(addTask, undefined, true)
}
}
export function noDebounceRaf<T>(
abortSignal: Event<void>,
source: any,
cb: (data: T) => any
): Subscription {
const stopSignal = abortSignal.watch(() => {
stopSignal()
stop()
})
const stop = source.watch(cb)
return stopSignal
}
export function debounceRaf<T>(
abortSignal: Event<void>,
source: any,
cb: (data: T) => any
): Subscription {
const stopSignal = abortSignal.watch(() => {
stopSignal()
stop()
deleteTask(taskID)
})
const {taskID, updater} = planTask(cb)
const stop = source.watch(updater)
return stopSignal
}
import {Event} from 'effector'
import {StackRecord, NSType} from './index.h'
export const nodeStack: StackRecord[] = []
export const typeStack: NSType[] = ['html', 'html']
export const svgRootStack: SVGSVGElement[] = []
export const signalStack: Event<void>[] = []
export function stackAppendHead() {
return nodeStack[nodeStack.length - 1].append
}
export function stackHead() {
return nodeStack[nodeStack.length - 1].node
}
export function getSignal(): Event<void> | null {
if (signalStack.length > 0) return signalStack[signalStack.length - 1]
return null
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment