spritejs/sprite-core

View on GitHub
src/modules/css/stylesheet.js

Summary

Maintainability
F
3 days
Test Coverage
import {isMatched, compile} from '../dom/selector';
import {parseFont, sizeToPixel, relatedAttributes} from '../../utils';

const cssWhat = require('css-what');

let cssRules = [];
const keyFrames = {};

const _matchedSelectors = Symbol('matchedSelectors');
const _transitions = Symbol('transitions');
const _animation = Symbol('animation');

/* istanbul ignore next */
function parseTransitionValue(values) {
  if(typeof values === 'string') values = values.trim().split(/\s*,\s*/g);
  const ret = [];
  for(let i = 0; i < values.length; i++) {
    let value = values[i].toString();
    if(value === 'initial') {
      value = 0;
    } else if(/ms$/.test(value)) {
      value = parseFloat(value) / 1000;
    } else {
      value = parseFloat(value);
    }
    ret.push(value);
  }
  return ret;
}

/* istanbul ignore next */
function parseAnimationValue(value) {
  value = value.toString();
  if(value === 'initial') {
    value = 0;
  } else if(/ms$/.test(value)) {
    value = parseFloat(value);
  } else {
    value = parseFloat(value) * 1000;
  }
  return value;
}

/* istanbul ignore next */
function toPxValue(value, defaultWidth) { // eslint-disable-line complexity
  if(typeof value === 'string') {
    const matched = value.match(/^([\d.]+)(px|pt|pc|in|cm|mm|em|ex|rem|q|vw|vh|vmax|vmin)$/);
    if(matched) {
      // console.log(matched);
      const size = parseFloat(matched[1]);
      const unit = matched[2];
      value = sizeToPixel({size, unit});
    } else {
      const size = Number(value);
      if(!Number.isNaN(size)) {
        value = size;
      }
    }
  }
  return value;
}

/* istanbul ignore next */
const CSSGetter = {
  opacity: true,
  width: true,
  height: true,
  backgroundColor: true,
  flexGrow: true,
  flexShrink: true,
  flexBasis: true,
  order: true,
  flexDirection: true,
  flexWrap: true,
  justifyContent: true,
  alignItems: true,
  alignContent: true,
  position: true,
  alignSelf: true,
  transform: true,
  transformOrigin: true,
  borderTopWidth: true,
  borderRightWidth: true,
  borderBottomWidth: true,
  borderLeftWidth: true,
  borderTopColor: true,
  borderRightColor: true,
  borderBottomColor: true,
  borderLeftColor: true,
  borderTopStyle: true,
  borderRightStyle: true,
  borderBottomStyle: true,
  borderLeftStyle: true,
  borderTopLeftRadius: true,
  borderTopRightRadius: true,
  borderBottomRightRadius: true,
  borderBottomLeftRadius: true,
  boxSizing: true,
  display: true,
  paddingTop: true,
  paddingRight: true,
  paddingBottom: true,
  paddingLeft: true,
  marginTop: true,
  marginRight: true,
  marginBottom: true,
  marginLeft: true,
  zIndex: true,
  font: true,
  fontSize: true,
  fontFamily: true,
  fontStyle: true,
  fontVariantCaps: true,
  fontWeight: true,
  color: true,
  textAlign: true,
  lineHeight: true,
  lineBreak: true,
  wordBreak: true,
  letterSpacing: true,
  textIndent: true,
  transitionDuration: parseTransitionValue,
  transitionTimingFunction(values) {
    if(typeof values === 'string') values = values.trim().split(/\s*,\s*/g);
    const ret = [];
    for(let i = 0; i < values.length; i++) {
      let value = values[i].toString();
      if(value === 'initial') value = 'ease';
      ret.push(value);
    }
    return ret;
  },
  transitionDelay: parseTransitionValue,
  transitionProperty: true,
  animationDuration: parseAnimationValue,
  animationDelay: parseAnimationValue,
  animationTimingFunction(value) {
    value = value.toString();
    return value !== 'initial' ? value : 'ease';
  },
  animationIterationCount(value) {
    value = value.toString();
    if(value === 'initial') return 1;
    if(value === 'infinity') return Infinity;
    return parseFloat(value);
  },
  animationDirection(value) {
    value = value.toString();
    return value !== 'initial' ? value : 'normal';
  },
  animationFillMode(value) {
    value = value.toString();
    if(value === 'initial' || value === 'none') value = 'auto';
    return value;
  },
  animationPlayState: true,
  animationName: true,
};

