Home Reference Source

src/style/Selector.js

/**
SelectorFragmentList is a list of SelectorElements and Combinators (which both extend SelectorFragment)
Example strings that represent a selector fragment list:

Single selector elements:
- .class
- #id
- tag

Multiple selector elements:
- tag .class .another-class

Multiple elements with explicit combinators
- tag > .class:active + .another-class

*/
class SelectorFragmentList {
	constructor(selectorFragments) {
		this._forwardFragments = selectorFragments.slice(0, selectorFragments.length)
		this._reverseFragments = selectorFragments.slice(0, selectorFragments.length).reverse()
		this._specificity = this._calculateSpecificity()
	}

	/**
	@see https://developer.mozilla.org/en-US/docs/Learn/CSS/Introduction_to_CSS/Cascade_and_inheritance
	@return {float} 
	*/
	get specificity() {
		return this._specificity
	}

	*[Symbol.iterator]() {
		for (const frag of this._forwardFragments) {
			yield frag
		}
	}

	get raw() {
		return Array.from(this)
			.filter(frag => frag.type !== Combinator.DESCENDANT)
			.map(frag => frag.raw)
			.join(' ')
	}

	/**
	Go through the list of selectors and combinators and check whether this node matches
	@param {THREE.Object3D} node
	@param {int} [fragmentIndex=0] the index in the reversed list of fragments at which to start
	@param {THREE.Object3D} [previouslyMatchedNode=null]
	@return {bool} true if the node is matched
	*/
	matches(node, fragmentIndex = 0, previouslyMatchedNode = null) {
		if (fragmentIndex < 0 || fragmentIndex >= this._reverseFragments.length) {
			console.error('Invalid fragmentIndex', fragmentIndex, node, this)
			return false
		}
		const fragment = this._reverseFragments[fragmentIndex]

		// Handle a combinator
		if (fragment instanceof Combinator) {
			// Refuse if there is no previous matched node
			if (previouslyMatchedNode === null) return false
			// Refuse if there is a combinator but no following element
			if (fragmentIndex + 1 >= this._reverseFragments.length) return false
			// Refuse if there are two combinators in a row
			if (this._reverseFragments[fragmentIndex + 1] instanceof Combinator) return false

			switch (fragment.type) {
				case Combinator.DESCENDANT: // >>
					// Run up the ancestors until you either match or reach the root
					let workingNode = node
					let parentMatched = false
					while (workingNode !== null) {
						if (this.matches(workingNode, fragmentIndex + 1)) {
							parentMatched = true
							break
						} else {
							workingNode = workingNode.parent
						}
					}
					// Refuse if the descendent match failed
					if (parentMatched === false) return false
					// Accept if there are no more fragments after the post-combinator fragment
					if (fragmentIndex + 2 >= this._reverseFragments.length) return true
					// Refuse if this is the root but there are more fragments to match
					if (workingNode.parent === null) return false
					// Move on to the parent and next fragment
					return this.matches(workingNode.parent, fragmentIndex + 2)

				case Combinator.CHILD: // >
					// Test against the next fragment
					return this.matches(node, fragmentIndex + 1)

				case Combinator.ADJACENT_SIBLING: // +
					// Refuse if the node is the root, which fails this combinator
					if (previouslyMatchedNode.parent === null) return false
					const thisNodeIndex = previouslyMatchedNode.parent.children.indexOf(previouslyMatchedNode)
					// Refuse if there is no previous sibling (remember, these fragments are ordered backward from leaf to root)
					if (thisNodeIndex < 1) {
						return false
					}
					return this.matches(previouslyMatchedNode.parent.children[thisNodeIndex - 1], fragmentIndex + 1)

				case Combinator.GENERAL_SIBLING: // ~
					// Refuse if the node is the root, which fails this combinator
					if (previouslyMatchedNode.parent === null) return false
					// Refuse if there are no siblings
					if (previouslyMatchedNode.parent.children.length === 1) return false
					const siblings = previouslyMatchedNode.parent.children.filter(child => child !== node)
					for (let i = 0; i < siblings.length; i++) {
						if (this.matches(siblings[i], fragmentIndex + 1)) {
							return true
						}
					}
					return false

				default:
					console.error('Unknown combinator type', fragment.type)
					return false
			}
		} else {
			// Refuse if this node and SelectorElement don't match
			if (this._reverseFragments[fragmentIndex].matches(node) === false) {
				return false
			}
			// Accept if there are no more fragments
			if (fragmentIndex === this._reverseFragments.length - 1) return true
			// Refuse if there are more fragments but this is the root
			if (node.parent === null) return false
			// Move on to the next ancestor and fragment
			return this.matches(node.parent, fragmentIndex + 1, node)
		}
	}

