svgdom
Version:
Straightforward DOM implementation for SVG, HTML and XML
271 lines (212 loc) • 8.16 kB
JavaScript
import { removeQuotes, splitNotInBrackets } from '../utils/strUtils.js'
import * as regex from '../utils/regex.js'
import { html } from '../utils/namespaces.js'
export class CssQuery {
constructor (query) {
if (CssQuery.cache.has(query)) {
this.queries = CssQuery.cache.get(query)
return
}
let queries = splitNotInBrackets(query, ',')
queries = queries.map(query => {
let roundBrackets = 0
let squareBrackets = 0
// this is the same as above but easier
query = query.replace(/[()[\]>~+]/g, function (ch) {
if (ch === '(') ++roundBrackets
else if (ch === ')') --roundBrackets
else if (ch === '[') ++squareBrackets
else if (ch === ']') --squareBrackets
if ('()[]'.indexOf(ch) > -1) return ch
if (squareBrackets || roundBrackets) return ch
return ' ' + ch + ' '
})
// split at space and remove empty results
query = splitNotInBrackets(query, ' ').filter(el => !!el.length)
const pairs = []
let relation = '%'
// generate querynode relation tuples
for (let i = 0, il = query.length; i < il; ++i) {
if ('>~+%'.indexOf(query[i]) > -1) {
relation = query[i]
continue
}
pairs.push([ relation, query[i] ])
relation = '%'
}
return pairs
})
this.queries = queries
// to prevent memory leaks we have to manage our cache.
// we delete everything which is older than 50 entries
if (CssQuery.cacheKeys.length > 50) {
CssQuery.cache.delete(CssQuery.cacheKeys.shift())
}
CssQuery.cache.set(query, queries)
CssQuery.cacheKeys.push(query)
}
matches (node, scope) {
for (let i = this.queries.length; i--;) {
if (this.matchHelper(this.queries[i], node, scope)) {
return true
}
}
return false
}
matchHelper (query, node, scope) {
query = query.slice()
const last = query.pop()
if (!new CssQueryNode(last[1]).matches(node, scope)) { return false }
if (!query.length) return true
if (last[0] === ',') return true
if (last[0] === '+') {
return !!node.previousSibling && this.matchHelper(query, node.previousSibling, scope)
}
if (last[0] === '>') {
return !!node.parentNode && this.matchHelper(query, node.parentNode, scope)
}
if (last[0] === '~') {
while ((node = node.previousSibling)) {
if (this.matchHelper(query, node, scope)) { return true }
}
return false
}
if (last[0] === '%') {
while ((node = node.parentNode)) {
if (this.matchHelper(query, node, scope)) { return true }
}
return false
}
}
}
CssQuery.cache = new Map()
CssQuery.cacheKeys = []
// check if [node] is the [nth] child of [arr] where nth can also be a formula
const nth = (node, arr, nth) => {
if (nth === 'even') nth = '2n'
else if (nth === 'odd') nth = '2n+1'
// check for eval chars
if (/[^\d\-n+*/]+/.test(nth)) return false
nth = nth.replace('n', '*n')
// eval nth to get the index
for (var i, n = 0, nl = arr.length; n < nl; ++n) {
/* eslint no-eval: off */
i = eval(nth)
if (i > nl) break
if (arr[i - 1] === node) return true
}
return false
}
const lower = a => a.toLowerCase()
// checks if a and b are equal. Is insensitive when i is true
const eq = (a, b, i) => i ? lower(a) === lower(b) : a === b
// [i] (prebound) is true if insensitive matching is required
// [a] (prebound) is the value the attr is compared to
// [b] (passed) is the value of the attribute
const attributeMatcher = {
'=': (i, a, b) => eq(a, b, i),
'~=': (i, a, b) => b.split(regex.delimiter).filter(el => eq(el, a, i)).length > 0,
'|=': (i, a, b) => eq(b.split(regex.delimiter)[0], a, i),
'^=': (i, a, b) => i ? lower(b).startsWith(lower(a)) : b.startsWith(a),
'$=': (i, a, b) => i ? lower(b).endsWith(lower(a)) : b.endsWith(a),
'*=': (i, a, b) => i ? lower(b).includes(lower(a)) : b.includes(a),
'*': (i, a, b) => b != null
}
const getAttributeValue = (prefix, name, node) => {
if (!prefix || prefix === '*') {
return node.getAttribute(name)
}
return node.getAttribute(prefix + ':' + name)
}
// [a] (prebound) [a]rgument of the pseudo selector
// [n] (passed) [n]ode
// [s] (passed) [s]cope - the element this query is scoped to
const pseudoMatcher = {
'first-child': (a, n) => n.parentNode && n.parentNode.firstChild === n,
'last-child': (a, n) => n.parentNode && n.parentNode.lastChild === n,
'nth-child': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes, a),
'nth-last-child': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.slice().reverse(), a),
'first-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName)[0] === n,
'last-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).pop() === n,
'nth-of-type': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName), a),
'nth-last-of-type': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).reverse(), a),
'only-child': (a, n) => n.parentNode && n.parentNode.childNodes.length === 1,
'only-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).length === 1,
root: (a, n) => n.ownerDocument.documentElement === n,
not: (a, n, s) => !(new CssQuery(a)).matches(n, s),
matches: (a, n, s) => (new CssQuery(a)).matches(n, s),
scope: (a, n, s) => n === s
}
export class CssQueryNode {
constructor (node) {
this.tag = ''
this.id = ''
this.classList = []
this.attrs = []
this.pseudo = []
// match the tag name
let matches = node.match(/^[\w-]+|^\*/)
if (matches) {
this.tag = matches[0]
node = node.slice(this.tag.length)
}
// match pseudo classes
while ((matches = /:([\w-]+)(?:\((.+)\))?/g.exec(node))) {
this.pseudo.push(pseudoMatcher[matches[1]].bind(this, removeQuotes(matches[2] || '')))
node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length)
}
// match attributes
while ((matches = /\[([\w-*]+\|)?([\w-]+)(([=^~$|*]+)(.+?)( +[iI])?)?\]/g.exec(node))) {
const prefix = matches[1] ? matches[1].split('|')[0] : null
this.attrs.push({
name: matches[2],
getValue: getAttributeValue.bind(this, prefix, matches[2]),
matcher: attributeMatcher[matches[4] || '*'].bind(
this,
!!matches[6], // case insensitive yes/no
removeQuotes((matches[5] || '').trim()) // attribute value
)
})
node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length)
}
// match the id
matches = node.match(/#([\w-]+)/)
if (matches) {
this.id = matches[1]
node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length)
}
// match classes
while ((matches = /\.([\w-]+)/g.exec(node))) {
this.classList.push(matches[1])
node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length)
}
}
matches (node, scope) {
let i
if (node.nodeType !== 1) return false
// Always this extra code for html -.-
if (node.namespaceURI === html) {
this.tag = this.tag.toUpperCase()
}
if (this.tag && this.tag !== node.nodeName && this.tag !== '*') { return false }
if (this.id && this.id !== node.id) {
return false
}
const classList = (node.getAttribute('class') || '').split(regex.delimiter).filter(el => !!el.length)
if (this.classList.filter(className => classList.indexOf(className) < 0).length) {
return false
}
for (i = this.attrs.length; i--;) {
const attrValue = this.attrs[i].getValue(node)
if (attrValue === null || !this.attrs[i].matcher(attrValue)) {
return false
}
}
for (i = this.pseudo.length; i--;) {
if (!this.pseudo[i](node, scope)) {
return false
}
}
return true
}
}