/* istanbul ignore next */
function parseRuleAttrs(rule) { // eslint-disable-line complexity
  let styleAttrs;
  const isStyleMap = !!rule.styleMap;
  if(!isStyleMap) {
    if(!rule.style) return;

    const props = [...rule.style].map((key) => {
      return [key, rule.style[key]];
    }).filter(([key, value]) => value != null);

    const matched = rule.cssText.match(/--sprite-[\w-]+\s*:\s*.+?(;|$)/img);
    if(matched) {
      matched.forEach((rule) => {
        const [key, value] = rule.split(':');
        props.push([key, value.trim().replace(/;$/, '')]);
      });
    }
    const isIgnore = props['--sprite-ignore'];
    if(isIgnore && isIgnore !== 'false' && isIgnore !== '0') {
      return;
    }

    styleAttrs = props;
  }
  if(rule.styleMap && rule.styleMap.has('--sprite-ignore')) {
    const isIgnore = rule.styleMap.get('--sprite-ignore')[0].trim();
    if(isIgnore !== 'false' && isIgnore !== '0' && isIgnore !== '') {
      return;
    }
  }
  if(rule.styleMap) {
    styleAttrs = [...rule.styleMap];
  }
  const attrs = {},
    reserved = {};
  let borderRadius = null;
  let transition = null;
  const gradient = {};

  styleAttrs.forEach(([key, value]) => { // eslint-disable-line complexity
    if(key === '--sprite-transition') {
      throw new Error('Not support --sprite-transition, instead use transition.');
    }
    if(key === '--sprite-animation') {
      throw new Error('Not support --sprite-animation, instead use animation.');
    }
    if(key.indexOf('--sprite-') === 0) {
      key = key.replace('--sprite-', '');
      key = toCamel(key);
      if(isStyleMap) value = value[0][0].trim();
      if(key === 'gradient') {
        // --sprite-gradient: bgcolor,color vector(0, 150, 150, 0) 0,#fff 0.5,rgba(33, 33, 77, 0.7) 1,rgba(128, 45, 88, 0.5)
        const matched = value.match(/(.+?)vector\((.+?)\)(.+)/);
        if(matched) {
          const properties = matched[1].trim().split(/\s*,\s*/g),
            vector = matched[2].split(',').map(s => Number(s.trim())),
            colors = matched[3].trim().split(/\s+/).map(
              (s) => {
                const m = s.match(/^([\d.]+),(.*)/);
                if(m) {
                  return {offset: Number(m[1].trim()), color: m[2].trim()};
                }
                return null;
              }
            ).filter(c => c != null);
          properties.forEach((prop) => {
            gradient[prop] = {vector, colors};
          });
        }
      } if(key === 'border') {
        const values = value.split(/\s+/);
        const [style, width, color] = values;
        reserved.border = {style, width, color};
      } else {
        if(key !== 'fontSize' && typeof value === 'string') {
          if(/,/.test(value)) {
            const values = value.split(',');
            value = values.map(v => toPxValue(v.trim()));
          } else {
            value = toPxValue(value);
          }
        }
        reserved[key] = value;
      }
    } else {
      key = toCamel(key);
      if(key in CSSGetter) {
        if(typeof CSSGetter[key] === 'function') {
          value = CSSGetter[key](value);
        } else {
          if(isStyleMap) {
            value = value[0].toString();
          }
          if(key !== 'fontSize') {
            value = toPxValue(value);
          }
        }
        if(/^animation/.test(key)) {
          attrs.animation = attrs.animation || {};
          attrs.animation[key] = value;
          return;
        }

        if(value === 'initial') return;
        if(key === 'backgroundColor') key = 'bgcolor';
        if(key === 'fontVariantCaps') key = 'fontVariant';
        if(key === 'lineHeight' && value === 'normal') value = '';

        if(/Radius$/.test(key)) {
          if(typeof value === 'string') {
            value = value.split(/\s+/).map(v => toPxValue(v));
          } else {
            value = [value, value];
          }
          borderRadius = borderRadius || [0, 0, 0, 0, 0, 0, 0, 0];
          if(key === 'borderTopLeftRadius') {
            borderRadius[0] = value[0];
            borderRadius[1] = value[1];
          } else if(key === 'borderTopRightRadius') {
            borderRadius[2] = value[0];
            borderRadius[3] = value[1];
          } else if(key === 'borderBottomRightRadius') {
            borderRadius[4] = value[0];
            borderRadius[5] = value[1];
          } else if(key === 'borderBottomLeftRadius') {
            borderRadius[6] = value[0];
            borderRadius[7] = value[1];
          }
        } else if(/^border(Left|Right|Top|Bottom)\w+/.test(key)) {
          if(/Color$/.test(key)) {
            attrs.borderColor = value;
          } else if(/Style$/.test(key)) {
            attrs.borderStyle = value;
          } else if(/Width$/.test(key)) {
            attrs.borderWidth = value;
          }
        } else if(key === 'transitionDelay') {
          transition = transition || {};
          transition.delay = value;
        } else if(key === 'transitionDuration') {
          transition = transition || {};
          transition.duration = value;
        } else if(key === 'transitionTimingFunction') {
          transition = transition || {};
          transition.easing = value;
        } else if(key === 'transitionProperty') {
          transition = transition || {};
          transition.properties = value;
        } else {
          attrs[key] = value;
        }
      }
    }
  });
  if(borderRadius) {
    attrs.borderRadius = borderRadius;
  }
  Object.assign(attrs, reserved, gradient);
  if(transition) {
    transition.properties = transition.properties || 'all';
    transition.delay = transition.delay || [0];
    transition.duration = transition.duration || [0];
    transition.easing = transition.easing || ['ease'];
    attrs.transitions = [];
    const properties = transition.properties.split(',').map(p => p.trim());
    properties.forEach((key, i) => {
      let _attrs = null;
      if(key.indexOf('--sprite-') === 0) {
        key = key.replace('--sprite-', '');
      }
      key = toCamel(key);
      if(key !== 'borderRadius' && /^border/.test(key)) {
        key = 'border';
      }
      if(key === 'backgroundColor' || key === 'background') key = 'bgcolor';
      if(key === 'fontVariantCaps') key = 'fontVariant';
      if(key === 'all') {
        _attrs = Object.assign({}, attrs);
        delete _attrs.transitions;
        delete _attrs.animation;
      } else if(key in attrs) {
        _attrs = {[key]: attrs[key]};
      }
      if(_attrs) {
        attrs.transitions.push({
          easing: transition.easing[i],
          attrs: _attrs,
          delay: transition.delay[i],
          duration: transition.duration[i]});
      }
    });
  }
  if('fontSize' in attrs
    || 'fontFamily' in attrs
    || 'fontStyle' in attrs
    || 'fontVariant' in attrs
    || 'fontWeight' in attrs) {
    // for font inherit
    const font = attrs.font || 'normal normal normal 16px Arial';
    const {style, variant, weight, family, size, unit} = parseFont(font);
    attrs.font = `${attrs.fontStyle || style} ${attrs.fontVariant || variant} ${attrs.fontWeight || weight} ${attrs.fontSize || size + unit} ${attrs.fontFamily || family}`;

    delete attrs.fontSize;
    delete attrs.fontFamily;
    delete attrs.fontVariant;
    delete attrs.fontWeight;
    delete attrs.fontStyle;
  }
  return attrs;
}