	/*
	Breaks up the rawSelector into a SelectorFragmentList which contains SelectorElements and Combinators
	@param {string} rawSelector a selector string like 'div.action[foo=23][bar~=grik i] > .pickle:active'
	@return {SelectorFragmentList}
	*/
	static Parse(rawSelector) {
		const rawFragments = _splitSelectors(rawSelector).filter(rf => rf.trim().length > 0)
		const results = []
		let previousWasElement = false
		for (const rf of rawFragments) {
			if (Combinator.TYPES.includes(rf)) {
				results.push(new Combinator(rf))
				previousWasElement = false
			} else {
				if (previousWasElement) {
					// Insert an implied descendant combinator
					results.push(new Combinator('>>'))
				}
				results.push(new SelectorElement(rf))
				previousWasElement = true
			}
		}
		return new SelectorFragmentList(results)
	}

	/**
	Calculates the specificity of the overall list of selector fragments
	@return {number} specificity
	*/
	_calculateSpecificity() {
		// Hundreds: Score one in this column for each ID selector contained inside the overall selector.
		let hundredCount = 0
		// Tens: Score one in this column for each class selector, attribute selector, or pseudo-class contained inside the overall selector.
		let tenCount = 0
		// Ones: Score one in this column for each element selector or pseudo-element contained inside the overall selector.
		let oneCount = 0

		for (const fragment of this._reverseFragments) {
			if (fragment instanceof Combinator) continue
			// IDs
			hundredCount += fragment._elements.filter(element => element.type === SelectorElement.ID_ELEMENT).length
			// Classes
			tenCount += fragment._elements.filter(element => element.type === SelectorElement.CLASS_ELEMENT).length
			// Attributes
			tenCount += fragment._attributes.length
			// Pseudo-classes
			tenCount += fragment._pseudos.filter(pseudo => pseudo.type === SelectorElement.PSEUDO_CLASS).length
			// Elements
			oneCount += fragment._elements.filter(element => element.type === SelectorElement.TAG_ELEMENT).length
			// Pseudo-elements
			oneCount += fragment._pseudos.filter(pseudo => pseudo.type === SelectorElement.PSEUDO_ELEMENT).length
		}
		return 100 * hundredCount + 10 * tenCount + oneCount
	}
}

/**
An abstract class for Selectors and Combinator
*/
class SelectorFragment {}

/**
SelectorElement holds a parsed version of a single element in a selector.

Example element strings:
	.foo:active
	div#id.group.dinkus-moon[foo=23][bar^="glitz grease"][blatz]::after:first

Note: combinators like '>>', '>', '+', and '~' are represented by the {Combinator} class, not this class
*/
class SelectorElement extends SelectorFragment {
	/**
	@param {string} selector the string of a single selector element
	*/
	constructor(rawSelector) {
		super()
		this._raw = rawSelector
		const [rawElements, rawAttributes, rawPseudos] = this._splitRaw()

		/** @type {Array[Object{ type {SelectorElement.ELEMENT_TYPES}, value {string} }]} */
		this._elements = this._parseElements(rawElements)

		/** @type {Array[Object{ key {string}, operator {SelectorElement.ATTRIBUTE_TYPES}, value {string} }, caseInsensitive {bool}] } */
		this._attributes = this._parseAttributes(rawAttributes)

		/** @type {Array[Object{ type { SelectorElement.PSEUDO_CLASS | SelectorElement.PSEUDO_ELEMENT }, value, parameters[]:[] }]} */
		this._pseudos = this._parsePseudos(rawPseudos)
	}

	/** @type {string} */
	get raw() {
		return this._raw
	}

