mirror of
https://github.com/NativeScript/NativeScript.git
synced 2025-08-20 07:26:11 +08:00

this also allows to remove them from bundle. However this is not the best way. We should use global vars for cssParser so that weback automatically removes the code
939 lines
30 KiB
TypeScript
939 lines
30 KiB
TypeScript
import { Keyframes } from '../animation/keyframe-animation';
|
|
import { ViewBase } from '../core/view-base';
|
|
import { View } from '../core/view';
|
|
import { unsetValue, _evaluateCssVariableExpression, _evaluateCssCalcExpression, isCssVariable, isCssVariableExpression, isCssCalcExpression } from '../core/properties';
|
|
import { SyntaxTree, Keyframes as KeyframesDefinition, Node as CssNode } from '../../css';
|
|
|
|
import { RuleSet, SelectorsMap, SelectorCore, SelectorsMatch, ChangeMap, fromAstNodes, Node } from './css-selector';
|
|
import { Trace } 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('../animation/keyframe-animation');
|
|
}
|
|
}
|
|
|
|
import * as capm from './css-animation-parser';
|
|
import { sanitizeModuleName } from '../builder/module-name-sanitizer';
|
|
import { resolveModuleName } from '../../module-name-resolver';
|
|
|
|
let cssAnimationParserModule: typeof capm;
|
|
function ensureCssAnimationParserModule() {
|
|
if (!cssAnimationParserModule) {
|
|
cssAnimationParserModule = require('./css-animation-parser');
|
|
}
|
|
}
|
|
|
|
let parser: 'rework' | 'nativescript' | 'css-tree' = 'css-tree';
|
|
try {
|
|
const appConfig = require('~/package.json');
|
|
if (appConfig) {
|
|
if (appConfig.cssParser === 'rework') {
|
|
parser = 'rework';
|
|
} else if (appConfig.cssParser === 'nativescript') {
|
|
parser = 'nativescript';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
//
|
|
}
|
|
|
|
/**
|
|
* Evaluate css-variable and css-calc expressions
|
|
*/
|
|
function evaluateCssExpressions(view: ViewBase, property: string, value: string) {
|
|
const newValue = _evaluateCssVariableExpression(view, property, value);
|
|
if (newValue === 'unset') {
|
|
return unsetValue;
|
|
}
|
|
|
|
value = newValue;
|
|
|
|
try {
|
|
value = _evaluateCssCalcExpression(value);
|
|
} catch (e) {
|
|
Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error);
|
|
|
|
return unsetValue;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
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 constructor(private _ast: SyntaxTree, private _url: string, private _file: string, private _keyframes: KeyframesMap, private _source: string) {
|
|
this.parse();
|
|
}
|
|
|
|
public static fromDetect(cssOrAst: any, keyframes: KeyframesMap, fileName?: string): CSSSource {
|
|
if (typeof cssOrAst === 'string') {
|
|
// raw-loader
|
|
return CSSSource.fromSource(cssOrAst, keyframes, fileName);
|
|
} else if (typeof cssOrAst === 'object' && cssOrAst.type === 'stylesheet' && cssOrAst.stylesheet && cssOrAst.stylesheet.rules) {
|
|
// css-loader
|
|
return CSSSource.fromAST(cssOrAst, keyframes, fileName);
|
|
} else {
|
|
// css2json-loader
|
|
return CSSSource.fromSource(cssOrAst.toString(), keyframes, fileName);
|
|
}
|
|
}
|
|
|
|
public static fromURI(uri: string, keyframes: KeyframesMap): CSSSource {
|
|
// webpack modules require all file paths to be relative to /app folder
|
|
const appRelativeUri = CSSSource.pathRelativeToApp(uri);
|
|
const sanitizedModuleName = sanitizeModuleName(appRelativeUri);
|
|
const resolvedModuleName = resolveModuleName(sanitizedModuleName, 'css');
|
|
|
|
try {
|
|
const cssOrAst = global.loadModule(resolvedModuleName, true);
|
|
if (cssOrAst) {
|
|
return CSSSource.fromDetect(cssOrAst, keyframes, resolvedModuleName);
|
|
}
|
|
} catch (e) {
|
|
Trace.write(`Could not load CSS from ${uri}: ${e}`, Trace.categories.Error, Trace.messageType.error);
|
|
}
|
|
|
|
return CSSSource.fromFile(appRelativeUri, keyframes);
|
|
}
|
|
|
|
private static pathRelativeToApp(uri: string): string {
|
|
if (!uri.startsWith('/')) {
|
|
return uri;
|
|
}
|
|
|
|
const appPath = knownFolders.currentApp().path;
|
|
if (!uri.startsWith(appPath)) {
|
|
Trace.write(`${uri} does not start with ${appPath}`, Trace.categories.Error, Trace.messageType.error);
|
|
|
|
return uri;
|
|
}
|
|
|
|
const relativeUri = `.${uri.substr(appPath.length)}`;
|
|
|
|
return relativeUri;
|
|
}
|
|
|
|
public static fromFile(url: string, keyframes: KeyframesMap): CSSSource {
|
|
// .scss, .sass, etc. css files in vanilla app are usually compiled to .css so we will try to load a compiled file first.
|
|
let cssFileUrl = url.replace(/\..\w+$/, '.css');
|
|
if (cssFileUrl !== url) {
|
|
const cssFile = CSSSource.resolveCSSPathFromURL(cssFileUrl);
|
|
if (cssFile) {
|
|
return new CSSSource(undefined, url, cssFile, keyframes, undefined);
|
|
}
|
|
}
|
|
|
|
const file = CSSSource.resolveCSSPathFromURL(url);
|
|
|
|
return new CSSSource(undefined, url, file, keyframes, undefined);
|
|
}
|
|
|
|
public static fromFileImport(url: string, keyframes: KeyframesMap, importSource: string): CSSSource {
|
|
const file = CSSSource.resolveCSSPathFromURL(url, importSource);
|
|
|
|
return new CSSSource(undefined, url, file, keyframes, undefined);
|
|
}
|
|
|
|
@profile
|
|
public static resolveCSSPathFromURL(url: string, importSource?: string): string {
|
|
const app = knownFolders.currentApp().path;
|
|
const file = resolveFileNameFromUrl(url, app, File.exists, importSource);
|
|
|
|
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();
|
|
}
|
|
// [object Object] check guards against empty app.css file
|
|
if (this._source && this.source !== '[object Object]') {
|
|
this.parseCSSAst();
|
|
}
|
|
}
|
|
if (this._ast) {
|
|
this.createSelectors();
|
|
} else {
|
|
this._selectors = [];
|
|
}
|
|
} catch (e) {
|
|
Trace.write('Css styling failed: ' + e, Trace.categories.Error, Trace.messageType.error);
|
|
this._selectors = [];
|
|
}
|
|
}
|
|
|
|
@profile
|
|
private parseCSSAst() {
|
|
if (this._source) {
|
|
switch (parser) {
|
|
case 'css-tree':
|
|
const cssTreeParse = require('../../css/css-tree-parser').pacssTreeParserse;
|
|
this._ast = cssTreeParse(this._source, this._file);
|
|
return;
|
|
case 'nativescript':
|
|
const CSS3Parser = require('../../css/parser').CSS3Parser;
|
|
const CSSNativeScript = require('../../css/parser').CSSNativeScript;
|
|
const cssparser = new CSS3Parser(this._source);
|
|
const stylesheet = cssparser.parseAStylesheet();
|
|
const cssNS = new CSSNativeScript();
|
|
this._ast = cssNS.parseStylesheet(stylesheet);
|
|
|
|
return;
|
|
case 'rework':
|
|
const parseCss = require('../../css').parse;
|
|
this._ast = parseCss(this._source, { source: this._file });
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
@profile
|
|
private createSelectors() {
|
|
if (this._ast) {
|
|
this._selectors = [...this.createSelectorsFromImports(), ...this.createSelectorsFromSyntaxTree()];
|
|
}
|
|
}
|
|
|
|
private createSelectorsFromImports(): RuleSet[] {
|
|
const imports = this._ast['stylesheet']['rules'].filter((r) => r.type === 'import');
|
|
|
|
const urlFromImportObject = (importObject) => {
|
|
const importItem = importObject['import'] as string;
|
|
const urlMatch = importItem && importItem.match(pattern);
|
|
|
|
return urlMatch && urlMatch[2];
|
|
};
|
|
|
|
const sourceFromImportObject = (importObject) => importObject['position'] && importObject['position']['source'];
|
|
|
|
const toUrlSourcePair = (importObject) => ({
|
|
url: urlFromImportObject(importObject),
|
|
source: sourceFromImportObject(importObject),
|
|
});
|
|
|
|
const getCssFile = ({ url, source }) => (source ? CSSSource.fromFileImport(url, this._keyframes, source) : CSSSource.fromURI(url, this._keyframes));
|
|
|
|
const cssFiles = imports
|
|
.map(toUrlSourcePair)
|
|
.filter(({ url }) => !!url)
|
|
.map(getCssFile);
|
|
|
|
const selectors = cssFiles.map((file) => (file && file.selectors) || []);
|
|
|
|
return selectors.reduce((acc, val) => acc.concat(val), []);
|
|
}
|
|
|
|
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)';
|
|
}
|
|
}
|
|
|
|
export function removeTaggedAdditionalCSS(tag: String | Number): Boolean {
|
|
let changed = false;
|
|
for (let i = 0; i < applicationAdditionalSelectors.length; i++) {
|
|
if (applicationAdditionalSelectors[i].tag === tag) {
|
|
applicationAdditionalSelectors.splice(i, 1);
|
|
i--;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
mergeCssSelectors();
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
export function addTaggedAdditionalCSS(cssText: string, tag?: string | Number): Boolean {
|
|
const parsed: RuleSet[] = CSSSource.fromDetect(cssText, applicationKeyframes, undefined).selectors;
|
|
let changed = false;
|
|
if (parsed && parsed.length) {
|
|
changed = true;
|
|
if (tag != null) {
|
|
for (let i = 0; i < parsed.length; i++) {
|
|
parsed[i].tag = tag;
|
|
}
|
|
}
|
|
applicationAdditionalSelectors.push.apply(applicationAdditionalSelectors, parsed);
|
|
mergeCssSelectors();
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
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, null, null);
|
|
}
|
|
});
|
|
|
|
function onLiveSync(args: application.CssChangedEventData): void {
|
|
loadCss(application.getCssFileName(), null, null);
|
|
}
|
|
|
|
const loadCss = profile(`"style-scope".loadCss`, (cssModule: string) => {
|
|
if (!cssModule) {
|
|
return undefined;
|
|
}
|
|
|
|
// safely remove "./" as global CSS should be resolved relative to app folder
|
|
if (cssModule.startsWith('./')) {
|
|
cssModule = cssModule.substr(2);
|
|
}
|
|
|
|
const result = CSSSource.fromURI(cssModule, applicationKeyframes).selectors;
|
|
if (result.length > 0) {
|
|
applicationSelectors = result;
|
|
mergeCssSelectors();
|
|
}
|
|
});
|
|
|
|
global.NativeScriptGlobals.events.on('cssChanged', <any>onCssChanged);
|
|
global.NativeScriptGlobals.events.on('livesync', onLiveSync);
|
|
|
|
// Call to this method is injected in the application in:
|
|
// - no-snapshot - code injected in app.ts by [bundle-config-loader](https://github.com/NativeScript/nativescript-dev-webpack/blob/9b1e34d8ef838006c9b575285c42d2304f5f02b5/bundle-config-loader.ts#L85-L92)
|
|
// - with-snapshot - code injected in snapshot bundle by [NativeScriptSnapshotPlugin](https://github.com/NativeScript/nativescript-dev-webpack/blob/48b26f412fd70c19dc0b9c7763e08e9505a0ae11/plugins/NativeScriptSnapshotPlugin/index.js#L48-L56)
|
|
// Having the app.css loaded in snapshot provides significant boost in startup (when using the ns-theme ~150 ms). However, because app.css is resolved at build-time,
|
|
// when the snapshot is created - there is no way to use file qualifiers or change the name of on app.css
|
|
export const loadAppCSS = profile('"style-scope".loadAppCSS', (args: application.LoadAppCSSEventData) => {
|
|
loadCss(args.cssFile, null, null);
|
|
global.NativeScriptGlobals.events.off('loadAppCss', loadAppCSS);
|
|
});
|
|
|
|
if (application.hasLaunched()) {
|
|
loadAppCSS(
|
|
{
|
|
eventName: 'loadAppCss',
|
|
object: <any>application,
|
|
cssFile: application.getCssFileName(),
|
|
},
|
|
null,
|
|
null
|
|
);
|
|
} else {
|
|
global.NativeScriptGlobals.events.on('loadAppCss', <any>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(),
|
|
addAttribute: () => {},
|
|
addPseudoClass: () => {},
|
|
properties: null,
|
|
};
|
|
|
|
_onDynamicStateChangeHandler: () => void;
|
|
_appliedChangeMap: Readonly<ChangeMap<ViewBase>>;
|
|
_appliedPropertyValues: Readonly<{}>;
|
|
_appliedAnimations: ReadonlyArray<kam.KeyframeAnimation>;
|
|
_appliedSelectorsVersion: number;
|
|
|
|
_match: SelectorsMatch<ViewBase>;
|
|
_matchInvalid: boolean;
|
|
_playsKeyframeAnimations: boolean;
|
|
|
|
constructor(private viewRef: WeakRef<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 {
|
|
const view = this.viewRef.get();
|
|
if (view && view.isLoaded) {
|
|
this.unsubscribeFromDynamicUpdates();
|
|
this.updateMatch();
|
|
this.subscribeForDynamicUpdates();
|
|
this.updateDynamicState();
|
|
} else {
|
|
this._matchInvalid = true;
|
|
}
|
|
}
|
|
|
|
public isSelectorsLatestVersionApplied(): boolean {
|
|
const view = this.viewRef.get();
|
|
if (!view) {
|
|
Trace.write(`isSelectorsLatestVersionApplied returns default value "false" because "this.viewRef" cleared.`, Trace.categories.Style, Trace.messageType.warn);
|
|
|
|
return false;
|
|
}
|
|
|
|
return this.viewRef.get()._styleScope.getSelectorsVersion() === this._appliedSelectorsVersion;
|
|
}
|
|
|
|
public onLoaded(): void {
|
|
if (this._matchInvalid) {
|
|
this.updateMatch();
|
|
}
|
|
this.subscribeForDynamicUpdates();
|
|
this.updateDynamicState();
|
|
}
|
|
|
|
public onUnloaded(): void {
|
|
this.unsubscribeFromDynamicUpdates();
|
|
}
|
|
|
|
@profile
|
|
private updateMatch() {
|
|
const view = this.viewRef.get();
|
|
if (view && view._styleScope) {
|
|
this._match = view._styleScope.matchSelectors(view);
|
|
this._appliedSelectorsVersion = view._styleScope.getSelectorsVersion();
|
|
} else {
|
|
this._match = CssState.emptyMatch;
|
|
}
|
|
|
|
this._matchInvalid = false;
|
|
}
|
|
|
|
@profile
|
|
private updateDynamicState(): void {
|
|
const view = this.viewRef.get();
|
|
if (!view) {
|
|
Trace.write(`updateDynamicState not executed to view because ".viewRef" is cleared`, Trace.categories.Style, Trace.messageType.warn);
|
|
|
|
return;
|
|
}
|
|
|
|
const matchingSelectors = this._match.selectors.filter((sel) => (sel.dynamic ? sel.match(view) : true));
|
|
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)) {
|
|
const view = this.viewRef.get();
|
|
if (!view) {
|
|
Trace.write(`KeyframeAnimations cannot play because ".viewRef" is cleared`, Trace.categories.Animation, Trace.messageType.warn);
|
|
|
|
return;
|
|
}
|
|
|
|
animations.map((animation) => animation.play(<View>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;
|
|
|
|
const view = this.viewRef.get();
|
|
if (view) {
|
|
view.style['keyframe:rotate'] = unsetValue;
|
|
view.style['keyframe:rotateX'] = unsetValue;
|
|
view.style['keyframe:rotateY'] = unsetValue;
|
|
view.style['keyframe:scaleX'] = unsetValue;
|
|
view.style['keyframe:scaleY'] = unsetValue;
|
|
view.style['keyframe:translateX'] = unsetValue;
|
|
view.style['keyframe:translateY'] = unsetValue;
|
|
view.style['keyframe:backgroundColor'] = unsetValue;
|
|
view.style['keyframe:opacity'] = unsetValue;
|
|
} else {
|
|
Trace.write(`KeyframeAnimations cannot be stopped because ".viewRef" is cleared`, Trace.categories.Animation, Trace.messageType.warn);
|
|
}
|
|
|
|
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 view = this.viewRef.get();
|
|
if (!view) {
|
|
Trace.write(`${matchingSelectors} not set to view's property because ".viewRef" is cleared`, Trace.categories.Style, Trace.messageType.warn);
|
|
|
|
return;
|
|
}
|
|
|
|
const newPropertyValues = new view.style.PropertyBag();
|
|
matchingSelectors.forEach((selector) => selector.ruleset.declarations.forEach((declaration) => (newPropertyValues[declaration.property] = declaration.value)));
|
|
|
|
const oldProperties = this._appliedPropertyValues;
|
|
|
|
let isCssExpressionInUse = false;
|
|
|
|
// Update values for the scope's css-variables
|
|
view.style.resetScopedCssVariables();
|
|
|
|
for (const property in newPropertyValues) {
|
|
const value = newPropertyValues[property];
|
|
if (isCssVariable(property)) {
|
|
view.style.setScopedCssVariable(property, value);
|
|
|
|
delete newPropertyValues[property];
|
|
continue;
|
|
}
|
|
|
|
isCssExpressionInUse = isCssExpressionInUse || isCssVariableExpression(value) || isCssCalcExpression(value);
|
|
}
|
|
|
|
if (isCssExpressionInUse) {
|
|
// Evalute css-expressions to get the latest values.
|
|
for (const property in newPropertyValues) {
|
|
const value = evaluateCssExpressions(view, property, newPropertyValues[property]);
|
|
if (value === unsetValue) {
|
|
delete newPropertyValues[property];
|
|
continue;
|
|
}
|
|
|
|
newPropertyValues[property] = value;
|
|
}
|
|
}
|
|
|
|
// Property values are fully updated, freeze the object to be used for next update.
|
|
Object.freeze(newPropertyValues);
|
|
|
|
// Unset removed values
|
|
for (const property in oldProperties) {
|
|
if (!(property in newPropertyValues)) {
|
|
if (property in view.style) {
|
|
view.style[`css:${property}`] = unsetValue;
|
|
} else {
|
|
// TRICKY: How do we unset local value?
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set new values to the style
|
|
for (const property in newPropertyValues) {
|
|
if (oldProperties && property in oldProperties && oldProperties[property] === newPropertyValues[property]) {
|
|
// Skip unchanged values
|
|
continue;
|
|
}
|
|
|
|
const value = newPropertyValues[property];
|
|
try {
|
|
if (property in view.style) {
|
|
view.style[`css:${property}`] = value;
|
|
} else {
|
|
const camelCasedProperty = property.replace(/-([a-z])/g, function (g) {
|
|
return g[1].toUpperCase();
|
|
});
|
|
view[camelCasedProperty] = value;
|
|
}
|
|
} catch (e) {
|
|
Trace.write(`Failed to apply property [${property}] with value [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.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(attribute + 'Change', 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 {
|
|
const view = this.viewRef.get();
|
|
if (!view) {
|
|
Trace.write(`toString() of CssState cannot execute correctly because ".viewRef" is cleared`, Trace.categories.Animation, Trace.messageType.warn);
|
|
|
|
return '';
|
|
}
|
|
|
|
return `${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<any>;
|
|
private _css: 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.setCss(value);
|
|
}
|
|
|
|
public addCss(cssString: string, cssFileName?: string): void {
|
|
this.appendCss(cssString, cssFileName);
|
|
}
|
|
|
|
public addCssFile(cssFileName: string): void {
|
|
this.appendCss(null, cssFileName);
|
|
}
|
|
|
|
public changeCssFile(cssFileName: string): void {
|
|
if (!cssFileName) {
|
|
return;
|
|
}
|
|
|
|
const cssSelectors = CSSSource.fromURI(cssFileName, this._keyframes);
|
|
this._css = cssSelectors.source;
|
|
this._localCssSelectors = cssSelectors.selectors;
|
|
this._localCssSelectorVersion++;
|
|
this.ensureSelectors();
|
|
}
|
|
|
|
@profile
|
|
private setCss(cssString: string, cssFileName?): void {
|
|
this._css = cssString;
|
|
|
|
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;
|
|
}
|
|
|
|
let parsedCssSelectors = cssString ? CSSSource.fromSource(cssString, this._keyframes, cssFileName) : CSSSource.fromURI(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.isApplicationCssSelectorsLatestVersionApplied() || !this.isLocalCssSelectorsLatestVersionApplied() || !this._mergedCssSelectors) {
|
|
this._createSelectors();
|
|
}
|
|
|
|
return this.getSelectorsVersion();
|
|
}
|
|
|
|
public _increaseApplicationCssSelectorVersion(): void {
|
|
applicationCssSelectorVersion++;
|
|
}
|
|
|
|
public isApplicationCssSelectorsLatestVersionApplied(): boolean {
|
|
return this._applicationCssSelectorsAppliedVersion === applicationCssSelectorVersion;
|
|
}
|
|
|
|
public isLocalCssSelectorsLatestVersionApplied(): boolean {
|
|
return this._localCssSelectorsAppliedVersion === this._localCssSelectorVersion;
|
|
}
|
|
|
|
@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.reduce((merged, next) => merged.concat(next || []), []);
|
|
this._applyKeyframesOnSelectors();
|
|
this._selectors = new SelectorsMap(this._mergedCssSelectors);
|
|
}
|
|
}
|
|
|
|
// HACK: This @profile decorator creates a circular dependency
|
|
// HACK: because the function parameter type is evaluated with 'typeof'
|
|
@profile
|
|
public matchSelectors(view: any): SelectorsMatch<ViewBase> {
|
|
// should be (view: ViewBase): SelectorsMatch<ViewBase>
|
|
this.ensureSelectors();
|
|
|
|
return this._selectors.query(view);
|
|
}
|
|
|
|
public query(node: Node): SelectorCore[] {
|
|
this.ensureSelectors();
|
|
|
|
return this._selectors.query(node).selectors;
|
|
}
|
|
|
|
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, importSource?: string): 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) {
|
|
if (fileName[0] === '~' && fileName[1] !== '/' && fileName[1] !== '"') {
|
|
fileName = fileName.substr(1);
|
|
}
|
|
|
|
if (importSource) {
|
|
const importFile = resolveFilePathFromImport(importSource, fileName);
|
|
if (fileExists(importFile)) {
|
|
return importFile;
|
|
}
|
|
}
|
|
|
|
const external = path.join(appDirectory, 'tns_modules', fileName);
|
|
if (fileExists(external)) {
|
|
return external;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveFilePathFromImport(importSource: string, fileName: string): string {
|
|
const importSourceParts = importSource.split(path.separator);
|
|
const fileNameParts = fileName
|
|
.split(path.separator)
|
|
// exclude the dot-segment for current directory
|
|
.filter((p) => !isCurrentDirectory(p));
|
|
|
|
// remove current file name
|
|
importSourceParts.pop();
|
|
// remove element in case of dot-segment for parent directory or add file name
|
|
fileNameParts.forEach((p) => (isParentDirectory(p) ? importSourceParts.pop() : importSourceParts.push(p)));
|
|
|
|
return importSourceParts.join(path.separator);
|
|
}
|
|
|
|
export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) {
|
|
let localStyle = `local { ${styleStr} }`;
|
|
let inlineRuleSet = CSSSource.fromSource(localStyle, new Map()).selectors;
|
|
|
|
// Reset unscoped css-variables
|
|
view.style.resetUnscopedCssVariables();
|
|
|
|
// Set all the css-variables first, so we can be sure they are up-to-date
|
|
inlineRuleSet[0].declarations.forEach((d) => {
|
|
// Use the actual property name so that a local value is set.
|
|
let property = d.property;
|
|
if (isCssVariable(property)) {
|
|
view.style.setUnscopedCssVariable(property, d.value);
|
|
}
|
|
});
|
|
|
|
inlineRuleSet[0].declarations.forEach((d) => {
|
|
// Use the actual property name so that a local value is set.
|
|
let property = d.property;
|
|
try {
|
|
if (isCssVariable(property)) {
|
|
// Skip css-variables, they have been handled
|
|
return;
|
|
}
|
|
|
|
const value = evaluateCssExpressions(view, property, d.value);
|
|
if (property in view.style) {
|
|
view.style[property] = value;
|
|
} else {
|
|
view[property] = value;
|
|
}
|
|
} catch (e) {
|
|
Trace.write(`Failed to apply property [${d.property}] with value [${d.value}] to ${view}. ${e}`, Trace.categories.Error, Trace.messageType.error);
|
|
}
|
|
});
|
|
|
|
// This is needed in case of changes to css-variable or css-calc expressions.
|
|
view._onCssStateChange();
|
|
});
|
|
|
|
function isCurrentDirectory(uriPart: string): boolean {
|
|
return uriPart === '.';
|
|
}
|
|
|
|
function isParentDirectory(uriPart: string): boolean {
|
|
return uriPart === '..';
|
|
}
|
|
|
|
function isKeyframe(node: CssNode): node is KeyframesDefinition {
|
|
return node.type === 'keyframes';
|
|
}
|