import * as React from 'react'; import { Component, useEffect, useMemo, useState } from 'react'; import type { JsxAST, Middleware, Styles, UriProps, UriState, XmlAST, XmlProps, XmlState, } from 'react-native-svg'; import { camelCase, fetchText, parse, SvgAst } from 'react-native-svg'; import type { Atrule, AtrulePrelude, CssNode, Declaration, DeclarationList, ListItem, PseudoClassSelector, Rule, Selector, SelectorList, } from 'css-tree'; import csstree, { List } from 'css-tree'; import type { Options } from 'css-select'; 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: XmlAST | string): node is XmlAST { return typeof node === 'object'; } // get the parent of the node // getParent: ( node:Node ) => parentNode:Node // returns null when no parent exists function getParent(node: XmlAST | string): XmlAST { return ((typeof node === 'object' && node.parent) || null) as XmlAST; } // get the node's children // getChildren: ( node:Node ) => children:[Node] function getChildren(node: XmlAST | string): Array { return (typeof node === 'object' && node.children) || []; } // get the name of the tag' // getName: ( elem:ElementNode ) => tagName:String function getName(elem: XmlAST): string { 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: XmlAST | string): string { return ''; } // get the attribute value // getAttributeValue: ( elem:ElementNode, name:String ) => value:String // returns null when attribute doesn't exist function getAttributeValue(elem: XmlAST, name: string): string { return (elem.props[name] || null) as string; } // takes an array of nodes, and removes any duplicates, as well as any nodes // whose ancestors are also in the array function removeSubsets(nodes: Array): Array { 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: (v: XmlAST) => boolean, elems: Array ): boolean { 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: XmlAST | string): Array { const parent = typeof node === 'object' && node.parent; return (parent && parent.children) || []; } // does the element have the named attribute? function hasAttrib(elem: XmlAST, name: string): boolean { 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: (v: XmlAST) => boolean, elems: Array ): XmlAST | null { let elem: XmlAST | null = 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: (v: XmlAST) => boolean, nodes: Array, result: Array = [] ): Array { 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: Options = { xmlMode: true, adapter: { removeSubsets, existsOne, getSiblings, hasAttrib, findOne, findAll, isTag, getParent, getChildren, getName, getText, getAttributeValue, }, }; type FlatPseudoSelector = { item: ListItem; list: List; }; type FlatPseudoSelectorList = FlatPseudoSelector[]; type FlatSelector = { item: ListItem; atrule: Atrule | null; rule: CssNode; pseudos: FlatPseudoSelectorList; }; type FlatSelectorList = FlatSelector[]; /** * Flatten a CSS AST to a selectors list. * * @param {Object} cssAst css-tree AST to flatten * @param {Array} selectors */ function flattenToSelectors(cssAst: CssNode, selectors: FlatSelectorList) { csstree.walk(cssAst, { visit: 'Rule', enter(rule: CssNode) { const { type, prelude } = rule as Rule; if (type !== 'Rule') { return; } const atrule = this.atrule; (prelude as SelectorList).children.each((node, item) => { const { children } = node as Selector; const pseudos: FlatPseudoSelectorList = []; 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: FlatSelectorList) { return selectors.filter(({ atrule }) => { if (atrule === null) { return true; } const { name, prelude } = atrule; const atPrelude = prelude as AtrulePrelude; 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 ( ) 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: FlatSelectorList) { 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: FlatSelectorList) { selectors.forEach(({ pseudos }) => pseudos.forEach((pseudo) => pseudo.list.remove(pseudo.item)) ); } type Specificity = [number, number, number]; function specificity(selector: Selector): Specificity { let A = 0; let B = 0; let C = 0; selector.children.each(function walk(node: CssNode) { 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 as PseudoClassSelector).children; children && children.each(walk); break; } case 'before': case 'after': case 'first-line': case 'first-letter': C++; break; // TODO: support for :nth-*(.. of ), :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: Specificity, bSpecificity: Specificity ): number { 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; } type Spec = { selector: FlatSelector; specificity: Specificity; }; function selectorWithSpecificity(selector: FlatSelector): Spec { return { selector, specificity: specificity(selector.item.data as Selector), }; } /** * 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: Spec, b: Spec): number { return compareSpecificity(a.specificity, b.specificity); } // Run a single pass with the given chunk size. function pass(arr: Spec[], len: number, chk: number, result: Spec[]) { // 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: Spec[], len: number): Spec[] { // 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: FlatSelectorList) { // 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: XmlAST) { const { props, styles } = ast; if (!props.style) { props.style = {}; } const style = props.style as Styles; const priority = new Map(); ast.style = style; ast.priority = priority; if (!styles || styles.length === 0) { return; } try { const declarations = csstree.parse( styles, declarationParseProps ) as DeclarationList; declarations.children.each((node) => { try { const { property, value, important } = node as Declaration; 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 ); } } interface StyledAST extends XmlAST { style: Styles; priority: Map; } function initStyle(selectedEl: XmlAST): StyledAST { if (!selectedEl.style) { CSSStyleDeclaration(selectedEl); } return selectedEl as StyledAST; } /** * Find the closest ancestor of the current element. * @param node * @param elemName * @return {?Object} */ function closestElem(node: XmlAST, elemName: string) { let elem: XmlAST | null = 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 * @author modified by: msand */ function extractVariables(stylesheet: CssNode): Map { const variables = new Map(); csstree.walk(stylesheet, { visit: 'Declaration', enter(node) { const { property, value } = node as Declaration; if (property.startsWith('--')) { const variableName = property.trim(); const variableValue = csstree.generate(value).trim(); variables.set(variableName, variableValue); } }, }); return variables; } function resolveVariables( value: string | CssNode | undefined, variables: Map ): string { 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: XmlAST, variables: Map ) => propsToResolve.forEach((prop) => { const value = element.props[prop] as string; if (value && value.startsWith('var(')) { element.props[prop] = resolveVariables(value, variables); } }); export const inlineStyles: Middleware = function inlineStyles( document: XmlAST ) { // collect