	/**
	@return {bool}
	*/
	matches(node) {
		// Refuse if any element does not match
		if (
			this._elements.some(element => {
				return this._elementMatches(element, node) === false
			})
		)
			return false

		// Refuse if any attribute does not match
		if (
			this._attributes.some(attribute => {
				return this._attributeMatches(attribute, node) === false
			})
		)
			return false

		// Refuse if any pseudo does not match
		if (
			this._pseudos.some(pseudo => {
				return this._pseudoMatches(pseudo, node) === false
			})
		)
			return false

		return true
	}

	/**
	@param {Object<type:SelectorElement.ELEMENT_TYPES, value:string>} element
	@param {THREE.Object3D} node
	@return {bool}
	*/
	_elementMatches(element, node) {
		switch (element.type) {
			case SelectorElement.CLASS_ELEMENT:
				return this._classMatches(element.value, node)
			case SelectorElement.ID_ELEMENT:
				return this._idMatches(element.value, node)
			case SelectorElement.TAG_ELEMENT:
				return this._tagMatches(element.value, node)
			case SelectorElement.WILDCARD_ELEMENT:
				return true
			default:
				console.error('Unknown element type', element)
				return false
		}
	}

	_attributeMatches(attribute, node) {
		if (attribute.operator === SelectorElement.ATTRIBUTE_EXISTS) {
			return node.attributes.has(attribute.key)
		}

		const nodeAttributeValue = attribute.caseInsensitive
			? (node.attributes.get(attribute.key, '') + '').toLowerCase()
			: node.attributes.get(attribute.key, '') + ''
		switch (attribute.operator) {
			case SelectorElement.ATTRIBUTE_EQUALS:
				return nodeAttributeValue == attribute.value
			case SelectorElement.ATTRIBUTE_EQUALS_HYPHEN:
				return nodeAttributeValue == attribute.value || nodeAttributeValue == attribute.value + '-'
			case SelectorElement.ATTRIBUTE_LISTED:
				return nodeAttributeValue.split(' ').includes(attribute.value)
			case SelectorElement.ATTRIBUTE_CONTAINS:
				return nodeAttributeValue.indexOf(attribute.value) !== -1
			case SelectorElement.ATTRIBUTE_STARTS_WITH:
				return nodeAttributeValue.startsWith(attribute.value)
			case SelectorElement.ATTRIBUTE_ENDS_WITH:
				return nodeAttributeValue.endsWith(attribute.value)
			default:
				console.error('Unknown attribute operator', attribute)
				return false
		}
	}

	_pseudoMatches(pseudo, node) {
		const checkFunction = SelectorElement.PSEUDO_CHECK_FUNCTIONS.get(pseudo.value) || null
		if (!checkFunction) return false
		return checkFunction(node)
	}

	_tagMatches(tag, node) {
		const checkFunction = SelectorElement.TAG_CHECK_FUNCTIONS.get(tag) || null
		if (!checkFunction) return false
		return checkFunction(node)
	}

	_idMatches(id, node) {
		return node.userData.id === id
	}

	_classMatches(className, node) {
		return node.hasClass(className)
	}

