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