Changes
8 changed files (+399/-338)
-
-
@@ -1,40 +0,0 @@import { css, html, svg } from "lit-element"; import { styleMap, StyleInfo } from "lit-html/directives/style-map"; import { DistanceGuide as TDistanceGuide, Point2D, round } from "./utils"; export interface DistaneGuideProps { guide: TDistanceGuide; reverseScale: number; } export const DistanceGuide = ({ guide, reverseScale }: DistaneGuideProps) => { return svg` <line x1=${guide.points[0].x} y1=${guide.points[0].y} x2=${guide.points[1].x} y2=${guide.points[1].y} shape-rendering="geometricPrecision" /> ${ guide.bisector && svg` <line x1=${guide.bisector[0].x} y1=${guide.bisector[0].y} x2=${guide.bisector[1].x} y2=${guide.bisector[1].y} style=${styleMap({ strokeDasharray: `${4 * reverseScale}`, })} shape-rendering="geometricPrecision" /> ` } `; }; export const styles = css``;
-
-
-
@@ -1,27 +1,19 @@import * as Figma from "figma-js"; import { css, svg } from "lit-element"; import { css, html, svg } from "lit-element"; import { styleMap, StyleInfo } from "lit-html/directives/style-map"; import { round } from "./utils"; export interface OutlineProps { node: Extract<Figma.Node, { absoluteBoundingBox: any }>; computedThickness: number; style?: StyleInfo; onClick?(ev: MouseEvent): void; } export const Outline = ({ node, computedThickness, style = {}, }: OutlineProps) => { const { width, height } = node.absoluteBoundingBox; const guideStyle: StyleInfo = { width: `${width}px`, height: `${height}px`, ...style, }; export const Outline = ({ node, computedThickness, onClick }: OutlineProps) => { const { x, y, width, height } = node.absoluteBoundingBox; const radius: { topLeft: number;
-
@@ -82,26 +74,47 @@ "Z",].join(" "); return svg` <svg <path class="guide" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" d=${boxPath} shape-rendering="geometricPrecision" fill="none" style="${styleMap(guideStyle)}" > <path d=${boxPath} shape-rendering="geometricPrecision" /> </svg> transform="translate(${x}, ${y})" @click=${onClick} /> `; }; export interface TooltipProps { nodeSize: Figma.Rect; offsetX: number; offsetY: number; reverseScale: number; } export const Tooltip = ({ nodeSize: { x, y, width, height }, offsetX, offsetY, reverseScale, }: TooltipProps) => { const tooltipStyle: StyleInfo = { top: `${offsetY + y + height}px`, left: `${offsetX + x + width / 2}px`, transform: `translateX(-50%) scale(${reverseScale}) translateY(0.25em)`, }; return html` <div class="tooltip" style="${styleMap(tooltipStyle)}"> ${round(width)} x ${round(height)} </div> `; }; export const styles = css` .guide { position: absolute; stroke: none; /* * SVGs cannot be pixel perfect, especially floating values. * Since many platform renders them visually incorrectly (probably they
-
@@ -109,11 +122,30 @@ * are following the spec), it's safe to set overflow to visible.* Cropped borders are hard to visible and ugly. */ overflow: visible; pointer-events: all; opacity: 0; } .guide:hover { stroke: var(--color); opacity: 1; } :host([selected]) > .guide { stroke: var(--selected-color); [data-selected] > .guide { opacity: 1; stroke: var(--guide-selected-color); } .tooltip { position: absolute; padding: 0.25em 0.5em; font-size: var(--guide-tooltip-font-size); color: var(--guide-selected-tooltip-fg); background-color: var(--guide-selected-tooltip-bg); border-radius: 2px; pointer-events: none; z-index: calc(var(--z-index) + 1); transform-origin: top center; } `;
-
-
-
@@ -1,199 +0,0 @@import { LitElement, css, html, svg, property } from "lit-element"; import { styleMap, StyleInfo } from "lit-html/directives/style-map"; import * as Figma from "figma-js"; import * as DistanceGuide from "./DistanceGuide"; import * as Outline from "./Outline"; import { getDistanceGuides, round } from "./utils"; type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>; export class FigmaViewerGuide extends LitElement { @property({ type: Object, }) node: SizedNode | null = null; @property({ type: Number, }) level: number = 0; @property({ attribute: "offset-x", type: Number, }) offsetX: number = 0; @property({ attribute: "offset-y", type: Number, }) offsetY: number = 0; @property({ type: Boolean, }) selected: boolean = false; @property({ attribute: "distance-to", type: Object, }) distanceTo: SizedNode | null = null; @property({ type: Number, }) scale: number = 1; static get styles() { return [ css` :host { --thickness: var(--figma-viewer-guide-border-thickness, 1.5px); --color: var(--figma-viewer-guide-color, tomato); --selected-color: var( --figma-viewer-guide-selected-color, dodgerblue ); --fg: var(--figma-viewer-guide-font-color, white); --z-index: var(--figma-viewer-z-index, 1); --tooltip-z-index: var(--figma-viewer-tooltip-z-index, 9999); --tooltip-font-size: var(--figma-viewer-tooltip-font-size, 12px); } .tooltip { display: none; position: absolute; padding: 0.25em 0.5em; font-size: var(--tooltip-font-size); color: var(--fg); background-color: var(--selected-color); border-radius: 2px; pointer-events: none; z-index: var(--tooltip-z-index); transform-origin: top center; } :host([selected]) > .tooltip { display: block; } .distance-guide-container { position: absolute; overflow: visible; stroke: none; } .guide:hover ~ .distance-guide-container { stroke: var(--color); } .guide:hover ~ .distance-guide-container ~ .tooltip { display: block; } .distance-tooltip { background-color: var(--color); z-index: calc(var(--tooltip-z-index) + 1); } `, Outline.styles, DistanceGuide.styles, ]; } render() { if (!this.node) { return html``; } const { x, y, width, height } = this.node.absoluteBoundingBox; const reverseScale = 1 / this.scale; const thickness = `calc(var(--thickness) * ${reverseScale})`; const distanceGuides = this.distanceTo ? getDistanceGuides( this.distanceTo.absoluteBoundingBox, this.node.absoluteBoundingBox ) : []; const tooltipStyle: StyleInfo = { top: `${this.offsetY + y + height}px`, left: `${this.offsetX + x + width / 2}px`, transform: `translateX(-50%) scale(${reverseScale}) translateY(0.25em)`, }; return html` ${Outline.Outline({ node: this.node, computedThickness: parseFloat(getComputedStyle(this).getPropertyValue("--thickness")) * reverseScale, style: { left: `${x + this.offsetX}px`, top: `${y + this.offsetY}px`, zIndex: `calc(var(--z-index) + ${this.level})`, strokeWidth: thickness, }, })} <div class="tooltip" style="${styleMap(tooltipStyle)}"> ${round(width)} x ${round(height)} </div> ${svg` <svg class="distance-guide-container" viewBox="0 0 5 5" width="5" height="5" style=${styleMap({ left: `${this.offsetX}px`, top: `${this.offsetY}px`, strokeWidth: thickness, })} > ${distanceGuides.map((guide) => DistanceGuide.DistanceGuide({ guide, reverseScale, }) )} </svg> `} ${distanceGuides.map((guide) => { const xLength = Math.abs(guide.points[0].x - guide.points[1].x); const yLength = Math.abs(guide.points[0].y - guide.points[1].y); const style: StyleInfo = xLength > yLength ? { top: `${guide.points[0].y + this.offsetY}px`, left: `${ (guide.points[0].x + guide.points[1].x) / 2 + this.offsetX }px`, transform: `translateX(-50%) scale(${reverseScale}) translateY(0.3em)`, } : { top: `${ (guide.points[0].y + guide.points[1].y) / 2 + this.offsetY }px`, left: `${guide.points[0].x + this.offsetX}px`, transform: `translateY(-50%) scale(${reverseScale}) translateX(0.3em)`, transformOrigin: "center left", }; return html` <div class="tooltip distance-tooltip" style=${styleMap(style)}> ${round(Math.max(xLength, yLength))} </div> `; })} `; } }
-
-
-
@@ -123,7 +123,6 @@ bisector: !isYIntersecting? [ { x: isALeft ? b.right : b.left, y: selectedCenter.y }, { // These 0.5 makes the guides looks aligned with the outlines. x: isALeft ? b.right : b.left, y: isABelow ? b.bottom : b.top, },
-
@@ -141,7 +140,6 @@ bisector: !isXIntersecting? [ { y: isABelow ? b.bottom : b.top, x: selectedCenter.x }, { // These 0.5 makes the guides looks aligned with the outlines. y: isABelow ? b.bottom : b.top, x: isALeft ? b.right : b.left, },
-
-
-
@@ -0,0 +1,171 @@import { css, svg } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; import { DistanceGuide, getDistanceGuides, round } from "./utils"; import type { SizedNode } from "./types"; interface LineProps { guide: DistanceGuide; reverseScale: number; } const Line = ({ guide, reverseScale }: LineProps) => { const xLength = Math.abs(guide.points[0].x - guide.points[1].x); const yLength = Math.abs(guide.points[0].y - guide.points[1].y); if (xLength === 0 && yLength === 0) { return null; } return svg` <line class="distance-line" x1=${guide.points[0].x} y1=${guide.points[0].y} x2=${guide.points[1].x} y2=${guide.points[1].y} /> ${ guide.bisector && svg` <line class="distance-line" x1=${guide.bisector[0].x} y1=${guide.bisector[0].y} x2=${guide.bisector[1].x} y2=${guide.bisector[1].y} style=${styleMap({ strokeDasharray: `${4 * reverseScale}`, })} shape-rendering="geometricPrecision" fill="none" /> ` } `; }; interface TooltipProps { guide: DistanceGuide; reverseScale: number; fontSize: number; } const Tooltip = ({ guide, reverseScale, fontSize }: TooltipProps) => { const xLength = Math.abs(guide.points[0].x - guide.points[1].x); const yLength = Math.abs(guide.points[0].y - guide.points[1].y); if (xLength === 0 && yLength === 0) { return null; } const text = round(Math.max(xLength, yLength)).toString(10); // Decreases font width because every text is a number (narrow). // We can measure the correct width with getComputedTextLength method on // <text> element, but it needs access to DOM or creating an element each // render cycle, both have performance costs. const width = text.length * fontSize * 0.5; const startMargin = fontSize * 0.25; const vPadding = fontSize * 0.25; const hPadding = fontSize * 0.5; const x = xLength > yLength ? (guide.points[0].x + guide.points[1].x) / 2 - width / 2 : guide.points[0].x; const y = xLength > yLength ? guide.points[0].y : (guide.points[0].y + guide.points[1].y) / 2 - fontSize / 2; const transform = [ `scale(${reverseScale})`, xLength > yLength ? `translate(0, ${startMargin + vPadding})` : `translate(${startMargin + hPadding}, 0)`, ].join(" "); const cx = x + width / 2; const cy = y + fontSize / 2; const transformOrigin = xLength > yLength ? `${cx} ${y}` : `${x} ${cy}`; return svg` <g class="distance-tooltip"> <rect x=${x - hPadding} y=${y - vPadding} rx="2" width=${width + hPadding * 2} height=${fontSize + vPadding * 2} transform=${transform} transform-origin=${transformOrigin} stroke="none" /> <text x=${cx} y=${y + fontSize - vPadding / 2} text-anchor="middle" transform=${transform} transform-origin=${transformOrigin} stroke="none" fill="white" style="font-size: ${fontSize}px" > ${text} </text> </g> `; }; export interface GuidesProps { node: SizedNode; distanceTo: SizedNode; reverseScale: number; fontSize: number; } export const Guides = ({ node, distanceTo, reverseScale, fontSize, }: GuidesProps) => { const guides = getDistanceGuides( node.absoluteBoundingBox, distanceTo.absoluteBoundingBox ); return [ ...guides.map((guide) => Line({ guide, reverseScale })), ...guides.map((guide) => Tooltip({ guide, reverseScale, fontSize })), ]; }; export const styles = css` .distance-line { shape-rendering: geometricPrecision; fill: none; opacity: 0; } .distance-tooltip { opacity: 0; } .guide:hover ~ .distance-line, .guide:hover ~ .distance-tooltip { opacity: 1; } `;
-
-
-
@@ -1,6 +1,17 @@import type * as Figma from "figma-js"; import { LitElement, TemplateResult, css, html, property } from "lit-element"; import { LitElement, TemplateResult, css, html, property, svg, } from "lit-element"; import { styleMap, StyleInfo } from "lit-html/directives/style-map"; import * as DistanceGuide from "./DistanceGuide"; import * as Node from "./Node"; type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>;
-
@@ -147,61 +158,99 @@ });} static get styles() { return css` :host { --bg: var(--figma-viewer-bg, #666); --z-index: var(--figma-viewer-z-index, 0); --error-bg: var(--figma-viewer-error-bg, #870909); --error-fg: var(--figma-viewer-error-fg, white); return [ css` :host { --bg: var(--figspec-viewer-bg, #666); --z-index: var(--figspec-viewer-z-index, 0); --error-bg: var(--figspec-viewer-error-bg, #870909); --error-fg: var(--figspec-viewer-error-fg, white); position: relative; display: block; --guide-thickness: var(--figspec-viewer-guide-thickness, 1.5px); --guide-color: var(--figspec-viewer-guide-color, tomato); --guide-selected-color: var( --figspec-viewer-guide-selected-color, dodgerblue ); --guide-tooltip-fg: var(--figspec-viewer-guide-tooltip-fg, white); --guide-selected-tooltip-fg: var( --figspec-viewer-guide-selected-tooltip-fg, white ); --guide-tooltip-bg: var( --figspec-viewer-guide-tooltip-bg, var(--guide-color) ); --guide-selected-tooltip-bg: var( --figspec-viewer-guide-selected-tooltip-bg, var(--guide-selected-color) ); --guide-tooltip-font-size: var( --figspec-viewer-guide-tooltip-font-size, 12px ); background-color: var(--bg); user-select: none; overflow: hidden; z-index: var(--z-index); } position: relative; display: block; .canvas { position: absolute; top: 50%; left: 50%; } background-color: var(--bg); user-select: none; overflow: hidden; z-index: var(--z-index); } .rendered-image { position: absolute; top: 0; left: 0; } .canvas { position: absolute; top: 50%; left: 50%; } .error { position: absolute; top: 50%; left: 50%; max-width: 80%; padding: 0.75em 1em; .rendered-image { position: absolute; top: 0; left: 0; } background-color: var(--error-bg); border-radius: 4px; color: var(--error-fg); .error { position: absolute; top: 50%; left: 50%; max-width: 80%; padding: 0.75em 1em; background-color: var(--error-bg); border-radius: 4px; color: var(--error-fg); transform: translate(-50%, -50%); } transform: translate(-50%, -50%); } .error-title { display: block; font-size: 0.8em; font-weight: bold; text-transform: capitalize; } .error-title { display: block; font-size: 0.8em; .error-description { display: block; margin-block-start: 0.5em; } font-weight: bold; text-transform: capitalize; } .guides { position: absolute; .error-description { display: block; margin-block-start: 0.5em; } `; overflow: visible; stroke: var(--guide-color); fill: var(--guide-color); pointer-events: none; z-index: calc(var(--z-index) + 2); } `, Node.styles, DistanceGuide.styles, ]; } get documentNode(): SizedNode | null {
-
@@ -251,6 +300,18 @@ const canvasSize = documentNode.absoluteBoundingBox;const { scale, panX, panY } = this; const reverseScale = 1 / scale; const guideThickness = `calc(var(--guide-thickness) * ${reverseScale})`; const computedGuideThickness = parseFloat( getComputedStyle(this).getPropertyValue("--guide-thickness") ); const computedGuideTooltipFontSize = parseFloat( getComputedStyle(this).getPropertyValue("--guide-tooltip-font-size") ); return html` <div class="canvas"
-
@@ -271,23 +332,63 @@ width: ${canvasSize.width + margin.left + margin.right}px;height: ${canvasSize.height + margin.top + margin.bottom}px; " /> ${nodes.map((node) => { return html` <figma-viewer-guide .node=${node} .distanceTo=${node.id !== this.selectedNode?.id ? this.selectedNode : null} offset-x="${-canvasSize.x}" offset-y="${-canvasSize.y}" level="${node.depth}" scale="${this.scale}" ?selected=${node.id === this.selectedNode?.id} @click=${this.#handleNodeClick(node)} > </figma-viewer-guide> `; ${this.selectedNode && Node.Tooltip({ nodeSize: this.selectedNode.absoluteBoundingBox, offsetX: -canvasSize.x, offsetY: -canvasSize.y, reverseScale, })} ${svg` <svg class="guides" viewBox="0 0 5 5" width="5" height="5" style=${styleMap({ left: `${-canvasSize.x}px`, top: `${-canvasSize.y}px`, strokeWidth: guideThickness, })} > ${ this.selectedNode && svg` <g data-selected> ${Node.Outline({ node: this.selectedNode, computedThickness: computedGuideThickness * reverseScale, })} </g> ` } ${nodes.map((node) => { if (node.id === this.selectedNode?.id) { return null; } return svg` <g> ${Node.Outline({ node, computedThickness: computedGuideThickness * reverseScale, onClick: this.#handleNodeClick(node), })} ${ this.selectedNode && DistanceGuide.Guides({ node, distanceTo: this.selectedNode, reverseScale, fontSize: computedGuideTooltipFontSize, }) } </g> `; })} </svg> `} </div> `; }
-
-
-
@@ -0,0 +1,3 @@import * as Figma from "figma-js"; export type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>;
-
-
-
@@ -1,12 +1,7 @@import { FigspecViewer } from "./FigspecViewer"; import { FigmaViewerGuide } from "./FigmaViewerGuide"; if (!customElements.get("figspec-viewer")) { customElements.define("figspec-viewer", FigspecViewer); } if (!customElements.get("figma-viewer-guide")) { customElements.define("figma-viewer-guide", FigmaViewerGuide); } export { FigspecViewer } from "./FigspecViewer";
-