	_parseElements(rawElements) {
		if (!rawElements) return []
		return rawElements
			.match(/(\.|#)?([\w-]*|\*)/g)
			.filter(element => element.trim().length > 0)
			.map(element => {
				if (element.startsWith('.')) {
					return {
						type: SelectorElement.CLASS_ELEMENT,
						value: element.substring(1)
					}
				} else if (element.startsWith('#')) {
					return {
						type: SelectorElement.ID_ELEMENT,
						value: element.substring(1)
					}
				} else if (element === '*') {
					return {
						type: SelectorElement.WILDCARD_ELEMENT,
						value: ''
					}
				} else {
					return {
						type: SelectorElement.TAG_ELEMENT,
						value: element
					}
				}
			})
	}

	_parseAttributes(rawAttributes) {
		if (!rawAttributes) return []
		return rawAttributes.match(/\[[^\]]+\]/g).map(ra => {
			ra = ra.slice(1, ra.length - 1) // remove brackets
			const key = ra.match(/[^=~+|*$\^]*/)[0] || ''
			const operator = ra.match(/[=~+|*$\^]+/)[0] || ''
			let value = ra.slice(key.length + operator.length)
			const caseInsensitive = value.endsWith(' i') || value.endsWith(' I')
			if (caseInsensitive) value = value.substring(0, value.length - 2).toLowerCase()
			return {
				key: key,
				operator: SelectorElement.ATTRIBUTE_TYPE_MAP.get(operator) || SelectorElement.ATTRIBUTE_EXISTS,
				value: this._cleanAttributeValue(value),
				caseInsensitive: caseInsensitive
			}
		})
	}

	_cleanAttributeValue(val) {
		if (typeof val !== 'string') return val
		if (val.startsWith('"') || val.startsWith("'")) val = val.slice(1)
		if (val.endsWith('"') || val.endsWith("'")) val = val.slice(0, val.length - 1)
		return val
	}

	/**
	:active
	::before
	@todo handle pseudos that are fuctions like :matches() and :not()
	*/
	_parsePseudos(rawPseudos) {
		if (!rawPseudos) return []
		return rawPseudos.match(/:{1,2}[^:]+/g).map(pseudo => {
			if (pseudo.startsWith('::')) {
				return {
					type: SelectorElement.PSEUDO_ELEMENT,
					value: pseudo.substring(2)
				}
			} else {
				return {
					type: SelectorElement.PSEUDO_CLASS,
					value: pseudo.substring(1)
				}
			}
		})
	}

	/**
	@return {Array[rawElements, rawAttributes, rawPseudos]}
	*/
	_splitRaw() {
		let rawElements = this.raw
		let rawAttributes = ''
		let rawPseudos = ''

		// Split out attributes
		const firstAttributeIndex = rawElements.indexOf('[')
		if (firstAttributeIndex !== -1) {
			const lastAttributeIndex = rawElements.lastIndexOf(']')
			if (lastAttributeIndex === -1) throw new Error('Bad attributes', rawElements)
			rawAttributes = rawElements.substring(firstAttributeIndex, lastAttributeIndex + 1)
			rawElements = rawElements.slice(0, firstAttributeIndex) + rawElements.slice(lastAttributeIndex + 1)
		}

		// Split out pseudos
		const firstPseudoIndex = rawElements.indexOf(':')
		if (firstPseudoIndex !== -1) {
			rawPseudos = rawElements.substring(firstPseudoIndex)
			rawElements = rawElements.slice(0, firstPseudoIndex)
		}

		return [rawElements, rawAttributes, rawPseudos]
	}
}

SelectorElement.CLASS_ELEMENT = Symbol('class-element')
SelectorElement.ID_ELEMENT = Symbol('id-element')
SelectorElement.TAG_ELEMENT = Symbol('tag-element')
SelectorElement.WILDCARD_ELEMENT = Symbol('wildcard-element')

SelectorElement.ELEMENT_TYPES = [
	SelectorElement.CLASS_ELEMENT,
	SelectorElement.ID_ELEMENT,
	SelectorElement.TAG_ELEMENT,
	SelectorElement.WILDCARD_ELEMENT
]

SelectorElement.ATTRIBUTE_EXISTS = Symbol('declaration-attribute-exists') // [attr]
SelectorElement.ATTRIBUTE_EQUALS = Symbol('declaration-attribute-equals') // [attr=val]
SelectorElement.ATTRIBUTE_EQUALS_HYPHEN = Symbol('declaration-attribute-equals-hypen') // [attr|=val]
SelectorElement.ATTRIBUTE_LISTED = Symbol('declaration-attribute-listed') // [attr~=val]
SelectorElement.ATTRIBUTE_CONTAINS = Symbol('declaration-attribute-contains') // [attr*=val]
SelectorElement.ATTRIBUTE_STARTS_WITH = Symbol('declaration-attribute-starts-with') // [attr^=val]
SelectorElement.ATTRIBUTE_ENDS_WITH = Symbol('declaration-attribute-ends-with') // [attr$=val]

SelectorElement.ATTRIBUTE_TYPES = [
	SelectorElement.ATTRIBUTE_EXISTS,
	SelectorElement.ATTRIBUTE_EQUALS,
	SelectorElement.ATTRIBUTE_EQUALS_HYPHEN,
	SelectorElement.ATTRIBUTE_LISTED,
	SelectorElement.ATTRIBUTE_CONTAINS,
	SelectorElement.ATTRIBUTE_STARTS_WITH,
	SelectorElement.ATTRIBUTE_ENDS_WITH
]

SelectorElement.ATTRIBUTE_TYPE_MAP = new Map([
	['', SelectorElement.ATTRIBUTE_EXISTS],
	['=', SelectorElement.ATTRIBUTE_EQUALS],
	['|=', SelectorElement.ATTRIBUTE_EQUALS_HYPHEN],
	['~=', SelectorElement.ATTRIBUTE_LISTED],
	['*=', SelectorElement.ATTRIBUTE_CONTAINS],
	['^=', SelectorElement.ATTRIBUTE_STARTS_WITH],
	['$=', SelectorElement.ATTRIBUTE_ENDS_WITH]
])

SelectorElement.PSEUDO_CLASS = Symbol('pseudo-class')
SelectorElement.PSEUDO_ELEMENT = Symbol('pseudo-element')

function _intercaseTag(tag) {
	return tag
		.toLowerCase()
		.split('-')
		.map((token, index) => {
			if (token.length === 0) return ''
			return token.substring(0, 1).toUpperCase() + token.substring(1)
		})
		.join('')
}

function _createCheckFunction(tag) {
	const attribute = `is${_intercaseTag(tag)}`
	return function(node) {
		return node[attribute] === true
	}
}

const SpatialTags = [
	'scene',
	'node',
	'group',
	'text',

	'bone',
	'line',
	'line-loop',
	'line-segments',
	'lod',
	'mesh',
	'points',
	'skeleton',
	'skinned-mesh',
	'sprite',

	'ambient-light',
	'directional-light',
	'hemisphere-light',
	'light',
	'point-light',
	'rect-area-light',
	'spot-light'
]

/**
Maps a spatial tag name like 'scene' to a function that returns true if it's the named node (like a THREE.SCENE)
*/
SelectorElement.TAG_CHECK_FUNCTIONS = new Map()
for (const tag of SpatialTags) {
	SelectorElement.TAG_CHECK_FUNCTIONS.set(tag, _createCheckFunction(tag))
}

SelectorElement.PSEUDO_CHECK_FUNCTIONS = new Map()
SelectorElement.PSEUDO_CHECK_FUNCTIONS.set('root', node => {
	return node.parent === null
})

/**
Combinator represents a relationship between two {SelectorElement}s, like descendance or sibling.

In a raw selector, a combinator can be a space or `>>`, `>`, `+`, or `~`
*/
class Combinator extends SelectorFragment {
	constructor(rawFragment) {
		super()
		this._raw = rawFragment
		this._type = Combinator.TYPES.find(t => this._raw === t) || Combinator.DESCENDANT
	}