function parseFrames(rule) /* istanbul ignore next */ {
  const rules = rule.cssRules || rule.rules;
  if(rules && rules.length > 0) {
    const frames = [];
    for(let i = 0; i < rules.length; i++) {
      const rule = rules[i];
      const offset = parseFloat(rule.keyText) / 100;
      const frame = parseRuleAttrs(rule);
      frame.offset = offset;
      frames.push(frame);
    }
    return frames;
  }
}

/* istanbul ignore next */
function toCamel(str) {
  return str.replace(/([^-])(?:-+([^-]))/g, ($0, $1, $2) => {
    return $1 + $2.toUpperCase();
  });
}

function resolveToken(token) { // eslint-disable-line complexity
  let ret = '',
    priority = 0,
    valid = true;

  if(token.type === 'tag') {
    ret = token.name;
    priority = 1;
  } else if(token.type === 'universal') {
    ret = '*';
    priority = 0;
  } else if(token.type === 'pseudo') {
    const data = token.data;
    if(data != null) {
      if(token.name === 'not') {
        data.forEach((rules) => {
          rules.forEach((token) => {
            const r = resolveToken(token);
            ret += r.token;
            valid &= r.valid;
          });
        });
        ret = `:${token.name}(${ret})`;
      } else {
        ret = `:${token.name}(${token.data})`;
      }
    } else {
      ret = `:${token.name}`;
    }
    if(token.name === 'hover') /* istanbul ignore next */ {
      relatedAttributes.add('__internal_state_hover_');
    } else if(token.name === 'active') /* istanbul ignore next */ {
      relatedAttributes.add('__internal_state_active_');
    }
    // not support yet
    valid = token.name !== 'focus'
      && token.name !== 'link'
      && token.name !== 'visited'
      && token.name !== 'lang';
    priority = token.name !== 'not' ? 1000 : 0;
  } else if(token.type === 'pseudo-element') /* istanbul ignore next */ {
    ret = `::${token.name}`;
    priority = 1;
    valid = false; // pseudo-element not support
  } else if(token.type === 'attribute') {
    const {name, action, value} = token;

    relatedAttributes.add(name);

    if(action === 'exists') {
      ret = `[${name}]`;
    } else if(action === 'equals') {
      if(name === 'id') {
        ret = `#${value}`;
      } else {
        ret = `[${name}="${value}"]`;
      }
    } else if(action === 'not') /* istanbul ignore next */ {
      throw new Error('Attribute \'not\' action is not allowed.');
      // ret = `[${name}!="${value}"]`;
    } else if(action === 'start') {
      ret = `[${name}^="${value}"]`;
    } else if(action === 'end') {
      ret = `[${name}$="${value}"]`;
    } else if(action === 'element') {
      if(name === 'class') {
        ret = `.${value}`;
      } else {
        ret = `[${name}~="${value}"]`;
      }
    } else if(action === 'any') {
      ret = `[${name}*="${value}"]`;
    } else if(action === 'hyphen') {
      ret = `[${name}|="${value}"]`;
    }
    if(name === 'id' && action === 'equals') {
      priority = 1000000;
    } else {
      priority = 1000;
    }
  } else if(token.type === 'child') {
    ret = '>';
    priority = 0;
  } else if(token.type === 'parent') /* istanbul ignore next */ {
    throw new Error('Parent selector is not allowed.');
    // ret = '<';
    // priority = 0;
  } else if(token.type === 'sibling') {
    ret = '~';
    priority = 0;
  } else if(token.type === 'adjacent') {
    ret = '+';
    priority = 0;
  } else if(token.type === 'descendant') {
    ret = ' ';
    priority = 0;
  } else /* istanbul ignore next */ {
    throw new Error(`Unknown token ${token}.`);
  }
  return {token: ret, priority, valid};
}

