UNPKG

zeplin-extension-style-kit

Version:

Models and utilities to generate CSS-like style code in Zeplin extensions.

641 lines (544 loc) 21.8 kB
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); } }