Changes
6 changed files (+595/-404)
-
-
@@ -8,11 +8,17 @@ export interface OutlineProps {node: Extract<Figma.Node, { absoluteBoundingBox: any }>; computedThickness: number; selected?: boolean; onClick?(ev: MouseEvent): void; } export const Outline = ({ node, computedThickness, onClick }: OutlineProps) => { export const Outline = ({ node, selected = false, computedThickness, onClick, }: OutlineProps) => { const { x, y, width, height } = node.absoluteBoundingBox; const radius: {
-
@@ -80,6 +86,7 @@ d=${boxPath}shape-rendering="geometricPrecision" fill="none" transform="translate(${x}, ${y})" ?data-selected=${selected} @click=${onClick} /> `;
-
@@ -130,7 +137,7 @@ }.guide:hover { opacity: 1; } [data-selected] > .guide { .guide[data-selected] { opacity: 1; stroke: var(--guide-selected-color); }
-
-
-
@@ -0,0 +1,39 @@import type * as Figma from "figma-js"; import { LitElement, property } from "lit-element"; import type { Constructor, SizedNode } from "./utils"; export interface INodeSelectable { selectedNode: SizedNode | null; } export const NodeSelectableMixin = <T extends Constructor<LitElement>>( superClass: T ): T & Constructor<INodeSelectable> => { class NodeSelectable extends superClass { @property({ attribute: false, }) selectedNode: SizedNode | null = null; constructor(...args: any[]) { super(...args); } updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); if (changedProperties.has("selectedNode")) { this.dispatchEvent( new CustomEvent<{ selectedNode: Figma.Node | null }>("nodeselect", { detail: { selectedNode: this.selectedNode, }, }) ); } } } return NodeSelectable; };
-
-
-
@@ -9,12 +9,10 @@ scale: number;zoomSpeed: number; panSpeed: number; isMovable: boolean; readonly isMovable: boolean; readonly canvasTransform: readonly string[]; } /** * @property {number} panX */ export const PositionedMixin = <T extends Constructor<LitElement>>( superClass: T ): T & Constructor<Positioned> => {
-
@@ -48,6 +46,13 @@ panSpeed: number = 500;get isMovable() { return true; } get canvasTransform() { return [ `scale(${this.scale})`, `translate(${this.panX}px, ${this.panY}px)`, ]; } #isDragModeOn: boolean = false;
-
-
-
@@ -0,0 +1,478 @@import type * as Figma from "figma-js"; import { LitElement, css, html, svg, property, TemplateResult, } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; import { Constructor, extendStyles, SizedNode } from "./utils"; import { INodeSelectable, NodeSelectableMixin } from "./NodeSelectableMixin"; import { Positioned, PositionedMixin } from "./PositionedMixin"; import * as DistanceGuide from "./DistanceGuide"; import * as ErrorMessage from "./ErrorMessage"; import * as Node from "./Node"; interface Margin { top: number; right: number; bottom: number; left: number; } export interface IViewer { zoomMargin: number; /** * A record of rendered images. * Key is an id of the node. * Value is an URL of the rendered image. */ __images: Record<string, string>; readonly error?: string | TemplateResult | Error | null; updateTree(node: Figma.Node): void; } export const ViewerMixin = <T extends Constructor<LitElement>>( superClass: T ): T & Constructor<IViewer & INodeSelectable & Positioned> => { class Viewer extends NodeSelectableMixin(PositionedMixin(superClass)) { @property({ type: Number, attribute: "zoom-margin", }) zoomMargin: number = 50; static get styles() { // @ts-ignore const styles = super.styles; return extendStyles( styles, [ 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); --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 ); position: relative; display: block; background-color: var(--bg); user-select: none; overflow: hidden; z-index: var(--z-index); } .canvas { position: absolute; top: 50%; left: 50%; } .rendered-image { position: absolute; top: 0; left: 0; } .guides { position: absolute; overflow: visible; stroke: var(--guide-color); fill: var(--guide-color); pointer-events: none; z-index: calc(var(--z-index) + 2); } `, Node.styles, ErrorMessage.styles, DistanceGuide.styles, ] ); } get __images(): Record<string, string> { return {}; } // Cached values #canvasSize?: Figma.Rect; #canvasMargin?: Margin; #flattenedNodes?: readonly SizedNode[]; constructor(...args: any[]) { super(...args); this.addEventListener("click", () => { this.selectedNode = null; }); } get error(): string | Error | null | TemplateResult | undefined { if (!this.#canvasSize || !this.#canvasMargin || !this.#flattenedNodes) { return ErrorMessage.ErrorMessage({ title: "Error", children: "Please call `updateTree/1` method with a valid parameter.", }); } return null; } render() { if (this.error) { if (this.error instanceof Error) { return ErrorMessage.ErrorMessage({ title: this.error.name || "Error", children: this.error.message, }); } if (typeof this.error === "string") { return ErrorMessage.ErrorMessage({ title: "Error", children: this.error, }); } return this.error; } const margin = this.#canvasMargin!; const canvasSize = this.#canvasSize!; const reverseScale = 1 / this.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" 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); if (!node) { return null; } return html` <img class="rendered-image" src="${uri}" style=" margin-top: ${-margin.top}px; margin-left: ${-margin.left}px; width: ${canvasSize.width + margin.left + margin.right}px; height: ${canvasSize.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" width="5" height="5" style=${styleMap({ left: `${-canvasSize.x}px`, top: `${-canvasSize.y}px`, strokeWidth: guideThickness, })} > ${ this.selectedNode && Node.Outline({ node: this.selectedNode, selected: true, computedThickness: computedGuideThickness * reverseScale, }) } ${this.#flattenedNodes!.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> `; } connectedCallback() { super.connectedCallback(); this.#resetZoom(); } updateTree(node: Figma.Node) { if ( !( node.type === "CANVAS" || node.type === "FRAME" || node.type === "COMPONENT" ) ) { throw new Error( "Cannot update node tree: Top level node MUST be one of CANVAS, FRAME, or COMPONENT" ); } this.#canvasSize = node.type === "CANVAS" ? getCanvasSize(node) : node.absoluteBoundingBox; this.#flattenedNodes = flattenNode(node); this.#canvasMargin = getCanvasMargin( this.#canvasSize, this.#flattenedNodes ); // Since above properties aren't "attribute", their changes does not // trigger an update. We need to manually request an update. this.requestUpdate(); } #handleNodeClick = (node: SizedNode) => (ev: MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); this.selectedNode = node; }; #resetZoom = () => { if (this.#canvasSize) { // Set initial zoom level based on element size const { width, height } = this.#canvasSize; const { width: elementWidth, height: elementHeight, } = this.getBoundingClientRect(); const wDiff = elementWidth / (width + this.zoomMargin * 2); const hDiff = elementHeight / (height + this.zoomMargin * 2); this.scale = Math.min(wDiff, hDiff, 1); } }; #getNodeById = (id: string): Figma.Node | null => { return this.#flattenedNodes?.find((n) => n.id === id) ?? null; }; } return Viewer; }; function getCanvasSize(node: Figma.Canvas): Figma.Rect { const left: number[] = []; const right: number[] = []; const top: number[] = []; const bottom: number[] = []; for (const child of node.children) { if (child.type !== "FRAME" && child.type !== "COMPONENT") { continue; } const { x, y, width, height } = child.absoluteBoundingBox; left.push(x); right.push(x + width); top.push(y); bottom.push(y + height); } const minX = Math.min(...left); const minY = Math.min(...top); return { x: minX, y: minY, width: Math.abs(Math.max(...right) - minX), height: Math.abs(Math.min(...bottom) - minY), }; } function getCanvasMargin( canvasSize: Figma.Rect, nodes: readonly SizedNode[] ): Margin { const points = nodes.map((node) => { if (!("effects" in node)) { return { top: node.absoluteBoundingBox.y, right: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width, bottom: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height, left: node.absoluteBoundingBox.x, }; } const blurRadiuses = node.effects .filter((effect) => effect.visible && effect.type === "LAYER_BLUR") .map((effect) => effect.radius); const shadowMargins = node.effects .filter( ( effect ): effect is Figma.Effect & { offset: NonNullable<Figma.Effect["offset"]>; } => effect.visible && effect.type === "DROP_SHADOW" && !!effect.offset ) .map<Margin>((effect) => { return { left: effect.radius - effect.offset.x, top: effect.radius - effect.offset.y, right: effect.radius + effect.offset.x, bottom: effect.radius + effect.offset.y, }; }); const margin: Margin = { top: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.top) ), right: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.right) ), bottom: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.bottom) ), left: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.left) ), }; return { top: node.absoluteBoundingBox.y - margin.top, right: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width + margin.right, bottom: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height + margin.bottom, left: node.absoluteBoundingBox.x - margin.left, }; }); const bounds = { top: Math.min(...points.map((p) => p.top)), right: Math.max(...points.map((p) => p.right)), bottom: Math.max(...points.map((p) => p.bottom)), left: Math.min(...points.map((p) => p.left)), }; return { top: canvasSize.y - bounds.top, right: bounds.right - canvasSize.x - canvasSize.width, bottom: bounds.bottom - canvasSize.y - canvasSize.height, left: canvasSize.x - bounds.left, }; } function flattenNode( node: Figma.Node, depth: number = 0 ): readonly (SizedNode & { depth: number; })[] { if (!("absoluteBoundingBox" in node)) { return node.children.map((child) => flattenNode(child, depth + 1)).flat(); } if (!("children" in node) || node.children.length === 0) { return [{ ...node, depth }]; } return [ { ...node, depth }, ...node.children.map((child) => flattenNode(child, depth + 1)).flat(), ]; }
-
-
-
@@ -1,22 +1,14 @@import type * as Figma from "figma-js"; import { LitElement, TemplateResult, css, html, property, svg, } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; import { LitElement, html, property } from "lit-element"; import * as DistanceGuide from "./DistanceGuide"; import * as ErrorMessage from "./ErrorMessage"; import * as Node from "./Node"; import { PositionedMixin } from "./PositionedMixin"; import { ViewerMixin } from "./ViewerMixin"; type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>; import { extendStyles, SizedNode } from "./utils"; // TODO: Move docs for props in mixins (waiting for support at web-component-analyzer) /**
-
@@ -45,10 +37,19 @@ * This does not affect to dragging with middle button pressed.* Available values: 1 ~ 1000. * @attr [pan-speed=500] See docs for `panSpeed` property. * * @property {Figma.Node | null} [selectedNode=null] * Current selected node. * * @property {number} [zoomMargin=50] * The minimum margin for the preview canvas in px. Will be used when the preview * setting a default zooming scale for the canvas. * @attr [zoom-margin=50] See docs for `zoomMargin` property. * * @fires scalechange When a user zoom-in or zoom-out the preview. * @fires positionchange When a user panned the preview. * @fires nodeselect When a user selected / unselected a node. */ export class FigspecViewer extends PositionedMixin(LitElement) { export class FigspecViewer extends ViewerMixin(LitElement) { /** * A response of "GET file nodes" API. * https://www.figma.com/developers/api#get-file-nodes-endpoint
-
@@ -68,107 +69,6 @@ attribute: "rendered-image",}) renderedImage: string | null = null; /** * Current selected node. */ @property({ attribute: false, }) selectedNode: SizedNode | null = null; /** * The minimum margin for the preview canvas. Will be used when the preview * setting a default zooming scale for the canvas. */ @property({ type: Number, attribute: "zoom-margin", }) zoomMargin: number = 50; // Computed values. In order to avoid computing each time scale/pan, we // compute these values only when the source attributes has changed. #flattenedNodes?: ReturnType<typeof flattenNode>; #canvasMargin?: ReturnType<typeof getCanvasMargin>; constructor() { super(); this.addEventListener("click", () => { this.selectedNode = null; }); } static get styles() { 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); --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 ); position: relative; display: block; background-color: var(--bg); user-select: none; overflow: hidden; z-index: var(--z-index); } .canvas { position: absolute; top: 50%; left: 50%; } .rendered-image { position: absolute; top: 0; left: 0; } .guides { position: absolute; overflow: visible; stroke: var(--guide-color); fill: var(--guide-color); pointer-events: none; z-index: calc(var(--z-index) + 2); } `, Node.styles, ErrorMessage.styles, DistanceGuide.styles, ]; } /** @private */ get isMovable(): boolean { return !!(this.nodes && this.renderedImage && this.documentNode);
-
@@ -192,317 +92,57 @@return documentNode.document; } /** * @private */ get parameterError(): TemplateResult | null { if (!this.nodes || !this.renderedImage) { return html`Both <code>nodes</code> and <code>rendered-image</code> are required.`; /** @private */ get __images() { if (!this.documentNode || !this.renderedImage) { return {}; } if (!this.documentNode) { return html`Document node is empty or does not have size.`; } return null; return { [this.documentNode.id]: this.renderedImage, }; } render() { if (this.parameterError) { /** @private */ get error() { if (!this.nodes || !this.renderedImage) { return ErrorMessage.ErrorMessage({ title: "Parameter error", children: this.parameterError, children: html`<span> Both <code>nodes</code> and <code>rendered-image</code> are required. </span>`, }); } if (!this.#flattenedNodes || !this.#canvasMargin) { if (!this.documentNode) { return ErrorMessage.ErrorMessage({ title: "Computation Error", children: "Failed to calculate based on given inputs.", title: "Parameter Error", children: html` <span> Document node is empty or does not have size. </span> `, }); } const documentNode = this.documentNode as SizedNode; const margin = this.#canvasMargin; 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" style=" width: ${canvasSize.width}px; height: ${canvasSize.height}px; transform: translate(-50%, -50%) scale(${scale}) translate(${panX}px, ${panY}px); " > <img class="rendered-image" src="${this.renderedImage}" style=" margin-top: ${-margin.top}px; margin-left: ${-margin.left}px; width: ${canvasSize.width + margin.left + margin.right}px; height: ${canvasSize.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" 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> ` } ${this.#flattenedNodes.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> `; if (super.error) { return super.error; } } connectedCallback() { super.connectedCallback(); this.#resetZoom(); if (this.documentNode) { this.updateTree(this.documentNode); } } updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); // Flatten a node tree and calculate outermost boundary rect, // then save these result. if (changedProperties.has("nodes")) { if (!this.documentNode) return; this.#flattenedNodes = flattenNode(this.documentNode); this.#canvasMargin = getCanvasMargin( this.documentNode, this.#flattenedNodes ); // Since above properties aren't "attribute", their changes does not // trigger an update. We need to manually request an update. this.requestUpdate(); } // Dispatch "nodeselect" event. if (changedProperties.has("selectedNode")) { /** * When a user selected / unselected a node. */ this.dispatchEvent( new CustomEvent<{ selectedNode: Figma.Node | null }>("nodeselect", { detail: { selectedNode: this.selectedNode, }, }) ); } } #handleNodeClick = (node: SizedNode) => (ev: MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); this.selectedNode = node; }; #resetZoom = () => { if (this.documentNode) { // Set initial zoom level based on element size const { width, height } = this.documentNode.absoluteBoundingBox; const { width: elementWidth, height: elementHeight, } = this.getBoundingClientRect(); const wDiff = elementWidth / (width + this.zoomMargin * 2); const hDiff = elementHeight / (height + this.zoomMargin * 2); this.scale = Math.min(wDiff, hDiff, 1); } }; } interface Margin { top: number; right: number; bottom: number; left: number; } function getCanvasMargin( documentNode: SizedNode, nodes: readonly SizedNode[] ): Margin { const points = nodes.map((node) => { if (!("effects" in node)) { return { top: node.absoluteBoundingBox.y, right: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width, bottom: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height, left: node.absoluteBoundingBox.x, }; this.updateTree(this.documentNode); } const blurRadiuses = node.effects .filter((effect) => effect.visible && effect.type === "LAYER_BLUR") .map((effect) => effect.radius); const shadowMargins = node.effects .filter( ( effect ): effect is Figma.Effect & { offset: NonNullable<Figma.Effect["offset"]>; } => effect.visible && effect.type === "DROP_SHADOW" && !!effect.offset ) .map<Margin>((effect) => { return { left: effect.radius - effect.offset.x, top: effect.radius - effect.offset.y, right: effect.radius + effect.offset.x, bottom: effect.radius + effect.offset.y, }; }); const margin: Margin = { top: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.top) ), right: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.right) ), bottom: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.bottom) ), left: Math.max( 0, ...blurRadiuses, ...shadowMargins.map((margin) => margin.left) ), }; return { top: node.absoluteBoundingBox.y - margin.top, right: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width + margin.right, bottom: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height + margin.bottom, left: node.absoluteBoundingBox.x - margin.left, }; }); const bounds = { top: Math.min(...points.map((p) => p.top)), right: Math.max(...points.map((p) => p.right)), bottom: Math.max(...points.map((p) => p.bottom)), left: Math.min(...points.map((p) => p.left)), }; return { top: documentNode.absoluteBoundingBox.y - bounds.top, right: bounds.right - documentNode.absoluteBoundingBox.x - documentNode.absoluteBoundingBox.width, bottom: bounds.bottom - documentNode.absoluteBoundingBox.y - documentNode.absoluteBoundingBox.height, left: documentNode.absoluteBoundingBox.x - bounds.left, }; } function flattenNode( node: Figma.Node, depth: number = 0 ): readonly (SizedNode & { depth: number; })[] { if (!("absoluteBoundingBox" in node)) { return node.children.map((child) => flattenNode(child, depth + 1)).flat(); } if (!("children" in node) || node.children.length === 0) { return [{ ...node, depth }]; } return [ { ...node, depth }, ...node.children.map((child) => flattenNode(child, depth + 1)).flat(), ]; }
-
-
-
@@ -1,4 +1,7 @@import * as Figma from "figma-js"; import { CSSResultArray, LitElement } from "lit-element"; export type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>; export interface Point2D { x: number;
-
@@ -167,3 +170,22 @@ * // ...* } */ export type Constructor<T> = new (...args: any[]) => T; export function extendStyles( left: typeof LitElement.styles, right: typeof LitElement.styles ): CSSResultArray { return [...stylesToArray(left), ...stylesToArray(right)]; } function stylesToArray(styles: typeof LitElement.styles): CSSResultArray { if (!styles) { return []; } if (styles instanceof Array) { return styles; } return [styles]; }
-