Home Reference Source

src/style/ComputedStyles.js

import Evaluators from './Evaluators.js'

/**
ComputedStyles holds the previous and computed declarations for a single Object3D

The computed styles are the combined output of a node's {AssignedStyles}, {LocalStyles}, and inherited computed parental styles.
*/
class ComputedStyles {
	/**
	@param {THREE.Object3D} node
	*/
	constructor(node) {
		this.node = node
		/** @type {Map<string, StyleInfo>} property -> style */
		this._previousStyles = new Map()
		/** @type {Map<string, StyleInfo>} property -> style */
		this._currentStyles = new Map()
		/** @type {StyleInfo[]} */
		this._changes = []
	}

	/**
	Compute the final styles for a node:
	- apply the assigned styles
	- apply local styles that aren't assigned
	- apply inherited styles that aren't assigned or local

	@todo calculate relative units like `em`
	@todo handle the 'inherit' and 'reset' style values
	@todo handle inherited sub values like `border-top: 10px` inherited on top of `border: 0`
	@todo handle value methods like `calc()`

	@param {AssignedStyles} assignedStyles
	@param {LocalStyles} localStyles
	@param {ComputedStyles} [parentalComputedStyles=null]
	*/
	computeStyles(assignedStyles, localStyles, parentalComputedStyles = null) {
		// Swap the previous and current maps
		_holdingVariable = this._previousStyles
		this._previousStyles = this._currentStyles
		this._currentStyles = _holdingVariable
		this._currentStyles.clear()

		// Empty the working list
		this._changes.splice(0, this._changes.length)
		_currentPropertiesArray.splice(0, _currentPropertiesArray.length)
		_changedVariablesArray.splice(0, _changedVariablesArray.length)

		// Assign the assigned styles
		for (const styleInfo of assignedStyles) {
			this._currentStyles.set(styleInfo.property, styleInfo)
			_currentPropertiesArray.push(styleInfo.property)
		}

		// Assign the local styles
		for (const styleInfo of localStyles) {
			// Don't overwrite assigned styles
			if (assignedStyles.has(styleInfo.property)) continue
			this._currentStyles.set(styleInfo.property, styleInfo)
			_currentPropertiesArray.push(styleInfo.property)
		}

		// If there are parental styles then add the inheritable ones for which there is not a local style
		if (parentalComputedStyles !== null) {
			for (const styleInfo of parentalComputedStyles) {
				// Skip if this is not a variable and not an inherited property
				if (styleInfo.property.startsWith('--') === false && InheritedProperties.includes(styleInfo.property) === false)
					continue
				// Skip if there is an assigned or local style that overrides the inherited property
				if (this._currentStyles.has(styleInfo.property)) continue
				// Ok, this is a cascaded style!
				this._currentStyles.set(styleInfo.property, styleInfo)
				_currentPropertiesArray.push(styleInfo.property)
			}
		}

		//Recalculate the changes list
		for (const property of _currentPropertiesArray) {
			const hasStyle = this._previousStyles.has(property)
			if (hasStyle === false) {
				this._changes.push(property)
				if (property.startsWith('--')) _changedVariablesArray.push(property)
			} else if (hasStyle && this._previousStyles.get(property).value !== this._currentStyles.get(property).value) {
				this._changes.push(property)
				if (property.startsWith('--')) _changedVariablesArray.push(property)
			}
		}
		for (const property of this._previousStyles.keys()) {
			if (this._currentStyles.has(property) === false) this._changes.push(property)
			if (property.startsWith('--')) _changedVariablesArray.push(property)
		}

		// Mark changed any declarations whose value is one of the changed variables
		for (const property of this._currentStyles.keys()) {
			if (this._changes.includes(property)) continue // Already marked as a change
			_workingVal = this._currentStyles.get(property).value
			if (_workingVal.startsWith('var(--') === false) continue
			_workingVal = _workingVal.substring(4, _workingVal.length - 1)
			if (_changedVariablesArray.includes(_workingVal)) {
				this._changes.push(property)
			}
		}
	}

