Files
NativeScript/tns-core-modules/ui/styling/style-scope.ts
Panayot Cankov f7a3a36b9c Housekeeping node tests, renamed to unit-tests (#4936)
Add parsers for the background css shorthand property, make ViewBase unit testable in node environment

Add background parser and linear-gradient parser

Use sticky regexes

Simplify some types, introduce generic Parsed<T> instead of & TokenRange

Apply each parser to return a { start, end, value } object

Move the css selector parser to the css/parser and unify types

Add the first steps toward building homegrown css parser

Add somewhat standards compliant tokenizer, add baseline, rework and shady css parsers

Enable all tests again, skip flaky perf test

Improve css parser tokenizer by converting some char token types to simple string

Implement 'parse a stylesheet'

Add gonzales css-parser

Add parseLib and css-tree perf

Add a thin parser layer that will convert CSS3 tokens to values, for now output is compatible with rework

Make root tsc green

Return the requires of tns-core-modules to use relative paths for webpack to work

Implement support for '@import 'url-string';

Fix function parser, function-token is no-longer neglected

Make the style-scope be able to load from "css" and "css-ast" modules

Add a loadAppCss event so theme can be added to snapshot separately from loaded
2017-10-20 10:42:07 +03:00

666 lines
23 KiB
TypeScript

import { Keyframes } from "../animation/keyframe-animation";
import { ViewBase } from "../core/view-base";
import { View } from "../core/view";
import { unsetValue } from "../core/properties";
import {
SyntaxTree,
Keyframes as KeyframesDefinition,
parse as parseCss,
Node as CssNode,
} from "../../css";
import {
CSS3Parser,
CSSNativeScript
} from "../../css/parser";
import {
RuleSet,
SelectorsMap,
SelectorCore,
SelectorsMatch,
ChangeMap,
fromAstNodes,
Node,
} from "./css-selector";
import {
write as traceWrite,
categories as traceCategories,
messageType as traceMessageType,
} from "../../trace";
import { File, knownFolders, path } from "../../file-system";
import * as application from "../../application";
import { profile } from "../../profiling";
import * as kam from "../animation/keyframe-animation";
let keyframeAnimationModule: typeof kam;
function ensureKeyframeAnimationModule() {
if (!keyframeAnimationModule) {
keyframeAnimationModule = require("ui/animation/keyframe-animation");
}
}
import * as capm from "./css-animation-parser";
let cssAnimationParserModule: typeof capm;
function ensureCssAnimationParserModule() {
if (!cssAnimationParserModule) {
cssAnimationParserModule = require("./css-animation-parser");
}
}
let parser: "rework" | "nativescript" = "rework";
try {
const appConfig = require("~/package.json");
if (appConfig && appConfig.cssParser === "nativescript") {
parser = "nativescript";
}
} catch(e) {
//
}
export function mergeCssSelectors(): void {
applicationCssSelectors = applicationSelectors.slice();
applicationCssSelectors.push.apply(applicationCssSelectors, applicationAdditionalSelectors);
applicationCssSelectorVersion++;
}
let applicationCssSelectors: RuleSet[] = [];
let applicationCssSelectorVersion: number = 0;
let applicationSelectors: RuleSet[] = [];
const applicationAdditionalSelectors: RuleSet[] = [];
const applicationKeyframes: any = {};
const animationsSymbol: symbol = Symbol("animations");
const pattern: RegExp = /('|")(.*?)\1/;
class CSSSource {
private _selectors: RuleSet[] = [];
private static cssFilesCache: { [path: string]: CSSSource } = {};
private constructor(private _ast: SyntaxTree, private _url: string, private _file: string, private _keyframes: KeyframesMap, private _source: string) {
this.parse();
}
public static fromURI(uri: string, keyframes: KeyframesMap): CSSSource {
try {
const cssOrAst = global.loadModule(uri);
if (cssOrAst) {
if (typeof cssOrAst === "string") {
return CSSSource.fromSource(cssOrAst, keyframes, uri);
} else if (typeof cssOrAst === "object" && cssOrAst.type === "stylesheet" && cssOrAst.stylesheet && cssOrAst.stylesheet.rules) {
return CSSSource.fromAST(cssOrAst, keyframes, uri);
} else {
// Probably a webpack css-loader exported object.
return CSSSource.fromSource(cssOrAst.toString(), keyframes, uri);
}
}
} catch(e) {
//
}
return CSSSource.fromFile(uri, keyframes);
}
public static fromFile(url: string, keyframes: KeyframesMap): CSSSource {
const file = CSSSource.resolveCSSPathFromURL(url);
return new CSSSource(undefined, url, file, keyframes, undefined);
}
@profile
public static resolveCSSPathFromURL(url: string): string {
const app = knownFolders.currentApp().path;
const file = resolveFileNameFromUrl(url, app, File.exists);
return file;
}
public static fromSource(source: string, keyframes: KeyframesMap, url?: string): CSSSource {
return new CSSSource(undefined, url, undefined, keyframes, source);
}
public static fromAST(ast: SyntaxTree, keyframes: KeyframesMap, url?: string): CSSSource {
return new CSSSource(ast, url, undefined, keyframes, undefined);
}
get selectors(): RuleSet[] { return this._selectors; }
get source(): string { return this._source; }
@profile
private load(): void {
const file = File.fromPath(this._file);
this._source = file.readTextSync();
}
@profile
private parse(): void {
try {
if (!this._ast) {
if (!this._source && this._file) {
this.load();
}
if (this._source) {
this.parseCSSAst();
}
}
if (this._ast) {
this.createSelectors();
} else {
this._selectors = [];
}
} catch (e) {
traceWrite("Css styling failed: " + e, traceCategories.Error, traceMessageType.error);
this._selectors = [];
}
}
@profile
private parseCSSAst() {
if (this._source) {
switch(parser) {
case "nativescript":
const cssparser = new CSS3Parser(this._source);
const stylesheet = cssparser.parseAStylesheet();
const cssNS = new CSSNativeScript();
this._ast = cssNS.parseStylesheet(stylesheet);
return;
case "rework":
this._ast = parseCss(this._source, { source: this._file });
return;
}
}
}
@profile
private createSelectors() {
if (this._ast) {
this._selectors = [
...this.createSelectorsFromImports(),
...this.createSelectorsFromSyntaxTree()
];
}
}
private createSelectorsFromImports(): RuleSet[] {
let selectors: RuleSet[] = [];
const imports = this._ast["stylesheet"]["rules"].filter(r => r.type === "import");
for (let i = 0; i < imports.length; i++) {
const importItem = imports[i]["import"];
const match = importItem && (<string>importItem).match(pattern);
const url = match && match[2];
if (url !== null && url !== undefined) {
const cssFile = CSSSource.fromURI(url, this._keyframes);
selectors = selectors.concat(cssFile.selectors);
}
}
return selectors;
}
private createSelectorsFromSyntaxTree(): RuleSet[] {
const nodes = this._ast.stylesheet.rules;
(<KeyframesDefinition[]>nodes.filter(isKeyframe)).forEach(node => this._keyframes[node.name] = node);
const rulesets = fromAstNodes(nodes);
if (rulesets && rulesets.length) {
ensureCssAnimationParserModule();
rulesets.forEach(rule => {
rule[animationsSymbol] = cssAnimationParserModule.CssAnimationParser
.keyframeAnimationsFromCSSDeclarations(rule.declarations);
});
}
return rulesets;
}
toString(): string {
return this._file || this._url || "(in-memory)";
}
}
const onCssChanged = profile('"style-scope".onCssChanged', (args: application.CssChangedEventData) => {
if (args.cssText) {
const parsed = CSSSource.fromSource(args.cssText, applicationKeyframes, args.cssFile).selectors;
if (parsed) {
applicationAdditionalSelectors.push.apply(applicationAdditionalSelectors, parsed);
mergeCssSelectors();
}
} else if (args.cssFile) {
loadCss(args.cssFile);
}
});
function onLiveSync(args: application.CssChangedEventData): void {
loadCss(application.getCssFileName());
}
const loadCss = profile(`"style-scope".loadCss`, (cssFile: string) => {
if (!cssFile) {
return undefined;
}
const result = CSSSource.fromURI(cssFile, applicationKeyframes).selectors;
if (result.length > 0) {
applicationSelectors = result;
mergeCssSelectors();
}
});
application.on("cssChanged", onCssChanged);
application.on("livesync", onLiveSync);
export const loadAppCSS = profile('"style-scope".loadAppCSS', (args: application.LoadAppCSSEventData) => {
loadCss(args.cssFile);
application.off("loadAppCss", loadAppCSS);
});
if (application.hasLaunched()) {
loadAppCSS({ eventName: "loadAppCss", object: <any>application, cssFile: application.getCssFileName() });
} else {
application.on("loadAppCss", loadAppCSS);
}
export class CssState {
static emptyChangeMap: Readonly<ChangeMap<ViewBase>> = Object.freeze(new Map());
static emptyPropertyBag: Readonly<{}> = Object.freeze({});
static emptyAnimationArray: ReadonlyArray<kam.KeyframeAnimation> = Object.freeze([]);
static emptyMatch: Readonly<SelectorsMatch<ViewBase>> = { selectors: [], changeMap: new Map() };
_onDynamicStateChangeHandler: () => void;
_appliedChangeMap: Readonly<ChangeMap<ViewBase>>;
_appliedPropertyValues: Readonly<{}>;
_appliedAnimations: ReadonlyArray<kam.KeyframeAnimation>;
_match: SelectorsMatch<ViewBase>;
_matchInvalid: boolean;
_playsKeyframeAnimations: boolean;
constructor(private view: ViewBase) {
this._onDynamicStateChangeHandler = () => this.updateDynamicState();
}
/**
* Called when a change had occurred that may invalidate the statically matching selectors (class, id, ancestor selectors).
* As a result, at some point in time, the selectors matched have to be requerried from the style scope and applied to the view.
*/
public onChange(): void {
if (this.view.isLoaded) {
this.unsubscribeFromDynamicUpdates();
this.updateMatch();
this.subscribeForDynamicUpdates();
this.updateDynamicState();
} else {
this._matchInvalid = true;
}
}
public onLoaded(): void {
if (this._matchInvalid) {
this.updateMatch();
}
this.subscribeForDynamicUpdates();
this.updateDynamicState();
}
public onUnloaded(): void {
this.unsubscribeFromDynamicUpdates();
}
@profile
private updateMatch() {
this._match = this.view._styleScope ? this.view._styleScope.matchSelectors(this.view) : CssState.emptyMatch;
this._matchInvalid = false;
}
@profile
private updateDynamicState(): void {
const matchingSelectors = this._match.selectors.filter(sel => sel.dynamic ? sel.match(this.view) : true);
this.view._batchUpdate(() => {
this.stopKeyframeAnimations();
this.setPropertyValues(matchingSelectors);
this.playKeyframeAnimations(matchingSelectors);
});
}
private playKeyframeAnimations(matchingSelectors: SelectorCore[]): void {
const animations: kam.KeyframeAnimation[] = [];
matchingSelectors.forEach(selector => {
let ruleAnimations: kam.KeyframeAnimationInfo[] = selector.ruleset[animationsSymbol];
if (ruleAnimations) {
ensureKeyframeAnimationModule();
for (let animationInfo of ruleAnimations) {
let animation = keyframeAnimationModule.KeyframeAnimation.keyframeAnimationFromInfo(animationInfo);
if (animation) {
animations.push(animation);
}
}
}
});
if (this._playsKeyframeAnimations = animations.length > 0) {
animations.map(animation => animation.play(<View>this.view));
Object.freeze(animations);
this._appliedAnimations = animations;
}
}
private stopKeyframeAnimations(): void {
if (!this._playsKeyframeAnimations) {
return;
}
this._appliedAnimations
.filter(animation => animation.isPlaying)
.forEach(animation => animation.cancel());
this._appliedAnimations = CssState.emptyAnimationArray;
this.view.style["keyframe:rotate"] = unsetValue;
this.view.style["keyframe:scaleX"] = unsetValue;
this.view.style["keyframe:scaleY"] = unsetValue;
this.view.style["keyframe:translateX"] = unsetValue;
this.view.style["keyframe:translateY"] = unsetValue;
this.view.style["keyframe:backgroundColor"] = unsetValue;
this.view.style["keyframe:opacity"] = unsetValue;
this._playsKeyframeAnimations = false;
}
/**
* Calculate the difference between the previously applied property values,
* and the new set of property values that have to be applied for the provided selectors.
* Apply the values and ensure each property setter is called at most once to avoid excessive change notifications.
* @param matchingSelectors
*/
private setPropertyValues(matchingSelectors: SelectorCore[]): void {
const newPropertyValues = new this.view.style.PropertyBag();
matchingSelectors.forEach(selector =>
selector.ruleset.declarations.forEach(declaration =>
newPropertyValues[declaration.property] = declaration.value));
Object.freeze(newPropertyValues);
const oldProperties = this._appliedPropertyValues;
for(const key in oldProperties) {
if (!(key in newPropertyValues)) {
if (key in this.view.style) {
this.view.style[`css:${key}`] = unsetValue;
} else {
// TRICKY: How do we unset local value?
}
}
}
for(const property in newPropertyValues) {
if (oldProperties && property in oldProperties && oldProperties[property] === newPropertyValues[property]) {
continue;
}
const value = newPropertyValues[property];
try {
if (property in this.view.style) {
this.view.style[`css:${property}`] = value;
} else {
this.view[property] = value;
}
} catch (e) {
traceWrite(`Failed to apply property [${property}] with value [${value}] to ${this.view}. ${e}`, traceCategories.Error, traceMessageType.error);
}
}
this._appliedPropertyValues = newPropertyValues;
}
private subscribeForDynamicUpdates(): void {
const changeMap = this._match.changeMap;
changeMap.forEach((changes, view) => {
if (changes.attributes) {
changes.attributes.forEach(attribute => {
view.addEventListener(attribute + "Change", this._onDynamicStateChangeHandler);
});
}
if (changes.pseudoClasses) {
changes.pseudoClasses.forEach(pseudoClass => {
let eventName = ":" + pseudoClass;
view.addEventListener(":" + pseudoClass, this._onDynamicStateChangeHandler);
if (view[eventName]) {
view[eventName](+1);
}
});
}
});
this._appliedChangeMap = changeMap;
}
private unsubscribeFromDynamicUpdates(): void {
this._appliedChangeMap.forEach((changes, view) => {
if (changes.attributes) {
changes.attributes.forEach(attribute => {
view.removeEventListener("onPropertyChanged:" + attribute, this._onDynamicStateChangeHandler);
});
}
if (changes.pseudoClasses) {
changes.pseudoClasses.forEach(pseudoClass => {
let eventName = ":" + pseudoClass;
view.removeEventListener(eventName, this._onDynamicStateChangeHandler);
if (view[eventName]) {
view[eventName](-1);
}
});
}
});
this._appliedChangeMap = CssState.emptyChangeMap;
}
toString(): string {
return `${this.view}._cssState`;
}
}
CssState.prototype._appliedChangeMap = CssState.emptyChangeMap;
CssState.prototype._appliedPropertyValues = CssState.emptyPropertyBag;
CssState.prototype._appliedAnimations = CssState.emptyAnimationArray;
CssState.prototype._matchInvalid = true;
export class StyleScope {
private _selectors: SelectorsMap;
// caches all the visual states by the key of the visual state selectors
private _statesByKey = {};
private _viewIdToKey = {};
private _css: string = "";
private _cssFileName: string;
private _mergedCssSelectors: RuleSet[];
private _localCssSelectors: RuleSet[] = [];
private _localCssSelectorVersion: number = 0;
private _localCssSelectorsAppliedVersion: number = 0;
private _applicationCssSelectorsAppliedVersion: number = 0;
private _keyframes = new Map<string, Keyframes>();
get css(): string {
return this._css;
}
set css(value: string) {
this._cssFileName = undefined;
this.setCss(value);
}
public addCss(cssString: string, cssFileName?: string): void {
this.appendCss(cssString, cssFileName)
}
public addCssFile(cssFileName: string): void {
this.appendCss(null, cssFileName);
}
@profile
private setCss(cssString: string, cssFileName?): void {
this._css = cssString;
this._reset();
const cssFile = CSSSource.fromSource(cssString, this._keyframes, cssFileName);
this._localCssSelectors = cssFile.selectors;
this._localCssSelectorVersion++;
this.ensureSelectors();
}
@profile
private appendCss(cssString: string, cssFileName?): void {
if (!cssString && !cssFileName) {
return;
}
this._reset();
let parsedCssSelectors = cssString ? CSSSource.fromSource(cssString, this._keyframes, cssFileName) : CSSSource.fromFile(cssFileName, this._keyframes);
this._css = this._css + parsedCssSelectors.source;
this._localCssSelectors.push.apply(this._localCssSelectors, parsedCssSelectors.selectors);
this._localCssSelectorVersion++;
this.ensureSelectors();
}
public getKeyframeAnimationWithName(animationName: string): kam.KeyframeAnimationInfo {
const cssKeyframes = this._keyframes[animationName];
if (!cssKeyframes) {
return;
}
ensureKeyframeAnimationModule();
const animation = new keyframeAnimationModule.KeyframeAnimationInfo();
ensureCssAnimationParserModule();
animation.keyframes = cssAnimationParserModule
.CssAnimationParser.keyframesArrayFromCSS(cssKeyframes.keyframes);
return animation;
}
public ensureSelectors(): number {
if (this._applicationCssSelectorsAppliedVersion !== applicationCssSelectorVersion ||
this._localCssSelectorVersion !== this._localCssSelectorsAppliedVersion ||
!this._mergedCssSelectors) {
this._createSelectors();
}
return this._getSelectorsVersion();
}
@profile
private _createSelectors() {
let toMerge: RuleSet[][] = [];
toMerge.push(applicationCssSelectors);
this._applicationCssSelectorsAppliedVersion = applicationCssSelectorVersion;
toMerge.push(this._localCssSelectors);
this._localCssSelectorsAppliedVersion = this._localCssSelectorVersion;
for (let keyframe in applicationKeyframes) {
this._keyframes[keyframe] = applicationKeyframes[keyframe];
}
if (toMerge.length > 0) {
this._mergedCssSelectors = toMerge.filter(m => !!m).reduce((merged, next) => merged.concat(next), []);
this._applyKeyframesOnSelectors();
this._selectors = new SelectorsMap(this._mergedCssSelectors);
}
}
@profile
public matchSelectors(view: ViewBase): SelectorsMatch<ViewBase> {
this.ensureSelectors();
return this._selectors.query(view);
}
public query(node: Node): SelectorCore[] {
this.ensureSelectors();
return this._selectors.query(node).selectors;
}
private _reset() {
this._statesByKey = {};
this._viewIdToKey = {};
}
private _getSelectorsVersion() {
// The counters can only go up. So we can return just appVersion + localVersion
// The 100000 * appVersion is just for easier debugging
return 100000 * this._applicationCssSelectorsAppliedVersion + this._localCssSelectorsAppliedVersion;
}
private _applyKeyframesOnSelectors() {
for (let i = this._mergedCssSelectors.length - 1; i >= 0; i--) {
let ruleset = this._mergedCssSelectors[i];
let animations: kam.KeyframeAnimationInfo[] = ruleset[animationsSymbol];
if (animations !== undefined && animations.length) {
ensureCssAnimationParserModule();
for (let animation of animations) {
const cssKeyframe = this._keyframes[animation.name];
if (cssKeyframe !== undefined) {
animation.keyframes = cssAnimationParserModule
.CssAnimationParser.keyframesArrayFromCSS(cssKeyframe.keyframes);
}
}
}
}
}
public getAnimations(ruleset: RuleSet): kam.KeyframeAnimationInfo[] {
return ruleset[animationsSymbol];
}
}
type KeyframesMap = Map<string, Keyframes>;
export function resolveFileNameFromUrl(url: string, appDirectory: string, fileExists: (name: string) => boolean): string {
let fileName: string = typeof url === "string" ? url.trim() : "";
if (fileName.indexOf("~/") === 0) {
fileName = fileName.replace("~/", "");
}
const isAbsolutePath = fileName.indexOf("/") === 0;
const absolutePath = isAbsolutePath ? fileName : path.join(appDirectory, fileName);
if (fileExists(absolutePath)) {
return absolutePath;
}
if (!isAbsolutePath) {
const external = path.join(appDirectory, "tns_modules", fileName);
if (fileExists(external)) {
return external;
}
}
return null;
}
export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) {
let localStyle = `local { ${styleStr} }`;
let inlineRuleSet = CSSSource.fromSource(localStyle, new Map()).selectors;
const style = view.style;
inlineRuleSet[0].declarations.forEach(d => {
// Use the actual property name so that a local value is set.
let name = d.property;
try {
if (name in style) {
style[name] = d.value;
} else {
view[name] = d.value;
}
} catch (e) {
traceWrite(`Failed to apply property [${d.property}] with value [${d.value}] to ${view}. ${e}`, traceCategories.Error, traceMessageType.error);
}
});
});
function isKeyframe(node: CssNode): node is KeyframesDefinition {
return node.type === "keyframes";
}
class InlineSelector implements SelectorCore {
constructor(ruleSet: RuleSet) {
this.ruleset = ruleSet;
}
public specificity = 0x01000000;
public rarity = 0;
public dynamic: boolean = false;
public ruleset: RuleSet;
public match(node: Node): boolean { return true; }
}