let order = 0;

export default {
  add(rules, fromDoc = false) {
    Object.entries(rules).forEach(([rule, attributes]) => {
      const selectors = cssWhat.parse(rule);
      for(let i = 0; i < selectors.length; i++) {
        const selector = selectors[i];
        const tokens = selector.map((token) => {
          return resolveToken(token);
        }).filter(token => token.valid);

        const r = tokens.reduce((a, b) => {
          a.priority += b.priority;
          a.tokens.push(b.token);
          return a;
        }, {tokens: [], priority: 0});

        const selectorStr = r.tokens.join('');

        try {
          const compiled = compile(selectorStr);

          const rule = {
            selector: selectorStr,
            compiled,
            priority: r.priority,
            attributes,
            order: order++,
            fromDoc,
          };
          cssRules.push(rule);
        } catch (ex) /* istanbul ignore next */ {
          console.warn(ex.message);
        }
      }
    });
    cssRules.sort((a, b) => {
      const d = a.priority - b.priority;
      return d !== 0 ? d : a.order - b.order;
    });
  },
  fromDocumentCSS(stylesheets, override) /* istanbul ignore next */ {
    if(override) {
      cssRules = cssRules.filter(r => !r.fromDoc);
    }
    if(typeof document === 'undefined') return;
    if(!stylesheets) stylesheets = document.styleSheets;
    if(stylesheets) {
      const styleRules = {};
      for(let i = 0; i < stylesheets.length; i++) {
        let rules = null;
        try {
          rules = stylesheets[i].cssRules || stylesheets[i].rules;
        } catch (ex) {
          rules = null;
        }

        if(!rules) continue; // eslint-disable-line no-continue
        for(let j = 0; j < rules.length; j++) {
          const rule = rules[j];
          const selectorText = rule.selectorText;

          if(rule.type !== 1 && rule.type !== 7) { // is not style rule or keyframesrule
            continue; // eslint-disable-line no-continue
          }

          if(rule.type === 7) {
            const frames = parseFrames(rule);
            keyFrames[rule.name] = frames;
            continue; // eslint-disable-line no-continue
          }

          const attrs = parseRuleAttrs(rule);

          if(attrs) {
            styleRules[selectorText] = styleRules[selectorText] || {};
            Object.assign(styleRules[selectorText], attrs);
          }
        }
      }
      this.add(styleRules, true);
    }
  },
  computeStyle(el) {
    if(!el.attributes) return {};
    el.__styleNeedUpdate = false;
    if(cssRules.length <= 0) return {};
    const attrs = {};
    const selectors = [];
    const transitions = [];
    cssRules.forEach((rule) => {
      const {compiled, selector, attributes} = rule;
      if(isMatched(el, compiled)) {
        Object.assign(attrs, attributes);
        // console.log(JSON.stringify(attrs.transitions));
        if(attrs.transitions) /* istanbul ignore next */ {
          transitions.push(...attrs.transitions);
          attrs.transitions.forEach((t) => {
            Object.keys(t.attrs).forEach((k) => {
              // if(k in attrs) delete attrs[k];
              el.attributes.__getStyleTag = true;
              if(el.attributes[k]) {
                attrs[k] = el.attributes[k];
              }
              el.attributes.__getStyleTag = false;
              // console.log(el.attributes.style[k]);
            });
          });
          delete attrs.transitions;
        }
        selectors.push(selector);
      }
    });
    // if(selectors.length <= 0) return;
    const matchedSelectors = selectors.join();
    if(el[_matchedSelectors] !== matchedSelectors) {
      // console.log(transitions);
      /* istanbul ignore if */
      if(attrs.animation) {
        const animation = attrs.animation;
        const delay = animation.animationDelay,
          direction = animation.animationDirection,
          duration = animation.animationDuration,
          fill = animation.animationFillMode,
          iterations = animation.animationIterationCount,
          name = animation.animationName,
          playState = animation.animationPlayState,
          easing = animation.animationTimingFunction;

        const frames = keyFrames[name];
        if(frames) {
          if(el[_animation]) {
            el[_animation].cancel();
          }
          el[_animation] = el.animate(frames, {duration, delay, fill, iterations, easing, direction});
          el.setReleaseKey(_animation);
          if(playState !== 'running') el[_animation].pause();
        } else {
          console.warn(`Unknow animation: ${name}`);
        }
        delete attrs.animation;
      }

      /* istanbul ignore if */
      if(el[_transitions]) {
        el[_transitions].forEach((t) => {
          t.cancel(true);
          el.attributes.__styleTag = true;
          el.attr(t.__attrs);
          el.attributes.__styleTag = false;
        });
        delete el[_transitions];
      }

      /* istanbul ignore if */
      if(transitions.length > 0) {
        el[_transitions] = [];
        el.setReleaseKey(_transitions);
        Promise.all(transitions.map((t) => {
          const {attrs, delay, duration, easing} = t;
          const transition = el.transition({duration, delay}, easing, true);
          transition.__attrs = attrs;
          el[_transitions].push(transition);
          return transition.attr(Object.assign({}, attrs));
        })).then(() => {
          el.dispatchEvent('transitionend', {}, true, true);
        });
      }
      el.dispatchEvent('stylechange', {oldSelectors: el[_matchedSelectors], newSelectors: matchedSelectors},
        true, true);
      el[_matchedSelectors] = matchedSelectors;
      el.attributes.clearStyle();
      el.attributes.__styleTag = true;
      el.attr(attrs);
      el.attributes.__styleTag = false;
      // if(el.forceUpdate) el.forceUpdate();
    }
    return attrs;
  },
  get relatedAttributes() {
    return relatedAttributes;
  },
  get cssRules() {
    return cssRules;
  },
};