/* @flow */ import warning from 'tiny-warning' import sheets from './sheets' import toCssValue from './utils/toCssValue' import type { CSSStyleRule, CSSMediaRule, CSSKeyframesRule, CSSKeyframeRule, HTMLElementWithStyleMap, AnyCSSRule, Rule, RuleList, ContainerRule, JssValue, InsertionPoint, StyleSheet } from './types' type PriorityOptions = { index: number, insertionPoint?: InsertionPoint } /** * Cache the value from the first time a function is called. */ const memoize = <Value>(fn: () => Value): (() => Value) => { let value return () => { if (!value) value = fn() return value } } /** * Get a style property value. */ function getPropertyValue( cssRule: HTMLElementWithStyleMap | CSSStyleRule | CSSKeyframeRule, prop: string ): string { try { // Support CSSTOM. if (cssRule.attributeStyleMap) { return cssRule.attributeStyleMap.get(prop) } return cssRule.style.getPropertyValue(prop) } catch (err) { // IE may throw if property is unknown. return '' } } /** * Set a style property. */ function setProperty( cssRule: HTMLElementWithStyleMap | CSSStyleRule | CSSKeyframeRule, prop: string, value: JssValue ): boolean { try { let cssValue = ((value: any): string) if (Array.isArray(value)) { cssValue = toCssValue(value, true) if (value[value.length - 1] === '!important') { cssRule.style.setProperty(prop, cssValue, 'important') return true } } // Support CSSTOM. if (cssRule.attributeStyleMap) { cssRule.attributeStyleMap.set(prop, cssValue) } else { cssRule.style.setProperty(prop, cssValue) } } catch (err) { // IE may throw if property is unknown. return false } return true } /** * Remove a style property. */ function removeProperty( cssRule: HTMLElementWithStyleMap | CSSStyleRule | CSSKeyframeRule, prop: string ) { try { // Support CSSTOM. if (cssRule.attributeStyleMap) { cssRule.attributeStyleMap.delete(prop) } else { cssRule.style.removeProperty(prop) } } catch (err) { warning( false, `[JSS] DOMException "${err.message}" was thrown. Tried to remove property "${prop}".` ) } } /** * Set the selector. */ function setSelector(cssRule: CSSStyleRule, selectorText: string): boolean { cssRule.selectorText = selectorText // Return false if setter was not successful. // Currently works in chrome only. return cssRule.selectorText === selectorText } /** * Gets the `head` element upon the first call and caches it. * We assume it can't be null. */ const getHead = memoize((): HTMLElement => (document.querySelector('head'): any)) /** * Find attached sheet with an index higher than the passed one. */ function findHigherSheet(registry: Array<StyleSheet>, options: PriorityOptions): StyleSheet | null { for (let i = 0; i < registry.length; i++) { const sheet = registry[i] if ( sheet.attached && sheet.options.index > options.index && sheet.options.insertionPoint === options.insertionPoint ) { return sheet } } return null } /** * Find attached sheet with the highest index. */ function findHighestSheet( registry: Array<StyleSheet>, options: PriorityOptions ): StyleSheet | null { for (let i = registry.length - 1; i >= 0; i--) { const sheet = registry[i] if (sheet.attached && sheet.options.insertionPoint === options.insertionPoint) { return sheet } } return null } /** * Find a comment with "jss" inside. */ function findCommentNode(text: string): Node | null { const head = getHead() for (let i = 0; i < head.childNodes.length; i++) { const node = head.childNodes[i] if (node.nodeType === 8 && node.nodeValue.trim() === text) { return node } } return null } type PrevNode = { parent: ?Node, node: ?Node } /** * Find a node before which we can insert the sheet. */ function findPrevNode(options: PriorityOptions): PrevNode | false { const {registry} = sheets if (registry.length > 0) { // Try to insert before the next higher sheet. let sheet = findHigherSheet(registry, options) if (sheet && sheet.renderer) { return { parent: sheet.renderer.element.parentNode, node: sheet.renderer.element } } // Otherwise insert after the last attached. sheet = findHighestSheet(registry, options) if (sheet && sheet.renderer) { return { parent: sheet.renderer.element.parentNode, node: sheet.renderer.element.nextSibling } } } // Try to find a comment placeholder if registry is empty. const {insertionPoint} = options if (insertionPoint && typeof insertionPoint === 'string') { const comment = findCommentNode(insertionPoint) if (comment) { return { parent: comment.parentNode, node: comment.nextSibling } } // If user specifies an insertion point and it can't be found in the document - // bad specificity issues may appear. warning(false, `[JSS] Insertion point "${insertionPoint}" not found.`) } return false } /** * Insert style element into the DOM. */ function insertStyle(style: HTMLElement, options: PriorityOptions) { const {insertionPoint} = options const nextNode = findPrevNode(options) if (nextNode !== false && nextNode.parent) { nextNode.parent.insertBefore(style, nextNode.node) return } // Works with iframes and any node types. if (insertionPoint && typeof insertionPoint.nodeType === 'number') { // https://stackoverflow.com/questions/41328728/force-casting-in-flow const insertionPointElement: HTMLElement = (insertionPoint: any) const {parentNode} = insertionPointElement if (parentNode) parentNode.insertBefore(style, insertionPointElement.nextSibling) else warning(false, '[JSS] Insertion point is not in the DOM.') return } getHead().appendChild(style) } /** * Read jss nonce setting from the page if the user has set it. */ const getNonce = memoize( (): ?string => { const node = document.querySelector('meta[property="csp-nonce"]') return node ? node.getAttribute('content') : null } ) const insertRule = ( container: CSSStyleSheet | CSSMediaRule | CSSKeyframesRule, rule: string, index?: number ): false | any => { const maxIndex = container.cssRules.length // In case previous insertion fails, passed index might be wrong if (index === undefined || index > maxIndex) { // eslint-disable-next-line no-param-reassign index = maxIndex } try { if ('insertRule' in container) { const c = ((container: any): CSSStyleSheet) c.insertRule(rule, index) } // Keyframes rule. else if ('appendRule' in container) { const c = ((container: any): CSSKeyframesRule) c.appendRule(rule) } } catch (err) { warning(false, `[JSS] ${err.message}`) return false } return container.cssRules[index] } const createStyle = (): HTMLElement => { const el = document.createElement('style') // Without it, IE will have a broken source order specificity if we // insert rules after we insert the style tag. // It seems to kick-off the source order specificity algorithm. el.textContent = '\n' return el } export default class DomRenderer { getPropertyValue = getPropertyValue setProperty = setProperty removeProperty = removeProperty setSelector = setSelector // HTMLStyleElement needs fixing https://github.com/facebook/flow/issues/2696 element: any sheet: StyleSheet | void hasInsertedRules: boolean = false constructor(sheet?: StyleSheet) { // There is no sheet when the renderer is used from a standalone StyleRule. if (sheet) sheets.add(sheet) this.sheet = sheet const {media, meta, element} = this.sheet ? this.sheet.options : {} this.element = element || createStyle() this.element.setAttribute('data-jss', '') if (media) this.element.setAttribute('media', media) if (meta) this.element.setAttribute('data-meta', meta) const nonce = getNonce() if (nonce) this.element.setAttribute('nonce', nonce) } /** * Insert style element into render tree. */ attach(): void { // In the case the element node is external and it is already in the DOM. if (this.element.parentNode || !this.sheet) return insertStyle(this.element, this.sheet.options) // When rules are inserted using `insertRule` API, after `sheet.detach().attach()` // most browsers create a new CSSStyleSheet, except of all IEs. const deployed = Boolean(this.sheet && this.sheet.deployed) if (this.hasInsertedRules && deployed) { this.hasInsertedRules = false this.deploy() } } /** * Remove style element from render tree. */ detach(): void { const {parentNode} = this.element if (parentNode) parentNode.removeChild(this.element) } /** * Inject CSS string into element. */ deploy(): void { const {sheet} = this if (!sheet) return if (sheet.options.link) { this.insertRules(sheet.rules) return } this.element.textContent = `\n${sheet.toString()}\n` } /** * Insert RuleList into an element. */ insertRules(rules: RuleList, nativeParent?: CSSStyleSheet | CSSMediaRule | CSSKeyframesRule) { for (let i = 0; i < rules.index.length; i++) { this.insertRule(rules.index[i], i, nativeParent) } } /** * Insert a rule into element. */ insertRule( rule: Rule, index?: number, nativeParent?: CSSStyleSheet | CSSMediaRule | CSSKeyframesRule = this.element.sheet ): false | CSSStyleSheet | AnyCSSRule { if (rule.rules) { const parent: ContainerRule = (rule: any) let latestNativeParent = nativeParent if (rule.type === 'conditional' || rule.type === 'keyframes') { // We need to render the container without children first. latestNativeParent = insertRule(nativeParent, parent.toString({children: false}), index) if (latestNativeParent === false) { return false } } this.insertRules(parent.rules, latestNativeParent) return latestNativeParent } // IE keeps the CSSStyleSheet after style node has been reattached, // so we need to check if the `renderable` reference the right style sheet and not // rerender those rules. if (rule.renderable && rule.renderable.parentStyleSheet === this.element.sheet) { return rule.renderable } const ruleStr = rule.toString() if (!ruleStr) return false const nativeRule = insertRule(nativeParent, ruleStr, index) if (nativeRule === false) { return false } this.hasInsertedRules = true rule.renderable = nativeRule return nativeRule } /** * Delete a rule. */ deleteRule(cssRule: AnyCSSRule): boolean { const {sheet} = this.element const index = this.indexOf(cssRule) if (index === -1) return false sheet.deleteRule(index) return true } /** * Get index of a CSS Rule. */ indexOf(cssRule: AnyCSSRule): number { const {cssRules} = this.element.sheet for (let index = 0; index < cssRules.length; index++) { if (cssRule === cssRules[index]) return index } return -1 } /** * Generate a new CSS rule and replace the existing one. * * Only used for some old browsers because they can't set a selector. */ replaceRule(cssRule: AnyCSSRule, rule: Rule): false | CSSStyleSheet | AnyCSSRule { const index = this.indexOf(cssRule) if (index === -1) return false this.element.sheet.deleteRule(index) return this.insertRule(rule, index) } /** * Get all rules elements. */ getRules(): CSSRuleList { return this.element.sheet.cssRules } }