zeplin-extension-style-kit
Version:
Models and utilities to generate CSS-like style code in Zeplin extensions.
641 lines (544 loc) • 21.8 kB
text/typescript
import {
Border as ExtensionBorder,
Fill as ExtensionFill,
Layer as ExtensionLayer,
Layout as ExtensionLayout,
TextStyle as ExtensionTextStyle
} from "@zeplin/extension-model";
import { Angle } from "../values/angle.js";
import { Color } from "../values/color.js";
import { Gradient } from "../values/gradient/index.js";
import { Length } from "../values/length.js";
import { Scalar } from "../values/scalar.js";
import { Border, BorderSide } from "../declarations/border.js";
import { Shadow } from "../declarations/shadow.js";
import { Opacity } from "../declarations/opacity.js";
import { Width } from "../declarations/width.js";
import { Height } from "../declarations/height.js";
import { ObjectFit } from "../declarations/objectFit.js";
import { Transform } from "../declarations/transform.js";
import { BlendModeValue, MixBlendMode } from "../declarations/mixBlendMode.js";
import { BorderRadius } from "../declarations/borderRadius.js";
import { BackgroundBlendMode, BlendMode } from "../declarations/backgroundBlendMode.js";
import { BackgroundImage } from "../declarations/backgroundImage.js";
import { BackgroundColor } from "../declarations/backgroundColor.js";
import { BackgroundClip } from "../declarations/backgroundClip.js";
import { BackgroundOrigin } from "../declarations/backgroundOrigin.js";
import { TextFillColor } from "../declarations/textFillColor.js";
import { TextStroke } from "../declarations/textStroke.js";
import { BorderImageSource } from "../declarations/borderImageSource.js";
import { BorderWidth } from "../declarations/borderWidth.js";
import { BorderStyle } from "../declarations/borderStyle.js";
import { BorderImageSlice } from "../declarations/borderImageSlice.js";
import { BackdropFilter } from "../declarations/backdropFilter.js";
import { Filter } from "../declarations/filter.js";
import { FontColor } from "../declarations/fontColor.js";
import { Margin } from "../declarations/margin.js";
import { Padding } from "../declarations/padding.js";
import { Display } from "../declarations/display.js";
import { FlexDirection } from "../declarations/flexDirection.js";
import { Gap } from "../declarations/gap.js";
import { TextStyle } from "./textStyle.js";
import { RuleSet } from "../ruleSet.js";
import { blendColors, isHtmlTag, isTextRelatedTag, selectorize, webkit } from "../utils.js";
import { Bound } from "./utility/bound.js";
import { JustifyContent } from "../declarations/justifyContent.js";
import { AlignItems, AlignItemsValue } from "../declarations/alignItems.js";
import { AlignSelf, AlignSelfValue } from "../declarations/alignSelf.js";
import { FlexGrow } from "../declarations/flexGrow.js";
import { StyleDeclaration } from "../common.js";
const useRemUnitForMeasurement = ({ useForMeasurements }: {
useForMeasurements: boolean
}): boolean => useForMeasurements;
const borderPositionShadowTypeMapper: Record<string, string> = {
inside: "inner",
outside: "outer"
} as const;
export class Layer {
private object: ExtensionLayer;
private declarations: StyleDeclaration[];
constructor(layer: ExtensionLayer) {
this.object = layer;
this.declarations = this.collectDeclarations();
}
static fillToGradient(fill: ExtensionFill, width: number, height: number): Gradient {
return fill.type === ExtensionFill.TYPE.COLOR
? new Color(fill.color!).toGradient()
: new Gradient(fill.gradient!, width, height);
}
get hasBlendMode(): boolean {
return this.object.fills.some(f => f.blendMode && f.blendMode !== "normal");
}
get hasGradient(): boolean {
return this.object.fills.some(f => f.type === ExtensionFill.TYPE.GRADIENT);
}
get hasFill(): boolean {
return this.object.fills.length > 0;
}
getLayerTextStyleDeclarations(textStyle: ExtensionTextStyle) {
const { object: layer } = this;
let declarations = new TextStyle(textStyle).declarations;
if (layer.fills.length) {
declarations = declarations.filter(declaration => !(declaration instanceof Color));
if (this.hasGradient) {
declarations.push(new (webkit(BackgroundClip))(["text"]));
declarations.push(new BackgroundClip(["text"]));
declarations.push(new TextFillColor("transparent"));
const bgImages = layer.fills.map(fill =>
Layer.fillToGradient(fill, layer.rect.width, layer.rect.height)
);
if (textStyle.color) {
bgImages.push(new Color(textStyle.color).toGradient());
}
declarations.push(new BackgroundImage(bgImages));
} else {
let blentColor = blendColors(layer.fills.map(fill => fill.color!).filter(Boolean));
if (textStyle.color) {
blentColor = blentColor.blend(textStyle.color);
}
declarations.push(new FontColor(new Color(blentColor)));
}
}
return declarations;
}
blendBorders(accumulator: ExtensionBorder, current: ExtensionBorder): ExtensionBorder {
if (accumulator.fill.type === ExtensionFill.TYPE.COLOR
&& current.fill.type === ExtensionFill.TYPE.COLOR) {
accumulator.fill = Object.assign({}, accumulator.fill, {
color: current.fill.color!.blend(accumulator.fill.color!)
});
return accumulator;
} else if (accumulator.fill.type === ExtensionFill.TYPE.GRADIENT) {
accumulator.fill = Object.assign(
{},
accumulator.fill,
{
gradient: Object.assign(
{},
accumulator.fill.gradient,
{
colorStops: accumulator.fill?.gradient?.colorStops?.map(({ position, color }) => ({
position,
color: current.fill?.color?.blend(color)
}))
}
)
}
);
return accumulator;
}
accumulator.fill = Object.assign(
{},
current.fill,
{
gradient: Object.assign(
{},
current.fill.gradient,
{
colorStops: current.fill?.gradient?.colorStops?.map(({ position, color }) => ({
position,
color: color.blend(accumulator.fill.color!)
}))
}
)
}
);
return accumulator;
}
get elementBorder(): ExtensionBorder | null {
const { object: { borders } } = this;
if (!this.shouldDrawBorder()) {
return null;
}
if (borders.length === 1) {
return borders[0];
}
return borders.reduce(this.blendBorders);
}
get backgroundImages(): Gradient[] | null {
let bgImages: Gradient[] | null = null;
if (!this.hasFill) {
return null;
}
if (this.hasGradient || this.hasBlendMode) {
bgImages = this.object.fills.map(fill =>
Layer.fillToGradient(fill, this.object.rect.width, this.object.rect.height)
);
}
if (this.elementBorder) {
if (this.object.borderRadius && this.elementBorder.fill.type === ExtensionFill.TYPE.GRADIENT) {
const borderFill = new Gradient(
this.elementBorder.fill.gradient!,
this.object.rect.width,
this.object.rect.height
);
if (bgImages) {
bgImages.push(borderFill);
} else if (this.fillColor) {
bgImages = [this.fillColor.toGradient(), borderFill];
} else {
/*
* Actually the background should be transparent if there are no fills,
* i.e. what's on the background of this layer should be shown.
* But we have no way of knowing the background of parent layer,
* and making it transparent will make fake-gradient-border background visible.
*/
const white = Color.fromRGBA({
r: 255,
g: 255,
b: 255,
a: 1
});
bgImages = [white.toGradient(), borderFill];
}
}
}
return bgImages;
}
get fillColor(): Color | null {
if (this.hasFill && !this.hasGradient && !this.hasBlendMode) {
return new Color(blendColors(this.object.fills.map(fill => fill.color!)));
}
return null;
}
shouldDrawBorder(): boolean {
const {
object: {
borders
}
} = this;
const gradientCount = borders.filter(({ fill }) => fill.type === ExtensionFill.TYPE.GRADIENT).length;
if (gradientCount > 1 || borders.length === 0) {
return false;
}
const [{ position, thickness, individualThickness }] = borders;
if (individualThickness) {
return borders.every(border =>
border.position === position &&
border.thickness === thickness &&
border.individualThickness &&
border.individualThickness.top === individualThickness.top &&
border.individualThickness.right === individualThickness.right &&
border.individualThickness.bottom === individualThickness.bottom &&
border.individualThickness.left === individualThickness.left
);
}
return borders.every(border => border.position === position && border.thickness === thickness);
}
generateBorderDeclarations(): StyleDeclaration[] {
if (!this.shouldDrawBorder()) {
return [];
}
const layer = this.object;
const fill = this.elementBorder?.fill;
const thickness = this.elementBorder?.thickness;
const individualThickness = this.elementBorder?.individualThickness;
if (layer.type === "text" && fill?.type === ExtensionFill.TYPE.COLOR) {
return [
new (webkit(TextStroke))(new Length(thickness!), new Color(fill.color!))
];
}
switch (fill?.type) {
case ExtensionFill.TYPE.COLOR: {
if (individualThickness) {
return Object.entries(individualThickness)
.filter(([, sideWidth]) => sideWidth > 0)
.map(([side, sideWidth]) => new Border({
style: "solid",
width: new Length(sideWidth),
color: new Color(fill.color!),
side: side as BorderSide
}));
}
return [
new Border({
style: "solid",
width: new Length(thickness!),
color: new Color(fill.color!),
side: "all"
})
];
}
case ExtensionFill.TYPE.GRADIENT: {
if (individualThickness) {
return [];
}
return [
new BorderStyle("solid"),
new BorderWidth(new Length(thickness!)),
new BorderImageSource(new Gradient(fill.gradient!, layer.rect.width, layer.rect.height)),
new BorderImageSlice(new Scalar(1))
];
}
default:
return [];
}
}
shouldDrawShadowFromBorder(): boolean {
const {
object: {
borders
}
} = this;
return !this.shouldDrawBorder() &&
borders.length > 1 &&
borders.every(({ fill }) => fill.type === ExtensionFill.TYPE.COLOR);
}
shadowOffset(): number {
const {
object: {
borders
}
} = this;
const maxThickness = Math.max(...(borders || []).map(({ thickness }) => thickness || 0));
switch (borders[0].position) {
case "outside":
return maxThickness;
case "center":
return maxThickness / 2;
case "inside":
default:
return 0;
}
}
generateShadowDeclarations(): StyleDeclaration[] {
const {
object: {
borders,
shadows,
type
}
} = this;
const shouldDraw = this.shouldDrawShadowFromBorder();
if (!shouldDraw && shadows.length) {
return [new Shadow(shadows, type === "text" ? Shadow.TYPES.TEXT : Shadow.TYPES.BOX)];
}
if (!shouldDraw && shadows.length === 0) {
return [];
}
const bordersWithoutCenter = borders.flatMap(border => (
border.position === "center"
? [
Object.assign({}, border, { thickness: (border.thickness || 0) / 2, position: "inside" }),
Object.assign({}, border, { thickness: (border.thickness || 0) / 2, position: "outside" })
] : border
));
const borderShadows = bordersWithoutCenter.reverse().map(({ fill: { color }, thickness, position }) => ({
type: borderPositionShadowTypeMapper[position!],
color: color!,
offsetY: 0,
offsetX: 0,
blurRadius: 0,
spread: thickness!
}));
const offset = this.shadowOffset();
return [
new Shadow(
[
...borderShadows,
...shadows.map(shadow => Object.assign(
{},
shadow,
{ spread: shadow.spread + offset }
))
],
type === "text" ? Shadow.TYPES.TEXT : Shadow.TYPES.BOX
)
];
}
generateBlurDeclarations(): StyleDeclaration[] {
const { blur } = this.object;
const filterFns = [{ fn: "blur", args: [new Length(blur!.radius)] }];
if (blur!.type === "background") {
return [
new (webkit(BackdropFilter))(filterFns),
new BackdropFilter(filterFns)
];
}
return [
new (webkit(Filter))(filterFns),
new Filter(filterFns)
];
}
generateBackgroundDeclarations(): StyleDeclaration[] {
const { backgroundImages, elementBorder, fillColor, object: layer } = this;
const declarations = [];
if (this.hasFill && this.hasBlendMode) {
declarations.push(new BackgroundBlendMode((layer.fills || []).map(fill => fill.blendMode as BlendMode)));
}
if (backgroundImages) {
declarations.push(new BackgroundImage(backgroundImages));
if (layer.borderRadius && elementBorder && elementBorder.fill.type === ExtensionFill.TYPE.GRADIENT) {
declarations.push(new BackgroundOrigin(["border-box"]));
declarations.push(new BackgroundClip([...Array(backgroundImages.length - 1).fill("content-box"), "border-box"]));
}
} else if (fillColor) {
declarations.push(new BackgroundColor(fillColor));
}
return declarations;
}
shouldAddWidth(): boolean {
const {
object: {
parent,
layoutGrow,
layoutAlignment
}
} = this;
return !parent || !parent.layout || (
parent.layout.direction === ExtensionLayout.DIRECTION.ROW
? layoutGrow === 0
: layoutAlignment !== ExtensionLayout.ALIGNMENT.STRETCH
);
}
shouldAddHeight(): boolean {
const {
object: {
parent,
layoutGrow,
layoutAlignment
}
} = this;
return !parent || !parent.layout || (
parent.layout.direction === ExtensionLayout.DIRECTION.COLUMN
? layoutGrow === 0
: layoutAlignment !== ExtensionLayout.ALIGNMENT.STRETCH
);
}
addSizeToDeclaration(declarations: StyleDeclaration[]): void {
const {
object: {
rect: {
width,
height
}
}
} = this;
if (this.shouldAddWidth()) {
declarations.push(new Width(new Length(width, { useRemUnit: useRemUnitForMeasurement })));
}
if (this.shouldAddHeight()) {
declarations.push(new Height(new Length(height, { useRemUnit: useRemUnitForMeasurement })));
}
}
addLayoutToDeclarations(declarations: StyleDeclaration[]): void {
const {
object: {
layout,
layoutAlignment,
layoutGrow
}
} = this;
if (layoutAlignment && layoutAlignment !== ExtensionLayout.ALIGNMENT.INHERIT) {
declarations.push(new AlignSelf(layoutAlignment as AlignSelfValue));
}
if (layoutGrow !== void 0) {
declarations.push(new FlexGrow(new Scalar(layoutGrow)));
}
if (layout && layout.direction) {
const {
direction,
distribution,
itemAlignment,
gap = 0
} = layout;
declarations.push(
Display.flex(),
new FlexDirection(direction)
);
if (distribution) {
declarations.push(new JustifyContent(distribution));
}
if (itemAlignment) {
declarations.push(new AlignItems(itemAlignment as AlignItemsValue));
}
if (gap > 0) {
declarations.push(new Gap(new Length(gap)));
}
}
}
addPaddingMargin(declarations: StyleDeclaration[]): void {
const {
object: {
layout,
parent
},
object: layer
} = this;
const { margin, padding } = Bound.layerToBound(layer) || {};
if (
(!parent || !parent.layout) &&
(margin && !margin.equals(Margin.Zero))
) {
declarations.push(margin);
}
if (layout && layout.padding) {
declarations.push(
new Padding({
top: new Length(layout.padding.top!, { useRemUnit: useRemUnitForMeasurement }),
right: new Length(layout.padding.right!, { useRemUnit: useRemUnitForMeasurement }),
bottom: new Length(layout.padding.bottom!, { useRemUnit: useRemUnitForMeasurement }),
left: new Length(layout.padding.left!, { useRemUnit: useRemUnitForMeasurement })
})
);
} else if (padding && !padding.equals(Padding.Zero)) {
declarations.push(padding);
}
}
collectDeclarations(): StyleDeclaration[] {
const {
object: layer
} = this;
let declarations: StyleDeclaration[] = [];
this.addSizeToDeclaration(declarations);
this.addLayoutToDeclarations(declarations);
this.addPaddingMargin(declarations);
if (layer.exportable) {
declarations.push(new ObjectFit("contain"));
}
if (layer.rotation) {
declarations.push(new Transform([{ fn: "rotate", args: [new Angle(-layer.rotation)] }]));
}
if (layer.opacity !== 1) {
declarations.push(new Opacity(new Scalar(layer.opacity)));
}
if (layer.blendMode !== "normal") {
declarations.push(new MixBlendMode(layer.blendMode as BlendModeValue));
}
if (layer.borderRadius) {
// TODO: different radii for each corner?
declarations.push(new BorderRadius(new Length(layer.borderRadius as number)));
}
if (layer.blur && layer.blur.radius) {
declarations = declarations.concat(this.generateBlurDeclarations());
}
declarations = declarations.concat(this.generateShadowDeclarations());
declarations = declarations.concat(this.generateBorderDeclarations());
declarations = declarations.concat(this.generateBackgroundDeclarations());
return declarations;
}
get name() {
const {
object: {
type,
exportable,
name
}
} = this;
const layerName = selectorize(name);
if (exportable) {
const className = layerName ? layerName.replace(/^(\w)/, ".$1") : "";
return `img${className}`;
}
if (type !== "text") {
return layerName || "div";
}
if (isHtmlTag(layerName) && !isTextRelatedTag(layerName)) {
return `.${layerName}`;
}
return layerName || "span";
}
get style() {
return new RuleSet(this.name, this.declarations);
}
}