import '../../globals'; import { isCssVariable } from '../core/properties'; import { isNullOrUndefined } from '../../utils/types'; import * as ReworkCSS from '../../css'; import { Combinator as ICombinator, SimpleSelectorSequence as ISimpleSelectorSequence, Selector as ISelector, SimpleSelector as ISimpleSelector, parseSelector } from '../../css/parser'; /** * An interface describing the shape of a type on which the selectors may apply. * Note, the ui/core/view.View implements Node. */ export interface Node { parent?: Node; _modalParent?: Node; id?: string; nodeName?: string; cssType?: string; cssClasses?: Set; cssPseudoClasses?: Set; getChildIndex?(node: Node): number; getChildAt?(index: number): Node; } export interface Declaration { property: string; value: string; } export type ChangeMap = Map; export interface Changes { attributes?: Set; pseudoClasses?: Set; } const enum Specificity { Inline = 1000, Id = 100, Attribute = 10, Class = 10, PseudoClass = 10, Type = 1, Universal = 0, Invalid = 0, } const enum Rarity { Invalid = 4, Id = 3, Class = 2, Type = 1, PseudoClass = 0, Attribute = 0, Universal = 0, Inline = 0, } interface LookupSorter { sortById(id: string, sel: SelectorCore); sortByClass(cssClass: string, sel: SelectorCore); sortByType(cssType: string, sel: SelectorCore); sortAsUniversal(sel: SelectorCore); } namespace Match { /** * Depends on attributes or pseudoclasses state; */ export const Dynamic = true; /** * Depends only on the tree structure. */ export const Static = false; } function getNodeDirectSibling(node): null | Node { if (!node.parent || !node.parent.getChildIndex || !node.parent.getChildAt) { return null; } const nodeIndex = node.parent.getChildIndex(node); if (nodeIndex === 0) { return null; } return node.parent.getChildAt(nodeIndex - 1); } function SelectorProperties(specificity: Specificity, rarity: Rarity, dynamic = false): ClassDecorator { return (cls) => { cls.prototype.specificity = specificity; cls.prototype.rarity = rarity; cls.prototype.combinator = undefined; cls.prototype.dynamic = dynamic; return cls; }; } declare type Combinator = '+' | '>' | '~' | ' '; @SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static) export abstract class SelectorCore { public pos: number; public specificity: number; public rarity: Rarity; public combinator: Combinator; public ruleset: RuleSet; /** * Dynamic selectors depend on attributes and pseudo classes. */ public dynamic: boolean; public abstract match(node: Node): boolean; /** * If the selector is static returns if it matches the node. * If the selector is dynamic returns if it may match the node, and accumulates any changes that may affect its state. */ public abstract accumulateChanges(node: Node, map: ChangeAccumulator): boolean; public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortAsUniversal(base || this); } } export abstract class SimpleSelector extends SelectorCore { public accumulateChanges(node: Node, map?: ChangeAccumulator): boolean { if (!this.dynamic) { return this.match(node); } else if (this.mayMatch(node)) { this.trackChanges(node, map); return true; } return false; } public mayMatch(node: Node): boolean { return this.match(node); } public trackChanges(node: Node, map: ChangeAccumulator): void { // No-op, silence the tslint 'block is empty'. // Some derived classes (dynamic) will actually fill the map with stuff here, some (static) won't do anything. } } function wrap(text: string): string { return text ? ` ${text} ` : ''; } @SelectorProperties(Specificity.Invalid, Rarity.Invalid, Match.Static) export class InvalidSelector extends SimpleSelector { constructor(public e: Error) { super(); } public toString(): string { return ``; } public match(node: Node): boolean { return false; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { // No-op, silence the tslint 'block is empty'. // It feels like tslint has problems with simple polymorphism... // This selector is invalid and will never match so we won't bother sorting it to further appear in queries. } } @SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static) export class UniversalSelector extends SimpleSelector { public toString(): string { return `*${wrap(this.combinator)}`; } public match(node: Node): boolean { return true; } } @SelectorProperties(Specificity.Id, Rarity.Id, Match.Static) export class IdSelector extends SimpleSelector { constructor(public id: string) { super(); } public toString(): string { return `#${this.id}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.id === this.id; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortById(this.id, base || this); } } @SelectorProperties(Specificity.Type, Rarity.Type, Match.Static) export class TypeSelector extends SimpleSelector { constructor(public cssType: string) { super(); } public toString(): string { return `${this.cssType}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssType === this.cssType; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortByType(this.cssType, base || this); } } @SelectorProperties(Specificity.Class, Rarity.Class, Match.Static) export class ClassSelector extends SimpleSelector { constructor(public cssClass: string) { super(); } public toString(): string { return `.${this.cssClass}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssClasses && node.cssClasses.has(this.cssClass); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortByClass(this.cssClass, base || this); } } declare type AttributeTest = '=' | '^=' | '$=' | '*=' | '=' | '~=' | '|='; @SelectorProperties(Specificity.Attribute, Rarity.Attribute, Match.Dynamic) export class AttributeSelector extends SimpleSelector { constructor(public attribute: string, public test?: AttributeTest, public value?: string) { super(); if (!test) { // HasAttribute this.match = (node) => !isNullOrUndefined(node[attribute]); return; } if (!value) { this.match = (node) => false; } this.match = (node) => { const attr = node[attribute] + ''; if (test === '=') { // Equals return attr === value; } if (test === '^=') { // PrefixMatch return attr.startsWith(value); } if (test === '$=') { // SuffixMatch return attr.endsWith(value); } if (test === '*=') { // SubstringMatch return attr.indexOf(value) !== -1; } if (test === '~=') { // Includes const words = attr.split(' '); return words && words.indexOf(value) !== -1; } if (test === '|=') { // DashMatch return attr === value || attr.startsWith(value + '-'); } }; } public toString(): string { return `[${this.attribute}${wrap(this.test)}${(this.test && this.value) || ''}]${wrap(this.combinator)}`; } public match(node: Node): boolean { return false; } public mayMatch(node: Node): boolean { return true; } public trackChanges(node: Node, map: ChangeAccumulator): void { map.addAttribute(node, this.attribute); } } @SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Dynamic) export class PseudoClassSelector extends SimpleSelector { constructor(public cssPseudoClass: string) { super(); } public toString(): string { return `:${this.cssPseudoClass}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssPseudoClasses && node.cssPseudoClasses.has(this.cssPseudoClass); } public mayMatch(node: Node): boolean { return true; } public trackChanges(node: Node, map: ChangeAccumulator): void { map.addPseudoClass(node, this.cssPseudoClass); } } export class SimpleSelectorSequence extends SimpleSelector { private head: SimpleSelector; constructor(public selectors: SimpleSelector[]) { super(); this.specificity = selectors.reduce((sum, sel) => sel.specificity + sum, 0); this.head = this.selectors.reduce((prev, curr) => (!prev || curr.rarity > prev.rarity ? curr : prev), null); this.dynamic = selectors.some((sel) => sel.dynamic); } public toString(): string { return `${this.selectors.join('')}${wrap(this.combinator)}`; } public match(node: Node): boolean { return this.selectors.every((sel) => sel.match(node)); } public mayMatch(node: Node): boolean { return this.selectors.every((sel) => sel.mayMatch(node)); } public trackChanges(node, map): void { this.selectors.forEach((sel) => sel.trackChanges(node, map)); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { this.head.lookupSort(sorter, base || this); } } export class Selector extends SelectorCore { // Grouped by ancestor combinators, then by direct child combinators. private groups: Selector.ChildGroup[]; private last: SelectorCore; constructor(public selectors: SimpleSelector[]) { super(); const supportedCombinator = [undefined, ' ', '>', '+']; let siblingGroup: SimpleSelector[]; let lastGroup: SimpleSelector[][]; const groups: SimpleSelector[][][] = []; this.specificity = 0; this.dynamic = false; for (let i = selectors.length - 1; i > -1; i--) { const sel = selectors[i]; if (supportedCombinator.indexOf(sel.combinator) === -1) { throw new Error(`Unsupported combinator "${sel.combinator}".`); } if (sel.combinator === undefined || sel.combinator === ' ') { groups.push((lastGroup = [(siblingGroup = [])])); } if (sel.combinator === '>') { lastGroup.push((siblingGroup = [])); } this.specificity += sel.specificity; if (sel.dynamic) { this.dynamic = true; } siblingGroup.push(sel); } this.groups = groups.map((g) => new Selector.ChildGroup(g.map((sg) => new Selector.SiblingGroup(sg)))); this.last = selectors[selectors.length - 1]; } public toString(): string { return this.selectors.join(''); } public match(node: Node): boolean { return this.groups.every((group, i) => { if (i === 0) { node = group.match(node); return !!node; } else { let ancestor = node; while ((ancestor = ancestor.parent ?? ancestor._modalParent)) { if ((node = group.match(ancestor))) { return true; } } return false; } }); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { this.last.lookupSort(sorter, this); } public accumulateChanges(node: Node, map?: ChangeAccumulator): boolean { if (!this.dynamic) { return this.match(node); } const bounds: Selector.Bound[] = []; const mayMatch = this.groups.every((group, i) => { if (i === 0) { const nextNode = group.mayMatch(node); bounds.push({ left: node, right: node }); node = nextNode; return !!node; } else { let ancestor = node; while ((ancestor = ancestor.parent)) { const nextNode = group.mayMatch(ancestor); if (nextNode) { bounds.push({ left: ancestor, right: null }); node = nextNode; return true; } } return false; } }); // Calculating the right bounds for each selectors won't save much if (!mayMatch) { return false; } if (!map) { return mayMatch; } for (let i = 0; i < this.groups.length; i++) { const group = this.groups[i]; if (!group.dynamic) { continue; } const bound = bounds[i]; let node = bound.left; do { if (group.mayMatch(node)) { group.trackChanges(node, map); } } while (node !== bound.right && (node = node.parent)); } return mayMatch; } } export namespace Selector { // Non-spec. Selector sequences are grouped by ancestor then by child combinators for easier backtracking. export class ChildGroup { public dynamic: boolean; constructor(private selectors: SiblingGroup[]) { this.dynamic = selectors.some((sel) => sel.dynamic); } public match(node: Node): Node { return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel.match(node)) ? node : null; } public mayMatch(node: Node): Node { return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel.mayMatch(node)) ? node : null; } public trackChanges(node: Node, map: ChangeAccumulator) { this.selectors.forEach((sel, i) => (node = i === 0 ? node : node.parent) && sel.trackChanges(node, map)); } } export class SiblingGroup { public dynamic: boolean; constructor(private selectors: SimpleSelector[]) { this.dynamic = selectors.some((sel) => sel.dynamic); } public match(node: Node): Node { return this.selectors.every((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.match(node)) ? node : null; } public mayMatch(node: Node): Node { return this.selectors.every((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.mayMatch(node)) ? node : null; } public trackChanges(node: Node, map: ChangeAccumulator) { this.selectors.forEach((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.trackChanges(node, map)); } } export interface Bound { left: Node; right: Node; } } export class RuleSet { tag: string | number; scopedTag: string; constructor(public selectors: SelectorCore[], public declarations: Declaration[]) { this.selectors.forEach((sel) => (sel.ruleset = this)); } public toString(): string { return `${this.selectors.join(', ')} {${this.declarations.map((d, i) => `${i === 0 ? ' ' : ''}${d.property}: ${d.value}`).join('; ')} }`; } public lookupSort(sorter: LookupSorter): void { this.selectors.forEach((sel) => sel.lookupSort(sorter)); } } export function fromAstNodes(astRules: ReworkCSS.Node[]): RuleSet[] { return (astRules.filter(isRule)).map((rule) => { const declarations = rule.declarations.filter(isDeclaration).map(createDeclaration); const selectors = rule.selectors.map(createSelector); return new RuleSet(selectors, declarations); }); } function createDeclaration(decl: ReworkCSS.Declaration): any { return { property: isCssVariable(decl.property) ? decl.property : decl.property.toLowerCase(), value: decl.value }; } function createSimpleSelectorFromAst(ast: ISimpleSelector): SimpleSelector { if (ast.type === '.') { return new ClassSelector(ast.identifier); } if (ast.type === '') { return new TypeSelector(ast.identifier.replace('-', '').toLowerCase()); } if (ast.type === '#') { return new IdSelector(ast.identifier); } if (ast.type === '[]') { return new AttributeSelector(ast.property, ast.test, ast.test && ast.value); } if (ast.type === ':') { return new PseudoClassSelector(ast.identifier); } if (ast.type === '*') { return new UniversalSelector(); } } function createSimpleSelectorSequenceFromAst(ast: ISimpleSelectorSequence): SimpleSelectorSequence | SimpleSelector { if (ast.length === 0) { return new InvalidSelector(new Error('Empty simple selector sequence.')); } else if (ast.length === 1) { return createSimpleSelectorFromAst(ast[0]); } else { return new SimpleSelectorSequence(ast.map(createSimpleSelectorFromAst)); } } function createSelectorFromAst(ast: ISelector): SimpleSelector | SimpleSelectorSequence | Selector { if (ast.length === 0) { return new InvalidSelector(new Error('Empty selector.')); } else if (ast.length === 1) { return createSimpleSelectorSequenceFromAst(ast[0][0]); } else { const simpleSelectorSequences = []; let simpleSelectorSequence: SimpleSelectorSequence | SimpleSelector; let combinator: ICombinator; for (let i = 0; i < ast.length; i++) { simpleSelectorSequence = createSimpleSelectorSequenceFromAst(ast[i][0]); combinator = ast[i][1]; if (combinator) { simpleSelectorSequence.combinator = combinator; } simpleSelectorSequences.push(simpleSelectorSequence); } return new Selector(simpleSelectorSequences); } } export function createSelector(sel: string): SimpleSelector | SimpleSelectorSequence | Selector { try { const parsedSelector = parseSelector(sel); if (!parsedSelector) { return new InvalidSelector(new Error('Empty selector')); } return createSelectorFromAst(parsedSelector.value); } catch (e) { return new InvalidSelector(e); } } function isRule(node: ReworkCSS.Node): node is ReworkCSS.Rule { return node.type === 'rule'; } function isDeclaration(node: ReworkCSS.Node): node is ReworkCSS.Declaration { return node.type === 'declaration'; } interface SelectorMap { [key: string]: SelectorCore[]; } export class SelectorsMap implements LookupSorter { private id: SelectorMap = {}; private class: SelectorMap = {}; private type: SelectorMap = {}; private universal: SelectorCore[] = []; private position = 0; constructor(rulesets: RuleSet[]) { rulesets.forEach((rule) => rule.lookupSort(this)); } query(node: T): SelectorsMatch { const selectorsMatch = new SelectorsMatch(); const { cssClasses, id, cssType } = node; const selectorClasses = [this.universal, this.id[id], this.type[cssType]]; if (cssClasses && cssClasses.size) { cssClasses.forEach((c) => selectorClasses.push(this.class[c])); } const selectors = selectorClasses.reduce((cur, next) => cur.concat(next || []), []); selectorsMatch.selectors = selectors.filter((sel) => sel.accumulateChanges(node, selectorsMatch)).sort((a, b) => a.specificity - b.specificity || a.pos - b.pos); return selectorsMatch; } sortById(id: string, sel: SelectorCore): void { this.addToMap(this.id, id, sel); } sortByClass(cssClass: string, sel: SelectorCore): void { this.addToMap(this.class, cssClass, sel); } sortByType(cssType: string, sel: SelectorCore): void { this.addToMap(this.type, cssType, sel); } sortAsUniversal(sel: SelectorCore): void { this.universal.push(this.makeDocSelector(sel)); } private addToMap(map: SelectorMap, head: string, sel: SelectorCore): void { if (!map[head]) { map[head] = []; } map[head].push(this.makeDocSelector(sel)); } private makeDocSelector(sel: SelectorCore): SelectorCore { sel.pos = this.position++; return sel; } } interface ChangeAccumulator { addAttribute(node: Node, attribute: string): void; addPseudoClass(node: Node, pseudoClass: string): void; } export class SelectorsMatch implements ChangeAccumulator { public changeMap: ChangeMap = new Map(); public selectors: SelectorCore[]; public addAttribute(node: T, attribute: string): void { const deps: Changes = this.properties(node); if (!deps.attributes) { deps.attributes = new Set(); } deps.attributes.add(attribute); } public addPseudoClass(node: T, pseudoClass: string): void { const deps: Changes = this.properties(node); if (!deps.pseudoClasses) { deps.pseudoClasses = new Set(); } deps.pseudoClasses.add(pseudoClass); } public properties(node: T): Changes { let set = this.changeMap.get(node); if (!set) { this.changeMap.set(node, (set = {})); } return set; } } export const CSSHelper = { createSelector, SelectorCore, SimpleSelector, InvalidSelector, UniversalSelector, TypeSelector, ClassSelector, AttributeSelector, PseudoClassSelector, SimpleSelectorSequence, Selector, RuleSet, SelectorsMap, fromAstNodes, SelectorsMatch, };