emoji-picker-element/src/picker/components/Picker/framework.js

326 lines
11 KiB
JavaScript
Raw Normal View History

2023-11-26 19:55:59 +01:00
import { getFromMap, parseTemplate, toString } from './utils.js'
2023-11-19 19:38:51 +01:00
2023-11-27 01:22:33 +01:00
const parseCache = new WeakMap()
2023-12-11 01:37:01 +01:00
const domInstancesCache = new WeakMap()
2023-11-26 19:22:02 +01:00
const unkeyedSymbol = Symbol('un-keyed')
2023-11-19 23:45:52 +01:00
2023-12-16 22:29:44 +01:00
// for debugging
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
window.parseCache = parseCache
window.domInstancesCache = domInstancesCache
}
// Not supported in Safari <=13
const hasReplaceChildren = 'replaceChildren' in Element.prototype
function replaceChildren (parentNode, newChildren) {
/* istanbul ignore else */
if (hasReplaceChildren) {
parentNode.replaceChildren(...newChildren)
} else { // polyfill Element.prototype.replaceChildren
while (parentNode.lastChild) {
parentNode.removeChild(parentNode.lastChild)
}
for (const child of newChildren) {
parentNode.appendChild(child)
}
}
}
2023-12-10 22:10:51 +01:00
function doChildrenNeedRerender (parentNode, newChildren) {
let oldChild = parentNode.firstChild
let oldChildrenCount = 0
// iterate using firstChild/nextSibling because browsers use a linked list under the hood
while (oldChild) {
const newChild = newChildren[oldChildrenCount]
// check if the old child and new child are the same
2023-12-10 22:14:03 +01:00
if (newChild !== oldChild) {
2023-12-10 22:10:51 +01:00
return true
}
oldChild = oldChild.nextSibling
oldChildrenCount++
}
2023-12-16 19:27:36 +01:00
/* istanbul ignore if */
2023-12-10 22:10:51 +01:00
if (process.env.NODE_ENV !== 'production' && oldChildrenCount !== parentNode.children.length) {
throw new Error('parentNode.children.length is different from oldChildrenCount, it should not be')
}
// if new children length is different from old, we must re-render
return oldChildrenCount !== newChildren.length
}
function patchChildren (newChildren, instanceBinding) {
const { targetNode } = instanceBinding
let { targetParentNode } = instanceBinding
2023-11-22 16:59:37 +01:00
let needsRerender = false
if (targetParentNode) { // already rendered once
needsRerender = doChildrenNeedRerender(targetParentNode, newChildren)
2023-11-22 16:59:37 +01:00
} else { // first render of list
needsRerender = true
instanceBinding.targetNode = undefined // placeholder comment not needed anymore, free memory
instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode
2023-11-22 16:59:37 +01:00
}
2023-12-10 22:10:51 +01:00
// console.log('needsRerender?', needsRerender, 'newChildren', newChildren)
2023-11-22 17:08:23 +01:00
// avoid re-rendering list if the dom nodes are exactly the same before and after
2023-11-22 16:59:37 +01:00
if (needsRerender) {
replaceChildren(targetParentNode, newChildren)
2023-11-22 16:59:37 +01:00
}
}
function patch (expressions, instanceBindings) {
for (const instanceBinding of instanceBindings) {
2023-11-24 17:10:58 +01:00
const {
targetNode,
currentExpression,
binding: {
expressionIndex,
attributeName,
attributeValuePre,
attributeValuePost
}
} = instanceBinding
2023-11-24 17:10:58 +01:00
const expression = expressions[expressionIndex]
2023-11-20 00:23:23 +01:00
if (currentExpression === expression) {
2023-11-24 17:10:58 +01:00
// no need to update, same as before
continue
}
instanceBinding.currentExpression = expression
2023-11-24 17:10:58 +01:00
2023-12-12 04:15:12 +01:00
if (attributeName) { // attribute replacement
targetNode.setAttribute(attributeName, attributeValuePre + toString(expression) + attributeValuePost)
} else { // text node / child element / children replacement
2023-11-24 17:10:58 +01:00
let newNode
2023-11-27 01:22:33 +01:00
if (Array.isArray(expression)) { // array of html tag templates
patchChildren(expression, instanceBinding)
2023-12-11 16:37:53 +01:00
} else if (expression instanceof Element) { // html tag template returning a DOM element
2023-12-10 21:35:28 +01:00
newNode = expression
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && newNode === targetNode) {
// it seems impossible for the framework to get into this state, may as well assert on it
// worst case scenario is we lose focus if we call replaceWith on the same node
throw new Error('the newNode and targetNode are the same, this should never happen')
2023-11-24 17:10:58 +01:00
}
targetNode.replaceWith(newNode)
2023-11-24 17:10:58 +01:00
} else { // primitive - string, number, etc
if (targetNode.nodeType === Node.TEXT_NODE) { // already transformed into a text node
targetNode.nodeValue = toString(expression)
} else { // replace comment or whatever was there before with a text node
newNode = document.createTextNode(toString(expression))
targetNode.replaceWith(newNode)
2023-11-20 00:23:23 +01:00
}
}
2023-11-24 17:10:58 +01:00
if (newNode) {
instanceBinding.targetNode = newNode
2023-11-24 17:10:58 +01:00
}
2023-11-20 00:23:23 +01:00
}
2023-11-20 07:57:57 +01:00
}
}
2023-11-20 00:23:23 +01:00
2023-11-20 07:57:57 +01:00
function parse (tokens) {
let htmlString = ''
let withinTag = false
let withinAttribute = false
let elementIndexCounter = -1 // depth-first traversal order
const elementsToBindings = new Map()
2023-11-20 07:57:57 +01:00
const elementIndexes = []
2023-11-27 01:22:33 +01:00
for (let i = 0, len = tokens.length; i < len; i++) {
2023-11-20 07:57:57 +01:00
const token = tokens[i]
htmlString += token
2023-11-19 23:45:52 +01:00
2023-11-27 01:22:33 +01:00
if (i === len - 1) {
2023-11-20 07:57:57 +01:00
break // no need to process characters
2023-11-20 02:16:32 +01:00
}
2023-11-19 23:45:52 +01:00
2023-11-20 07:57:57 +01:00
for (let j = 0; j < token.length; j++) {
const char = token.charAt(j)
switch (char) {
case '<': {
const nextChar = token.charAt(j + 1)
2023-12-16 20:16:55 +01:00
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !/[/a-z]/.test(nextChar)) {
// we don't need to support comments ('<!') because we always use html-minify-literals
// also we don't support '<' inside tags, e.g. '<div> 2 < 3 </div>'
throw new Error('framework currently only supports a < followed by / or a-z')
}
if (nextChar === '/') { // closing tag
2023-11-20 07:57:57 +01:00
// leaving an element
2023-11-27 01:22:33 +01:00
elementIndexes.pop()
2023-12-16 20:16:55 +01:00
} else { // not a closing tag
withinTag = true
elementIndexes.push(++elementIndexCounter)
2023-11-19 23:45:52 +01:00
}
2023-11-20 07:57:57 +01:00
break
}
case '>': {
withinTag = false
withinAttribute = false
break
}
case '=': {
2023-12-16 20:16:55 +01:00
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !withinTag) {
// we don't currently support '=' anywhere but inside a tag, e.g.
// we don't support '<div>2 + 2 = 4</div>'
throw new Error('framework currently does not support = anywhere but inside a tag')
2023-11-20 07:57:57 +01:00
}
2023-12-16 20:16:55 +01:00
withinAttribute = true
2023-11-20 07:57:57 +01:00
break
2023-11-19 23:45:52 +01:00
}
}
2023-11-20 07:57:57 +01:00
}
const elementIndex = elementIndexes[elementIndexes.length - 1]
let bindings = elementsToBindings.get(elementIndex)
2023-11-20 07:57:57 +01:00
if (!bindings) {
bindings = []
elementsToBindings.set(elementIndex, bindings)
2023-11-20 07:57:57 +01:00
}
let attributeName
let attributeValuePre
let attributeValuePost
if (withinAttribute) {
attributeName = /(\S+)="?(?:[^"]+)?$/.exec(token)[1]
attributeValuePre = /="?([^"=]*)$/.exec(token)[1]
attributeValuePost = /^([^">]*)/.exec(tokens[i + 1])[1]
}
const binding = {
2023-11-20 07:57:57 +01:00
attributeName,
attributeValuePre,
attributeValuePost,
expressionIndex: i
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
// remind myself that this object is supposed to be immutable
Object.freeze(binding)
}
bindings.push(binding)
2023-11-20 07:57:57 +01:00
2023-12-11 01:39:57 +01:00
// add a placeholder comment that we can find later
htmlString += (!withinTag && !withinAttribute) ? `<!--${bindings.length - 1}-->` : ''
2023-11-20 02:16:32 +01:00
}
2023-11-20 00:23:23 +01:00
2023-11-20 07:57:57 +01:00
const template = parseTemplate(htmlString)
2023-11-20 00:23:23 +01:00
2023-11-20 07:57:57 +01:00
return {
template,
elementsToBindings
2023-11-20 07:57:57 +01:00
}
}
2023-11-19 23:45:52 +01:00
function findPlaceholderComment (element, bindingId) {
// If we had a lot of placeholder comments to find, it would make more sense to build up a map once
// rather than search the DOM every time. But it turns out that we always only have one child,
// and it's the comment node, so searching every time is actually faster.
let childNode = element.firstChild
while (childNode) {
// Note that minify-html-literals has already removed all non-framework comments
// So we just need to look for comments that have exactly the bindingId as its text content
if (childNode.nodeType === Node.COMMENT_NODE && childNode.textContent === toString(bindingId)) {
return childNode
}
childNode = childNode.nextSibling
2023-11-19 23:45:52 +01:00
}
2023-11-20 07:57:57 +01:00
}
function traverseAndSetupBindings (dom, elementsToBindings) {
const instanceBindings = []
2023-11-20 07:57:57 +01:00
// traverse dom
const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT)
let element = dom
let elementIndex = -1
do {
const bindings = elementsToBindings.get(++elementIndex)
2023-11-20 07:57:57 +01:00
if (bindings) {
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
const targetNode = binding.attributeName
? element // attribute binding, just use the element itself
: findPlaceholderComment(element, i) // not an attribute binding, so has a placeholder comment
2023-12-12 04:19:02 +01:00
2023-12-16 20:16:55 +01:00
/* istanbul ignore if */
2023-12-12 04:19:02 +01:00
if (process.env.NODE_ENV !== 'production' && !targetNode) {
throw new Error('targetNode should not be undefined')
2023-11-20 07:57:57 +01:00
}
const instanceBinding = {
binding,
targetNode,
targetParentNode: undefined,
currentExpression: undefined
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
// remind myself that this object is supposed to be monomorphic (for better JS engine perf)
Object.seal(instanceBinding)
}
instanceBindings.push(instanceBinding)
2023-11-20 07:57:57 +01:00
}
}
} while ((element = treeWalker.nextNode()))
return instanceBindings
2023-11-20 07:57:57 +01:00
}
function cloneDomAndBind (template, elementsToBindings) {
2023-12-11 16:13:09 +01:00
const dom = template.cloneNode(true).content.firstElementChild
const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings)
return { dom, instanceBindings }
2023-12-11 16:13:09 +01:00
}
2023-11-20 07:57:57 +01:00
2023-12-11 16:13:09 +01:00
function parseHtml (tokens) {
// All templates and bound expressions are unique per tokens array
const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens))
2023-11-20 01:04:26 +01:00
2023-12-11 16:13:09 +01:00
// When we parseHtml, we always return a fresh DOM instance ready to be updated
const { dom, instanceBindings } = cloneDomAndBind(template, elementsToBindings)
2023-11-20 17:11:19 +01:00
2023-12-11 16:13:09 +01:00
return function update (expressions) {
patch(expressions, instanceBindings)
2023-12-10 21:35:28 +01:00
return dom
2023-11-20 02:16:32 +01:00
}
2023-11-20 07:57:57 +01:00
}
2023-11-26 19:22:02 +01:00
export function createFramework (state) {
2023-12-11 01:37:01 +01:00
const domInstances = getFromMap(domInstancesCache, state, () => new Map())
let domInstanceCacheKey = unkeyedSymbol
2023-11-26 19:22:02 +01:00
function html (tokens, ...expressions) {
2023-12-11 01:37:01 +01:00
// Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
// which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map())
const domInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens))
2023-11-26 19:22:02 +01:00
2023-12-11 01:37:01 +01:00
return domInstance(expressions) // update with expressions
2023-11-26 19:22:02 +01:00
}
2023-12-11 01:37:01 +01:00
function map (array, callback, keyFunction) {
return array.map((item, index) => {
const originalCacheKey = domInstanceCacheKey
domInstanceCacheKey = keyFunction(item)
try {
2023-11-26 19:22:02 +01:00
return callback(item, index)
2023-12-11 01:37:01 +01:00
} finally {
domInstanceCacheKey = originalCacheKey
}
})
2023-11-26 19:22:02 +01:00
}
return { map, html }
2023-11-20 07:57:57 +01:00
}