tandem-front-end
Version:
Visual editor for web components
418 lines (355 loc) • 9.27 kB
text/typescript
import { EMPTY_ARRAY, KeyValue } from "tandem-common";
import { ComputedStyleInfo } from "paperclip";
// TODO - compute this information based on CSS properties
export enum CSSBackgroundType {
SOLID,
LINEAR_GRADIENT,
IMAGE
}
export enum BackgroundBlendMode {
SATURATION = "saturation"
}
export type CSSBaseBackground<TType extends CSSBackgroundType> = {
type: TType;
blendMode?: BackgroundBlendMode;
};
export type CSSSolidBackground = {
color: string;
} & CSSBaseBackground<CSSBackgroundType.SOLID>;
export type CSSLinearGradientColorStop = {
color: string;
stop: number;
};
export type CSSLinearGradientBackground = {
degree: string;
stops: CSSLinearGradientColorStop[];
} & CSSBaseBackground<CSSBackgroundType.LINEAR_GRADIENT>;
export type CSSImageBackground = {
// https://www.w3schools.com/cssref/pr_background-image.asp
uri: string;
// https://www.w3schools.com/CSSref/pr_background-repeat.asp
repeat?: string;
// https://www.w3schools.com/csSref/css3_pr_background-size.asp
size?: string;
position?: string;
} & CSSBaseBackground<CSSBackgroundType.IMAGE>;
export type CSSBackground =
| CSSSolidBackground
| CSSLinearGradientBackground
| CSSImageBackground;
export const computeCSSBackgrounds = ({
style
}: ComputedStyleInfo): CSSBackground[] => {
const source = style["background-image"];
if (!source) {
return EMPTY_ARRAY;
}
const backgrounds = parseCSSBackroundValue(source);
let newBackgrounds = [];
for (let i = 0, n = backgrounds.length; i < n; i++) {
let newBackground = backgrounds[i];
switch (newBackground.type) {
case CSSBackgroundType.IMAGE: {
newBackground = {
...newBackground,
repeat: getCSSParamValue(style["background-repeat"], i) || "repeat",
size: getCSSParamValue(style["background-size"], i) || "auto",
position: getCSSParamValue(style["background-position"], i) || "0px"
};
break;
}
}
newBackgrounds.push(newBackground);
}
return newBackgrounds;
};
const getCSSParamValue = (value: string, index: number) => {
return value && value.split(/\s*,\s*/)[index];
};
export const parseCSSBackroundValue = (value: string) => {
const scanner = new Scanner(value);
const tokenizer = new Tokenizer(scanner);
return getBackgroundExpressions(tokenizer);
};
const getBackgroundExpressions = (tokenizer: Tokenizer): CSSBackground[] => {
const backgrounds = [];
while (!tokenizer.ended()) {
tokenizer.eatWhitespace();
backgrounds.push(getBackgroundExpression(tokenizer));
tokenizer.next();
}
return backgrounds;
};
const getBackgroundExpression = (tokenizer: Tokenizer): CSSBackground => {
const keyword = tokenizer.next();
const t = tokenizer.next();
if (t.type === TokenType.L_PAREN) {
const params = getParams(tokenizer);
if (keyword.value === "linear-gradient") {
if (params.length === 2 && params[0] === params[1]) {
const solid: CSSSolidBackground = {
color: params[0],
type: CSSBackgroundType.SOLID
};
return solid;
} else {
let degree: string;
if (/deg$/.test(params[0])) {
degree = params.shift();
}
const stops: CSSLinearGradientColorStop[] = [];
for (const param of params) {
const [, color, stop] = param.match(/(.*?)([\d\.]+%)$/);
stops.push({
color: color.trim(),
stop: Number(stop.trim().replace("%", ""))
});
}
const linearGradient: CSSLinearGradientBackground = {
stops,
type: CSSBackgroundType.LINEAR_GRADIENT,
degree
};
return linearGradient;
}
} else if (keyword.value === "url") {
const image: CSSImageBackground = {
uri: params.join(","),
type: CSSBackgroundType.IMAGE
};
return image;
} else {
throw new Error(`unexpected keyword ${keyword.value}`);
}
}
return null;
};
const getParams = (tokenizer: Tokenizer): string[] => {
const params: string[] = [];
let buffer: string = "";
while (!tokenizer.ended()) {
const curr = tokenizer.next();
if (curr.type === TokenType.R_PAREN) {
params.push(buffer.trim());
break;
}
if (curr.type === TokenType.COMMA) {
params.push(buffer.trim());
buffer = "";
continue;
}
let value = curr.value;
if (!tokenizer.ended()) {
if (curr.type === TokenType.APOS || curr.type === TokenType.QUOTE) {
value = getString(tokenizer);
} else if (tokenizer.peek().type === TokenType.L_PAREN) {
tokenizer.next(); // eat (
value = `${value}(${getParams(tokenizer).join(", ")})`;
}
}
buffer += value;
}
return params;
};
const getString = (tokenizer: Tokenizer): string => {
let buffer: string = "";
while (!tokenizer.ended()) {
const curr = tokenizer.next();
if (curr.type === TokenType.QUOTE || curr.type === TokenType.APOS) {
break;
}
buffer += curr.value;
}
return buffer;
};
export const stringifyCSSBackground = (background: CSSBackground) => {
switch (background.type) {
case CSSBackgroundType.IMAGE: {
return `url("${background.uri}")`;
}
case CSSBackgroundType.LINEAR_GRADIENT: {
return `linear-gradient()`;
}
case CSSBackgroundType.SOLID: {
return `linear-gradient(${background.color}, ${background.color})`;
}
}
};
export const getCSSBackgroundsStyle = (backgrounds: CSSBackground[]) => {
let style: KeyValue<string> = {};
for (const background of backgrounds) {
switch (background.type) {
case CSSBackgroundType.IMAGE: {
style = addStyle(
style,
"background-image",
stringifyCSSBackground(background)
);
style = addStyle(
style,
"background-repeat",
background.repeat || "repeat"
);
style = addStyle(style, "background-size", background.size || "auto");
style = addStyle(
style,
"background-position",
background.position || "0px"
);
break;
}
case CSSBackgroundType.LINEAR_GRADIENT: {
style = addStyle(
style,
"background-image",
stringifyCSSBackground(background)
);
break;
}
case CSSBackgroundType.SOLID: {
style = addStyle(
style,
"background-image",
stringifyCSSBackground(background)
);
break;
}
}
}
return style;
};
const addStyle = (
style: KeyValue<string>,
key: string,
value: string
): KeyValue<string> => {
if (!value) {
return style;
}
if (!style[key]) {
return {
...style,
[key]: value
};
} else {
return {
...style,
[key]: style[key] + ", " + value
};
}
};
enum TokenType {
KEYWORD,
L_PAREN,
WHITESPACE,
R_PAREN,
COMMA,
NUMBER,
CHAR,
APOS,
QUOTE
}
type Token = {
type: TokenType;
value: string;
};
class Tokenizer {
private _stack: Token[] = [];
constructor(private _scanner: Scanner) {}
ended() {
return this._scanner.ended() && !this._stack.length;
}
putBack() {
this._stack.unshift(this.current());
}
next() {
if (this._stack.length === 0) {
this._stack.push(this._next());
}
return this._stack.shift();
}
eatWhitespace() {
if (!this.ended() && this.peek(1).type === TokenType.WHITESPACE) {
this.next();
}
}
peek(count: number = 1) {
let diff = count - this._stack.length;
while (diff > 0) {
this._stack.push(this._next());
diff--;
}
return this._stack[0];
}
_next(): Token {
const c = this._scanner.next();
// whitespace
if (/[\s\r\n\t]/.test(c)) {
return {
type: TokenType.WHITESPACE,
value: c + this._scanner.scan(/^[\s\n\r\t]+/)
};
}
if (c === '"')
return {
type: TokenType.QUOTE,
value: c
};
if (c === "'")
return {
type: TokenType.APOS,
value: c
};
if (c === "(")
return {
type: TokenType.L_PAREN,
value: c
};
if (c === ")")
return {
type: TokenType.R_PAREN,
value: c
};
if (/[a-zA-Z_-]/.test(c))
return {
type: TokenType.KEYWORD,
value: c + this._scanner.scan(/^[a-zA-Z_-]+/)
};
if (/[0-9\.]/.test(c))
return {
type: TokenType.NUMBER,
value: c + this._scanner.scan(/^[0-9\.]+/)
};
if (c === ",")
return {
type: TokenType.COMMA,
value: c
};
return {
type: TokenType.CHAR,
value: c
};
}
current(): Token {
return this._stack[0];
}
}
class Scanner {
private _position: number = 0;
constructor(private _source: string) {}
next() {
return this.scan(/./);
}
ended() {
return this._position >= this._source.length;
}
scan(pattern: RegExp) {
const match = this._source.substr(this._position).match(pattern);
if (!match) {
return "";
}
const value = match[0];
this._position += value.length;
return value;
}
}