Changes
4 changed files (+579/-46)
-
-
@@ -0,0 +1,29 @@import { svg } from "lit-element"; export const CloseIcon = ({ onClick = () => {} }) => svg` <svg @click=${onClick} title="close icon" width="14" height="14" viewBox="0 0 20 20" fill="none"> <path d="M1 19L19 1M19 19L1 1" stroke="#B3B3B3" stroke-width="2"/> </svg> `; export const CopyIcon = ({ onClick = () => {} }) => svg` <svg @click=${onClick} title="copy icon" width="14" height="14" viewBox="0 0 30 30" fill="none"> <path d="M21 25.5C21 24.9477 20.5523 24.5 20 24.5C19.4477 24.5 19 24.9477 19 25.5H21ZM13 2H25V0H13V2ZM28 5V21H30V5H28ZM25 24H13V26H25V24ZM10 21V5H8V21H10ZM13 24C11.3431 24 10 22.6569 10 21H8C8 23.7614 10.2386 26 13 26V24ZM28 21C28 22.6569 26.6569 24 25 24V26C27.7614 26 30 23.7614 30 21H28ZM25 2C26.6569 2 28 3.34315 28 5H30C30 2.23858 27.7614 0 25 0V2ZM13 0C10.2386 0 8 2.23858 8 5H10C10 3.34315 11.3431 2 13 2V0ZM16.5 28H5V30H16.5V28ZM2 25V10H0V25H2ZM5 28C3.34315 28 2 26.6569 2 25H0C0 27.7614 2.23858 30 5 30V28ZM5 7H8V5H5V7ZM2 10C2 8.34315 3.34315 7 5 7V5C2.23858 5 0 7.23858 0 10H2ZM16.5 30C18.9853 30 21 27.9853 21 25.5H19C19 26.8807 17.8807 28 16.5 28V30Z" fill="#B3B3B3"/> </svg> `; export const HorizontalPaddingIcon = () => svg` <svg title="horizontal padding" width="14" height="14" viewBox="0 0 29 28" fill="none"> <rect x="7" y="8" width="14" height="14" stroke="#B3B3B3" stroke-width="2"/> <path d="M27 1V28" stroke="#B3B3B3" stroke-width="2"/> <path d="M1 0V28" stroke="#B3B3B3" stroke-width="2"/> </svg> `; export const VerticalPaddingIcon = () => svg` <svg title="vertical padding" width="14" height="14" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect x="8" y="21" width="14" height="14" transform="rotate(-90 8 21)" stroke="#B3B3B3" stroke-width="2"/> <path d="M1 1L28 0.999999" stroke="#B3B3B3" stroke-width="2"/> <path d="M0 27L28 27" stroke="#B3B3B3" stroke-width="2"/> </svg> `;
-
-
-
@@ -0,0 +1,219 @@import { css, html } from "lit-element"; import * as copy from "copy-to-clipboard"; import { HorizontalPaddingIcon, VerticalPaddingIcon, CloseIcon, CopyIcon, } from "../Icons"; import { FigmaNode, getStyleRule, NodeStyles } from "./utils"; import type { CSSRule } from "./utils"; export type InspectorViewProps = { node: FigmaNode; onClose: () => void; }; export const View = ({ node, onClose }: InspectorViewProps) => { if (!node) { return null; } const nodeStyles = new NodeStyles(node); return html` <div class="inspector-view" @click=${(ev: Event) => { // TODO: remove this once this element is moved outside of ViewerMixin ev.stopPropagation(); }} > <div class="inspector-section selectable-content"> <div class="title-section"> <h4>${node.name}</h4> ${CloseIcon({ onClick: onClose })} </div> <div class="properties-overview"> <div class="title-section"> <p class="inspector-property"> <span>W: </span>${nodeStyles.width} </p> <p class="inspector-property" style="margin-left: 16px;"> <span>H: </span>${nodeStyles.height} </p> </div> ${nodeStyles.fontPostScriptName ? html`<p class="inspector-property"> <span>Font:</span> ${nodeStyles.fontPostScriptName} </p>` : null} </div> </div> ${nodeStyles.hasPadding ? html`<div class="inspector-section"> <h4>Layout</h4> ${nodeStyles.horizontalPadding && html`<p class="inspector-property"> ${HorizontalPaddingIcon()} ${nodeStyles.horizontalPadding} </p>`} ${nodeStyles.verticalPadding && html`<p class="inspector-property"> ${VerticalPaddingIcon()} ${nodeStyles.verticalPadding} </p>`} </div>` : null} ${node.characters ? html`<div class="inspector-section"> <div class="title-section"> <h4>Content</h4> ${CopyIcon({ onClick: () => copy(node.characters) })} </div> <p class="node-content code-section selectable-content"> ${node.characters} </p> </div>` : null} ${StylesSection(nodeStyles)} </div> `; }; export const StylesSection = (nodeStyles: NodeStyles) => { const onClick = () => copy(nodeStyles.getStyleSheet()); const styles = nodeStyles.getStyles(); return html`<div class="inspector-section"> <div class="title-section style-section"> <h4>CSS</h4> ${CopyIcon({ onClick })} </div> <div class="code-section selectable-content"> ${styles.map(CSSProperty)} </div> </div>`; }; const CSSProperty = (cssProperty: CSSRule) => { const { property, value, color } = cssProperty; let coloredSquare = null; switch (property) { case "background": case "fill": case "border": case "box-shadow": case "color": coloredSquare = html`<span class="color-preview" style="background-color: ${color}" ></span>`; break; case "background-image": coloredSquare = html`<span class="color-preview" style="background-image: ${value}" ></span>`; break; } return html`<div class="css-property" @click=${() => copy(getStyleRule(cssProperty))}> <span>${property}:</span>${coloredSquare}<span class="css-value">${value}</span>;</span> </div>`; }; export const styles = css` .inspector-view { height: 100vh; width: 300px; position: fixed; right: 0; background: white; border-left: 1px solid #ccc; } .inspector-view h4 { font-size: 16px; margin: 0; } .style-section { margin-bottom: 12px; } .title-section { display: flex; align-items: center; } .code-section { padding: 8px; background: #f3f3f3; font-family: monospace; } .title-section svg { cursor: pointer; margin-left: auto; } .inspector-section { padding: 16px; border-bottom: 1px solid #eee; } .properties-overview { font-family: monospace; color: #518785; } .properties-overview p span { color: #121212; } .inspector-property { display: flex; align-items: center; margin-bottom: 0; } .inspector-property span { color: #b3b3b3; margin-right: 4px; } .inspector-property svg { margin-right: 8px; } .css-property { margin: 8px; transition: background-color ease-in-out 100ms; } .css-property:hover { cursor: pointer; background-color: #e8e8e8; } .css-value { color: #518785; margin-left: 4px; } .color-preview { display: inline-block; width: 12px; height: 12px; border: 1px solid #ccc; margin-left: 4px; vertical-align: middle; } .selectable-content { cursor: text; user-select: text; } `;
-
-
-
@@ -0,0 +1,271 @@import * as Figma from "figma-js"; type ElementColor = Figma.Color; type GradientStop = { color: ElementColor; position: number }; type GradientHandlePosition = { x: number; y: number; }; type ElementGradientColor = { gradientHandlePositions: GradientHandlePosition[]; gradientStops: GradientStop[]; }; export type FigmaNode = Figma.Node & { name: string; characters: string; background: { color: ElementColor }[]; backgroundColor: ElementColor; fills: { color: ElementColor }[]; absoluteBoundingBox: { height: number; width: number; }; cornerRadius?: number; rectangleCornerRadii?: number[]; horizontalPadding: number; verticalPadding: number; style?: { fontFamily: string; fontPostScriptName: string; fontSize: number; fontWeight: number; lineHeightPx: number; textAlignHorizontal: string; textAlignVertical: string; }; type: "TEXT" | "INSTANCE" | "FRAME" | "VECTOR" | "RECTANGLE"; }; export type CSSRule = { property: string; value: string; color?: string; }; const extractColorStyle = (color: ElementColor) => { if (color.a === 0) { return "transparent"; } else if (color.a < 1) { return `rgba(${rgbToIntArray(color).join(", ")}, ${color.a.toFixed(2)})`; } else { return rgbToHex(color); } }; const extractGradientColorStyle = (color: ElementGradientColor) => { return new Gradient(color).cssColor; }; export class Gradient { colors; colorObjects; angle; gradientHandles: { start: GradientHandlePosition; end: GradientHandlePosition; }; constructor(data: ElementGradientColor) { this.gradientHandles = { start: data.gradientHandlePositions[0], end: data.gradientHandlePositions[1], }; this.colors = data.gradientStops; this.colorObjects = this.createColorObjects(this.colors); this.angle = this.calculateAngle( this.gradientHandles.start, this.gradientHandles.end ); } get cssGradientArray() { return this.colorObjects.map((color, index) => { const position = this.floatToPercent(this.colors[index].position); return color + " " + position; }); } get cssColor() { const cssGradientArray = this.cssGradientArray; cssGradientArray.unshift(this.angle + "deg"); return `linear-gradient(${cssGradientArray.join(", ")})`; } private createColorObjects(colors: GradientStop[]) { return colors.map(({ color }) => extractColorStyle(color)); } private floatToPercent(value: number) { return (value *= 100).toFixed(0) + "%"; } private calculateAngle( startHandle: GradientHandlePosition, endHandle: GradientHandlePosition ) { const radians = Math.atan(this.calculateGradient(startHandle, endHandle)); return parseInt(this.radToDeg(radians).toFixed(1)); } private calculateGradient( startHandle: GradientHandlePosition, endHandle: GradientHandlePosition ) { return ((endHandle.y - startHandle.y) / (endHandle.x - startHandle.x)) * -1; } private radToDeg(radian: number) { return (180 * radian) / Math.PI; } } export class NodeStyles { background; backgroundImage; border; borderColor; borderRadius; boxShadow; boxShadowColor; color; fontFamily; fontPostScriptName; fontSize; fontWeight; height; horizontalPadding; lineHeight; verticalPadding; width; hasPadding = false; constructor(node: FigmaNode) { this.height = `${Math.trunc(node.absoluteBoundingBox.height)}px`; this.width = `${Math.trunc(node.absoluteBoundingBox.width)}px`; // paddings if (node.horizontalPadding || node.verticalPadding) { this.hasPadding = true; this.horizontalPadding = `${node.horizontalPadding}px`; this.verticalPadding = `${node.verticalPadding}px`; } // font styles if (node.style) { this.fontFamily = node.style.fontFamily; this.fontPostScriptName = node.style.fontPostScriptName?.replace( "-", " " ); this.fontWeight = node.style.fontWeight; this.fontSize = `${Math.ceil(node.style.fontSize)}px`; this.lineHeight = `${Math.trunc(node.style.lineHeightPx)}px`; } // border radii if (node.rectangleCornerRadii) { this.borderRadius = node.rectangleCornerRadii.filter( (radius) => radius === node.cornerRadius ).length < 4 ? `${node.rectangleCornerRadii.join("px ")}px` : `${node.cornerRadius}px`; } // colors, background, fill if (node.backgroundColor || node.backgroundColor) { const color = node.backgroundColor || node.background?.[0].color; this.background = extractColorStyle(color); } const fillColor = node.fills?.[0]; if (fillColor && fillColor.visible !== false) { if (node.type === "TEXT") { this.color = extractColorStyle(fillColor.color); } else if (fillColor.type.includes("GRADIENT")) { this.backgroundImage = extractGradientColorStyle( (fillColor as unknown) as ElementGradientColor ); } else if (fillColor.type === "SOLID") { this.background = extractColorStyle(fillColor.color); } } // borders if (node.strokes && node.strokes.length > 0) { this.borderColor = extractColorStyle( node.strokes[0].color as ElementColor ); this.border = `${node.strokeWeight}px solid ${this.borderColor}`; } // box-shadow if (node.effects && node.effects.length > 0) { const { offset, radius, color } = node.effects[0]; this.boxShadowColor = extractColorStyle(color as Figma.Color); this.boxShadow = `${offset?.x || 0}px ${offset?.y || 0}px 0 ${radius} ${ this.boxShadowColor }`; } } getStyles() { return [ this.height && { property: "height", value: this.height }, this.width && { property: "width", value: this.width }, this.fontFamily && { property: "font-family", value: this.fontFamily }, this.fontSize && { property: "font-size", value: this.fontSize }, this.fontWeight && { property: "font-weight", value: this.fontWeight }, this.lineHeight && { property: "line-height", value: this.lineHeight }, this.borderRadius && { property: "border-radius", value: this.borderRadius, }, this.backgroundImage && { property: "background-image", value: this.backgroundImage, }, this.boxShadow && { property: "box-shadow", value: this.boxShadow, color: this.boxShadowColor, }, this.border && { property: "border", value: this.border, color: this.borderColor, }, this.background && { property: "background", value: this.background, color: this.background, }, this.color && { property: "color", value: this.color, color: this.color }, ].filter(Boolean) as CSSRule[]; } getStyleSheet() { return this.getStyles().map(getStyleRule).join("\n"); } } const rgbToIntArray = (color: ElementColor) => [ Math.trunc(255 * color.r), Math.trunc(255 * color.g), Math.trunc(255 * color.b), ]; const rgbToHex = (color: ElementColor) => { const [r, g, b] = rgbToIntArray(color); return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); }; export const getStyleRule = ({ property, value }: CSSRule) => `${property}: ${value};`;
-
-
-
@@ -15,6 +15,8 @@ import { INodeSelectable, NodeSelectableMixin } from "./NodeSelectableMixin";import { Positioned, PositionedMixin } from "./PositionedMixin"; import * as DistanceGuide from "./DistanceGuide"; import * as InspectorView from "./InspectorView/InspectorView"; import type { FigmaNode } from "./InspectorView/utils"; import * as ErrorMessage from "./ErrorMessage"; import * as Node from "./Node";
-
@@ -97,6 +99,11 @@ overflow: hidden;z-index: var(--z-index); } .spec-canvas-wrapper { width: 100vw; height: 100vh; } .canvas { position: absolute; top: 50%;
-
@@ -122,6 +129,7 @@ `,Node.styles, ErrorMessage.styles, DistanceGuide.styles, InspectorView.styles, ]); }
-
@@ -136,10 +144,10 @@ #flattenedNodes?: readonly SizedNode[];constructor(...args: any[]) { super(...args); } this.addEventListener("click", () => { this.selectedNode = null; }); deselectNode() { this.selectedNode = null; } get error(): string | Error | null | TemplateResult | undefined {
-
@@ -188,57 +196,58 @@ getComputedStyle(this).getPropertyValue("--guide-tooltip-font-size")); return html` <div class="canvas" style=" <div class="spec-canvas-wrapper" @click=${this.deselectNode}> <div class="canvas" style=" width: ${canvasSize.width}px; height: ${canvasSize.height}px; transform: translate(-50%, -50%) ${this.canvasTransform.join(" ")} " > ${Object.entries(this.__images).map(([nodeId, uri]) => { const node = this.#getNodeById(nodeId); > ${Object.entries(this.__images).map(([nodeId, uri]) => { const node = this.#getNodeById(nodeId); if ( !node || !("absoluteBoundingBox" in node) || !this.#effectMargins?.[node.id] ) { return null; } if ( !node || !("absoluteBoundingBox" in node) || !this.#effectMargins?.[node.id] ) { return null; } const margin = this.#effectMargins[node.id]; const margin = this.#effectMargins[node.id]; return html` <img class="rendered-image" src="${uri}" style=${styleMap({ top: `${node.absoluteBoundingBox.y - canvasSize.y}px`, left: `${node.absoluteBoundingBox.x - canvasSize.x}px`, marginTop: `${-margin.top}px`, marginLeft: `${-margin.left}px`, width: node.absoluteBoundingBox.width + margin.left + margin.right + "px", height: node.absoluteBoundingBox.height + margin.top + margin.bottom + "px", })}" " /> `; })} ${this.selectedNode && Node.Tooltip({ nodeSize: this.selectedNode.absoluteBoundingBox, offsetX: -canvasSize.x, offsetY: -canvasSize.y, reverseScale, })} ${svg` return html` <img class="rendered-image" src="${uri}" style=${styleMap({ top: `${node.absoluteBoundingBox.y - canvasSize.y}px`, left: `${node.absoluteBoundingBox.x - canvasSize.x}px`, marginTop: `${-margin.top}px`, marginLeft: `${-margin.left}px`, width: node.absoluteBoundingBox.width + margin.left + margin.right + "px", height: node.absoluteBoundingBox.height + margin.top + margin.bottom + "px", })}" " /> `; })} ${this.selectedNode && Node.Tooltip({ nodeSize: this.selectedNode.absoluteBoundingBox, offsetX: -canvasSize.x, offsetY: -canvasSize.y, reverseScale, })} ${svg` <svg class="guides" viewBox="0 0 5 5"
-
@@ -285,6 +294,11 @@ `;})} </svg> `} </div> ${InspectorView.View({ node: this.selectedNode as FigmaNode, onClose: this.deselectNode, })} </div> `; }
-