react-native-svg
Version:
SVG library for react-native
786 lines (746 loc) • 20.9 kB
JavaScript
import * as React from 'react';
import { Component, useEffect, useMemo, useState } from 'react';
import { camelCase, fetchText, parse, SvgAst } from 'react-native-svg';
import csstree, { List } from 'css-tree';
import cssSelect from 'css-select';
const err = console.error.bind(console);
/*
* Style element inlining experiment based on SVGO
* https://github.com/svg/svgo/blob/11f9c797411a8de966aacc4cb83dbb3e471757bc/plugins/inlineStyles.js
* */
/**
* DOMUtils API for rnsvg AST (used by css-select)
*/
// is the node a tag?
// isTag: ( node:Node ) => isTag:Boolean
function isTag(node) {
return typeof node === 'object';
}
// get the parent of the node
// getParent: ( node:Node ) => parentNode:Node
// returns null when no parent exists
function getParent(node) {
return typeof node === 'object' && node.parent || null;
}
// get the node's children
// getChildren: ( node:Node ) => children:[Node]
function getChildren(node) {
return typeof node === 'object' && node.children || [];
}
// get the name of the tag'
// getName: ( elem:ElementNode ) => tagName:String
function getName(elem) {
return elem.tag;
}
// get the text content of the node, and its children if it has any
// getText: ( node:Node ) => text:String
// returns empty string when there is no text
function getText(_node) {
return '';
}
// get the attribute value
// getAttributeValue: ( elem:ElementNode, name:String ) => value:String
// returns null when attribute doesn't exist
function getAttributeValue(elem, name) {
return elem.props[name] || null;
}
// takes an array of nodes, and removes any duplicates, as well as any nodes
// whose ancestors are also in the array
function removeSubsets(nodes) {
let idx = nodes.length;
let node;
let ancestor;
let replace;
// Check if each node (or one of its ancestors) is already contained in the
// array.
while (--idx > -1) {
node = ancestor = nodes[idx];
// Temporarily remove the node under consideration
delete nodes[idx];
replace = true;
while (ancestor) {
if (nodes.includes(ancestor)) {
replace = false;
nodes.splice(idx, 1);
break;
}
ancestor = typeof ancestor === 'object' && ancestor.parent || null;
}
// If the node has been found to be unique, re-insert it.
if (replace) {
nodes[idx] = node;
}
}
return nodes;
}
// does at least one of passed element nodes pass the test predicate?
function existsOne(predicate, elems) {
return elems.some(elem => typeof elem === 'object' && (predicate(elem) || existsOne(predicate, elem.children)));
}
/*
get the siblings of the node. Note that unlike jQuery's `siblings` method,
this is expected to include the current node as well
*/
function getSiblings(node) {
const parent = typeof node === 'object' && node.parent;
return parent && parent.children || [];
}
// does the element have the named attribute?
function hasAttrib(elem, name) {
return Object.prototype.hasOwnProperty.call(elem.props, name);
}
// finds the first node in the array that matches the test predicate, or one
// of its children
function findOne(predicate, elems) {
let elem = null;
for (let i = 0, l = elems.length; i < l && !elem; i++) {
const node = elems[i];
if (typeof node === 'string') {
/* empty */
} else if (predicate(node)) {
elem = node;
} else {
const {
children
} = node;
if (children.length !== 0) {
elem = findOne(predicate, children);
}
}
}
return elem;
}
// finds all of the element nodes in the array that match the test predicate,
// as well as any of their children that match it
function findAll(predicate, nodes, result = []) {
for (let i = 0, j = nodes.length; i < j; i++) {
const node = nodes[i];
if (typeof node !== 'object') {
continue;
}
if (predicate(node)) {
result.push(node);
}
const {
children
} = node;
if (children.length !== 0) {
findAll(predicate, children, result);
}
}
return result;
}
const cssSelectOpts = {
xmlMode: true,
adapter: {
removeSubsets,
existsOne,
getSiblings,
hasAttrib,
findOne,
findAll,
isTag,
getParent,
getChildren,
getName,
getText,
getAttributeValue
}
};
/**
* Flatten a CSS AST to a selectors list.
*
* @param {Object} cssAst css-tree AST to flatten
* @param {Array} selectors
*/
function flattenToSelectors(cssAst, selectors) {
csstree.walk(cssAst, {
visit: 'Rule',
enter(rule) {
const {
type,
prelude
} = rule;
if (type !== 'Rule') {
return;
}
const atrule = this.atrule;
prelude.children.each((node, item) => {
const {
children
} = node;
const pseudos = [];
selectors.push({
item,
atrule,
rule,
pseudos
});
children.each(({
type: childType
}, pseudoItem, list) => {
if (childType === 'PseudoClassSelector' || childType === 'PseudoElementSelector') {
pseudos.push({
item: pseudoItem,
list
});
}
});
});
}
});
}
/**
* Filter selectors by Media Query.
*
* @param {Array} selectors to filter
* @return {Array} Filtered selectors that match the passed media queries
*/
function filterByMqs(selectors) {
return selectors.filter(({
atrule
}) => {
if (atrule === null) {
return true;
}
const {
name,
prelude
} = atrule;
const atPrelude = prelude;
const first = atPrelude && atPrelude.children.first();
const mq = first && first.type === 'MediaQueryList';
const query = mq ? csstree.generate(atPrelude) : name;
return useMqs.includes(query);
});
}
// useMqs Array with strings of media queries that should pass (<name> <expression>)
const useMqs = ['', 'screen'];
/**
* Filter selectors by the pseudo-elements and/or -classes they contain.
*
* @param {Array} selectors to filter
* @return {Array} Filtered selectors that match the passed pseudo-elements and/or -classes
*/
function filterByPseudos(selectors) {
return selectors.filter(({
pseudos
}) => usePseudos.includes(csstree.generate({
type: 'Selector',
children: new List().fromArray(pseudos.map(pseudo => pseudo.item.data))
})));
}
// usePseudos Array with strings of single or sequence of pseudo-elements and/or -classes that should pass
const usePseudos = [''];
/**
* Remove pseudo-elements and/or -classes from the selectors for proper matching.
*
* @param {Array} selectors to clean
* @return {Array} Selectors without pseudo-elements and/or -classes
*/
function cleanPseudos(selectors) {
selectors.forEach(({
pseudos
}) => pseudos.forEach(pseudo => pseudo.list.remove(pseudo.item)));
}
function specificity(selector) {
let A = 0;
let B = 0;
let C = 0;
selector.children.each(function walk(node) {
switch (node.type) {
case 'SelectorList':
case 'Selector':
node.children.each(walk);
break;
case 'IdSelector':
A++;
break;
case 'ClassSelector':
case 'AttributeSelector':
B++;
break;
case 'PseudoClassSelector':
switch (node.name.toLowerCase()) {
case 'not':
{
const children = node.children;
children && children.each(walk);
break;
}
case 'before':
case 'after':
case 'first-line':
case 'first-letter':
C++;
break;
// TODO: support for :nth-*(.. of <SelectorList>), :matches(), :has()
default:
B++;
}
break;
case 'PseudoElementSelector':
C++;
break;
case 'TypeSelector':
{
// ignore universal selector
const {
name
} = node;
if (name.charAt(name.length - 1) !== '*') {
C++;
}
break;
}
}
});
return [A, B, C];
}
/**
* Compares two selector specificities.
* extracted from https://github.com/keeganstreet/specificity/blob/master/specificity.js#L211
*
* @param {Array} aSpecificity Specificity of selector A
* @param {Array} bSpecificity Specificity of selector B
* @return {Number} Score of selector specificity A compared to selector specificity B
*/
function compareSpecificity(aSpecificity, bSpecificity) {
for (let i = 0; i < 4; i += 1) {
if (aSpecificity[i] < bSpecificity[i]) {
return -1;
} else if (aSpecificity[i] > bSpecificity[i]) {
return 1;
}
}
return 0;
}
function selectorWithSpecificity(selector) {
return {
selector,
specificity: specificity(selector.item.data)
};
}
/**
* Compare two simple selectors.
*
* @param {Object} a Simple selector A
* @param {Object} b Simple selector B
* @return {Number} Score of selector A compared to selector B
*/
function bySelectorSpecificity(a, b) {
return compareSpecificity(a.specificity, b.specificity);
}
// Run a single pass with the given chunk size.
function pass(arr, len, chk, result) {
// Step size / double chunk size.
const dbl = chk * 2;
// Bounds of the left and right chunks.
let l, r, e;
// Iterators over the left and right chunk.
let li, ri;
// Iterate over pairs of chunks.
let i = 0;
for (l = 0; l < len; l += dbl) {
r = l + chk;
e = r + chk;
if (r > len) {
r = len;
}
if (e > len) {
e = len;
}
// Iterate both chunks in parallel.
li = l;
ri = r;
while (true) {
// Compare the chunks.
if (li < r && ri < e) {
// This works for a regular `sort()` compatible comparator,
// but also for a simple comparator like: `a > b`
if (bySelectorSpecificity(arr[li], arr[ri]) <= 0) {
result[i++] = arr[li++];
} else {
result[i++] = arr[ri++];
}
}
// Nothing to compare, just flush what's left.
else if (li < r) {
result[i++] = arr[li++];
} else if (ri < e) {
result[i++] = arr[ri++];
}
// Both iterators are at the chunk ends.
else {
break;
}
}
}
}
// Execute the sort using the input array and a second buffer as work space.
// Returns one of those two, containing the final result.
function exec(arr, len) {
// Rather than dividing input, simply iterate chunks of 1, 2, 4, 8, etc.
// Chunks are the size of the left or right hand in merge sort.
// Stop when the left-hand covers all of the array.
let buffer = new Array(len);
for (let chk = 1; chk < len; chk *= 2) {
pass(arr, len, chk, buffer);
const tmp = arr;
arr = buffer;
buffer = tmp;
}
return arr;
}
/**
* Sort selectors stably by their specificity.
*
* @param {Array} selectors to be sorted
* @return {Array} Stable sorted selectors
*/
function sortSelectors(selectors) {
// Short-circuit when there's nothing to sort.
const len = selectors.length;
if (len <= 1) {
return selectors;
}
const specs = selectors.map(selectorWithSpecificity);
return exec(specs, len).map(s => s.selector);
}
const declarationParseProps = {
context: 'declarationList',
parseValue: false
};
function CSSStyleDeclaration(ast) {
const {
props,
styles
} = ast;
if (!props.style) {
props.style = {};
}
const style = props.style;
const priority = new Map();
ast.style = style;
ast.priority = priority;
if (!styles || styles.length === 0) {
return;
}
try {
const declarations = csstree.parse(styles, declarationParseProps);
declarations.children.each(node => {
try {
const {
property,
value,
important
} = node;
const name = property.trim();
priority.set(name, important);
style[camelCase(name)] = csstree.generate(value).trim();
} catch (styleError) {
if (styleError instanceof Error && styleError.message !== 'Unknown node type: undefined') {
console.warn("Warning: Parse error when parsing inline styles, style properties of this element cannot be used. The raw styles can still be get/set using .attr('style').value. Error details: " + styleError);
}
}
});
} catch (parseError) {
console.warn("Warning: Parse error when parsing inline styles, style properties of this element cannot be used. The raw styles can still be get/set using .attr('style').value. Error details: " + parseError);
}
}
function initStyle(selectedEl) {
if (!selectedEl.style) {
CSSStyleDeclaration(selectedEl);
}
return selectedEl;
}
/**
* Find the closest ancestor of the current element.
* @param node
* @param elemName
* @return {?Object}
*/
function closestElem(node, elemName) {
let elem = node;
while ((elem = elem.parent) && elem.tag !== elemName) {
/* empty */
}
return elem;
}
const parseProps = {
parseValue: false,
parseCustomProperty: false
};
/**
* Moves + merges styles from style elements to element styles
*
* Options
* useMqs (default: ['', 'screen'])
* what media queries to be used
* empty string element for styles outside media queries
*
* usePseudos (default: [''])
* what pseudo-classes/-elements to be used
* empty string element for all non-pseudo-classes and/or -elements
*
* @param {Object} document document element
*
* @author strarsis <strarsis@gmail.com>
* @author modified by: msand <msand@abo.fi>
*/
function extractVariables(stylesheet) {
const variables = new Map();
csstree.walk(stylesheet, {
visit: 'Declaration',
enter(node) {
const {
property,
value
} = node;
if (property.startsWith('--')) {
const variableName = property.trim();
const variableValue = csstree.generate(value).trim();
variables.set(variableName, variableValue);
}
}
});
return variables;
}
function resolveVariables(value, variables) {
if (value === undefined) {
return '';
}
const valueStr = typeof value === 'string' ? value : csstree.generate(value);
return valueStr.replace(/var\((--[^,)]+)(?:,\s*([^)]+))?\)/g, (_, variableName, fallback) => {
const resolvedValue = variables.get(variableName);
if (resolvedValue !== undefined) {
return resolveVariables(resolvedValue, variables);
}
return fallback ? resolveVariables(fallback, variables) : '';
});
}
const propsToResolve = ['color', 'fill', 'floodColor', 'lightingColor', 'stopColor', 'stroke'];
const resolveElementVariables = (element, variables) => propsToResolve.forEach(prop => {
const value = element.props[prop];
if (value && value.startsWith('var(')) {
element.props[prop] = resolveVariables(value, variables);
}
});
export const inlineStyles = function inlineStyles(document) {
// collect <style/>s
const styleElements = cssSelect('style', document, cssSelectOpts);
// no <styles/>s, nothing to do
if (styleElements.length === 0) {
return document;
}
const selectors = [];
let variables = new Map();
for (const element of styleElements) {
const {
children
} = element;
if (!children.length || closestElem(element, 'foreignObject')) {
// skip empty <style/>s or <foreignObject> content.
continue;
}
// collect <style/>s and their css ast
try {
const styleString = children.join('');
const stylesheet = csstree.parse(styleString, parseProps);
variables = extractVariables(stylesheet);
flattenToSelectors(stylesheet, selectors);
} catch (parseError) {
console.warn('Warning: Parse error of styles of <style/> element, skipped. Error details: ' + parseError);
}
}
// filter for mediaqueries to be used or without any mediaquery
const selectorsMq = filterByMqs(selectors);
// filter for pseudo elements to be used
const selectorsPseudo = filterByPseudos(selectorsMq);
// remove PseudoClass from its SimpleSelector for proper matching
cleanPseudos(selectorsPseudo);
// stable sort selectors
const sortedSelectors = sortSelectors(selectorsPseudo).reverse();
const elementsWithColor = cssSelect('*[color], *[fill], *[floodColor], *[lightingColor], *[stopColor], *[stroke]', document, cssSelectOpts);
for (const element of elementsWithColor) {
resolveElementVariables(element, variables);
}
// match selectors
for (const {
rule,
item
} of sortedSelectors) {
if (rule === null) {
continue;
}
const selectorStr = csstree.generate(item.data);
try {
// apply <style/> to matched elements
const matched = cssSelect(selectorStr, document, cssSelectOpts).map(initStyle);
if (matched.length === 0) {
continue;
}
csstree.walk(rule, {
visit: 'Declaration',
enter(node) {
const {
property,
value,
important
} = node;
// existing inline styles have higher priority
// no inline styles, external styles, external styles used
// inline styles, external styles same priority as inline styles, inline styles used
// inline styles, external styles higher priority than inline styles, external styles used
const name = property.trim();
const camel = camelCase(name);
const val = csstree.generate(value).trim();
for (const element of matched) {
const {
style,
priority
} = element;
const current = priority.get(name);
if (current === undefined || current < important) {
priority.set(name, important);
// Handle if value is undefined
if (val !== undefined) {
style[camel] = val;
} else {
console.warn(`Undefined value for style property: ${camel}`);
}
}
}
}
});
} catch (selectError) {
if (selectError instanceof SyntaxError) {
console.warn('Warning: Syntax error when trying to select \n\n' + selectorStr + '\n\n, skipped. Error details: ' + selectError);
continue;
}
throw selectError;
}
}
return document;
};
export function SvgCss(props) {
const {
xml,
override,
fallback,
onError = err
} = props;
try {
const ast = useMemo(() => xml !== null ? parse(xml, inlineStyles) : null, [xml]);
return /*#__PURE__*/React.createElement(SvgAst, {
ast: ast,
override: override || props
});
} catch (error) {
onError(error);
return fallback ?? null;
}
}
export function SvgCssUri(props) {
const {
uri,
onError = err,
onLoad,
fallback
} = props;
const [xml, setXml] = useState(null);
const [isError, setIsError] = useState(false);
useEffect(() => {
uri ? fetchText(uri).then(data => {
setXml(data);
onLoad === null || onLoad === void 0 || onLoad();
}).catch(e => {
onError(e);
setIsError(true);
}) : setXml(null);
}, [onError, uri, onLoad]);
if (isError) {
return fallback ?? null;
}
return /*#__PURE__*/React.createElement(SvgCss, {
xml: xml,
override: props,
fallback: fallback
});
}
// Extending Component is required for Animated support.
export class SvgWithCss extends Component {
state = {
ast: null
};
componentDidMount() {
this.parse(this.props.xml);
}
componentDidUpdate(prevProps) {
const {
xml
} = this.props;
if (xml !== prevProps.xml) {
this.parse(xml);
}
}
parse(xml) {
try {
this.setState({
ast: xml ? parse(xml, inlineStyles) : null
});
} catch (e) {
this.props.onError ? this.props.onError(e) : console.error(e);
}
}
render() {
const {
props,
state: {
ast
}
} = this;
return /*#__PURE__*/React.createElement(SvgAst, {
ast: ast,
override: props.override || props
});
}
}
export class SvgWithCssUri extends Component {
state = {
xml: null
};
componentDidMount() {
this.fetch(this.props.uri);
}
componentDidUpdate(prevProps) {
const {
uri
} = this.props;
if (uri !== prevProps.uri) {
this.fetch(uri);
}
}
async fetch(uri) {
try {
this.setState({
xml: uri ? await fetchText(uri) : null
});
} catch (e) {
this.props.onError ? this.props.onError(e) : console.error(e);
}
}
render() {
const {
props,
state: {
xml
}
} = this;
return /*#__PURE__*/React.createElement(SvgWithCss, {
xml: xml,
override: props
});
}
}
//# sourceMappingURL=css.js.map