	get raw() {
		return this._raw
	}
	get type() {
		return this._type
	}
}

Combinator.DESCENDANT = '>>'
Combinator.CHILD = '>'
Combinator.ADJACENT_SIBLING = '+'
Combinator.GENERAL_SIBLING = '~'

Combinator.TYPES = [Combinator.DESCENDANT, Combinator.CHILD, Combinator.ADJACENT_SIBLING, Combinator.GENERAL_SIBLING]

/**
@param {string} rawSelector like 'div.class:first > p[name~=flowers][selected] + text'
@return {Array<string>} an array of separate fragments like ['div.class:first', '>', 'p[name~=flowers i][selected]', '+', 'text']
*/
const _splitSelectors = function(rawSelector) {
	const results = []
	let current = []
	let startQuote = null
	let inBrackets = false
	for (let i = 0; i < rawSelector.length; i++) {
		const char = rawSelector[i]
		if (char === ' ') {
			if (current.length === 0) continue
			if (startQuote === null && inBrackets === false) {
				results.push(current.join(''))
				current = []
				continue
			}
		}
		if ((char === '"' || char === "'") && startQuote === null) {
			// start a quoted string
			startQuote = char
		} else if (startQuote === char) {
			// end a quoted string
			startQuote = null
		} else if (char === '[') {
			inBrackets = true
		} else if (char === ']') {
			inBrackets = false
		}
		current.push(char)
	}
	if (current.length !== 0) results.push(current.join(''))
	return results
}

export { SelectorFragment, SelectorFragmentList, SelectorElement, Combinator, SpatialTags }