	get(property) {
		return this._currentStyles.get(property) || null
	}

	getNumber(property, defaultValue = null) {
		const styleInfo = this.get(property)
		if (styleInfo === null) return defaultValue
		const parsedValue = Evaluators.parse(styleInfo.value, this.node)
		if (parsedValue === null) return defaultValue
		if (Array.isArray(parsedValue)) return parsedValue[0]
		return parsedValue
	}

	/*

	The fillLength allows us to quickly handle array values that auto-expand, like margin-width or padding.
	This method will fill the result array with enough copies of the parsedValue that when combined with parsedValue it is fillLength long
	For example:
		a parsedValue of: [1, 2, 3]
		with a fillValue of 7
		would result in: [1, 2, 3, 1, 2, 3, 1]

	@param {string} property
	@param {Array?} defaultValue - the default to return if the property is not set or not parsable
	@param {number?} expectedLength
	*/
	getNumberArray(property, defaultValue = null, fillLength = null) {
		const styleInfo = this.get(property)
		if (styleInfo === null) return defaultValue
		const parsedValue = Evaluators.parse(styleInfo.value, this.node)
		if (parsedValue === null) return defaultValue
		if (Array.isArray(parsedValue) === false) {
			console.error('Expected an array', parsedValue, typeof parsedValue)
			return defaultValue
		}
		if (parsedValue.length === 0) return defaultValue
		if (fillLength !== null && parsedValue.length < fillLength) {
			const numToFill = fillLength - parsedValue.length
			const fillValues = new Array(numToFill)
			for (let i = 0; i < numToFill; i++) {
				fillValues[i] = parsedValue[i % parsedValue.length]
			}
			parsedValue.push(...fillValues)
		}
		return parsedValue
	}

	getBoolean(property, defaultValue = null) {
		const styleInfo = this.get(property)
		if (styleInfo === null) return defaultValue
		return styleInfo.value === 'true'
	}

	getString(property, defaultValue = null) {
		const styleInfo = this.get(property)
		if (styleInfo === null) return defaultValue
		return styleInfo.value
	}

	/**
	changes is used by the Stylist to know which styles need to be updated on the Three.Object3D
	@return {Array<property{string}>} the declarations that changed since the last update
	*/
	get changes() {
		return this._changes
	}

	/** Iterate over the current declarations */
	*[Symbol.iterator]() {
		for (const styleInfo of this._currentStyles.values()) {
			yield styleInfo
		}
	}

	log(showVars = false) {
		for (const styleInfo of this) {
			if (showVars === false && styleInfo.property.startsWith('--')) continue
			console.log(styleInfo.toString())
		}
	}
}

let _workingVal = null
let _holdingVariable = null
const _changedVariablesArray = new Array()
const _currentPropertiesArray = new Array()

/**
The name of properties that are inherited during the cascade.

Don't forget that --variables are also inherited!
*/
const InheritedProperties = [
	'border-collapse',
	'border-spacing',
	'caption-side',
	'color',
	'emissive',
	'cursor',
	'direction',
	'empty-cells',
	'font-family',
	'font-size',
	'font-style',
	'font-variant',
	'font-weight',
	'font-size-adjust',
	'font-stretch',
	'font',
	'letter-spacing',
	'line-height',
	'list-style-image',
	'list-style-position',
	'list-style-type',
	'list-style',
	'orphans',
	'quotes',
	'tab-size',
	'text-align',
	'text-align-last',
	'text-decoration-color',
	'text-indent',
	'text-justify',
	'text-shadow',
	'text-transform',
	'visibility',
	'white-space',
	'widows',
	'word-break',
	'word-spacing',
	'word-wrap'
]

const LayoutEffectingProperties = [
	'margin',
	'border-width',
	'padding',
	'scale',
	'display',
	'grid',
	'grid-template',
	'gap',
	'grid-auto-flow',
	'grid-auto-rows',
	'grid-auto-columns'
]

export default ComputedStyles
export { ComputedStyles, InheritedProperties, LayoutEffectingProperties }