Changes
79 changed files (+7720/-2785)
-
-
@@ -22,3 +22,5 @@ - name: Install dependenciesrun: yarn install --immutable - name: Check files are formatted with Prettier run: yarn prettier --check './**/src/**/*.{js,jsx,ts,tsx,css,html,md}' - name: Run unit tests run: yarn test
-
-
-
@@ -25,9 +25,6 @@ "sideEffects": ["./src/index.ts", "./{cjs,esm}/*/index.js" ], "dependencies": { "lit": "^2.1.3" }, "devDependencies": { "@picocss/pico": "^1.5.10", "commander": "^6.1.0",
-
@@ -37,19 +34,21 @@ "glob": "^10.3.2","husky": "^8.0.1", "lint-staged": "^13.0.3", "node-fetch": "^2.6.1", "prettier": "^2.1.2", "typescript": "^4.7.4", "vite": "^4.4.2" "prettier": "^3.0.2", "typescript": "^5.1.6", "vite": "^4.4.2", "vitest": "^0.34.2" }, "scripts": { "build:esm": "tsc --outDir esm/es2015", "build:cjs": "tsc --outDir cjs/es2016 --target es2016 --module CommonJS", "build:esm": "tsc -p tsconfig.build.json --outDir esm/es2015", "build:cjs": "tsc -p tsconfig.build.json --outDir cjs/es2016 --target es2016 --module CommonJS", "build": "yarn build:esm && yarn build:cjs", "prepublishOnly": "yarn build", "generate-static-figma-file": "node ./scripts/generateStaticFigmaFile/index.mjs", "postinstallDev": "husky install", "dev": "vite ./website", "build:website": "vite build ./website" "build:website": "vite build ./website", "test": "vitest" }, "lint-staged": { "*.{js,jsx,ts,tsx,css,html,md,yml,json}": [
-
-
-
@@ -26,12 +26,12 @@ const res = await fetch(image);if (res.status !== 200) { throw new Error( `Failed to fetch a rendered image: node-id=${nodeId}, url=${image}` `Failed to fetch a rendered image: node-id=${nodeId}, url=${image}`, ); } return res.buffer(); }) }), ); if (!image) {
-
-
-
@@ -12,7 +12,7 @@ import { fetchNode } from "./fetchNode.mjs";const isFigmaURL = (url) => /https:\/\/([w.-]+.)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/.test( url url, ); program
-
@@ -87,7 +87,7 @@ const outDir = path.resolve(path.isAbsolute(program.outDir) ? program.outDir : path.resolve(process.env.INIT_CWD, program.outDir), fileKey fileKey, ); try {
-
@@ -113,14 +113,14 @@ // See <https://github.com/pocka/figspec/issues/27> for the context.const safeFilename = file.filename.replace(/:/g, "-"); await fs.writeFile(path.resolve(outDir, safeFilename), file.data); }) }), ); } dotenv.config({ path: path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../.env" "../../.env", ), }); program.parse();
-
-
src/FigspecFileViewer.ts (new)
-
@@ -0,0 +1,295 @@import { attr, el } from "./dom"; import * as figma from "./figma"; import { FrameCanvas } from "./FrameCanvas/FrameCanvas"; import { defaultPreferenecs, isEqual as isEqualPreferences, type Preferences, } from "./preferences"; import { compute, effect, Signal } from "./signal"; import { styles } from "./styles"; import * as state from "./state"; import { ui } from "./ui/ui"; import { infoItems } from "./ui/infoItems/infoItems"; import { selectBox } from "./ui/selectBox/selectBox"; export class FigspecFileViewer extends HTMLElement { #link = new Signal<string | null>(null); #resp = new Signal<figma.GetFileResponse | null>(null); #images = new Signal<Map<string, string> | null>(null); #canvases = compute<Map<string, figma.Canvas>>(() => { const map = new Map<string, figma.Canvas>(); const resp = this.#resp.get(); if (!resp) { return map; } for (const canvas of figma.getCanvases(resp.document)) { map.set(canvas.id, canvas); } return map; }); #selectedCanvasId = new Signal<string | null>(null); #state = compute< state.State< [figma.GetFileResponse, Map<string, string>, Map<string, figma.Canvas>] > >(() => { const resp = this.#resp.get(); const images = this.#images.get(); if (!resp && !images) { return state.idle; } if (!images) { return state.setupError(new Error("Rendered image set is required")); } if (!resp) { return state.setupError( new Error("Returned result of Get File API is required"), ); } const canvases = this.#canvases.get(); if (!canvases.size) { return state.setupError(new Error("No node has type=CANVAS.")); } return state.loaded([resp, images, canvases]); }); #givenPreferences: Readonly<Preferences> = { ...defaultPreferenecs }; #preferences = new Signal<Preferences>({ ...this.#givenPreferences }); set preferences(value: Readonly<Preferences>) { this.#givenPreferences = value; this.#preferences.set({ ...value }); } get preferences(): Readonly<Preferences> { return this.#preferences.once(); } #ui = ui({ caller: "file", state: this.#state, preferences: this.#preferences, infoContents: ([resp, , canvases]) => { return infoItems([ { label: "Filename", content: [resp.name], }, { label: "Last modified", content: [new Date(resp.lastModified).toLocaleString()], }, compute(() => { const link = this.#link.get(); if (!link) { return null; } return { label: "File link", content: [ el( "a", [ attr("href", link), attr("target", "_blank"), attr("rel", "noopener"), ], [link], ), ], }; }), { label: "Number of canvases", content: [canvases.size.toString(10)], }, ]); }, menuSlot: ([, , canvases]) => { return compute(() => { return selectBox({ value: compute(() => this.#selectedCanvasId.get() ?? ""), options: Array.from(canvases).map(([, canvas]) => el("option", [attr("value", canvas.id)], [canvas.name]), ), onChange: (value) => { if (canvases.has(value)) { this.#selectedCanvasId.set(value); } }, }); }); }, frameCanvas: ([, images, canvases], $selected, $loadedState) => { const frameCanvas = new FrameCanvas(this.#preferences, $selected); effect(() => { $selected.set(null); const currentId = this.#selectedCanvasId.get(); if (typeof currentId !== "string") { return; } const node = canvases.get(currentId); if (!node) { return; } frameCanvas.render([node], images); return () => { frameCanvas.clear(); }; }); let isFirstRun = true; effect(() => { const selected = $selected.get(); if (isFirstRun) { isFirstRun = false; return; } this.dispatchEvent( new CustomEvent("nodeselect", { detail: { node: selected, }, }), ); }); effect(() => { if (!state.isCanvas($loadedState.get())) { return; } frameCanvas.connectedCallback(); return () => { frameCanvas.disconnectedCallback(); }; }); return frameCanvas.container; }, }); constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent += styles; style.textContent += FrameCanvas.styles; shadow.appendChild(style); effect(() => { for (const [id] of this.#canvases.get()) { this.#selectedCanvasId.set(id); return () => { this.#selectedCanvasId.set(null); }; } }); effect(() => { const node = this.#ui.get(); shadow.appendChild(node); return () => { shadow.removeChild(node); }; }); // Emit `preferencesupdate` event on preferences updates effect(() => { const preferences = this.#preferences.get(); if (!isEqualPreferences(this.#givenPreferences, preferences)) { this.dispatchEvent( new CustomEvent("preferencesupdate", { detail: { preferences }, }), ); } }); } set link(link: string | null) { this.#link.set(link); } get link(): string | null { return this.#link.once(); } get renderedImages(): Record<string, string> | null { const map = this.#images.once(); if (!map) { return null; } return Object.fromEntries(map.entries()); } set renderedImages(set: Record<string, string>) { const map = new Map<string, string>(); for (const nodeId in set) { map.set(nodeId, set[nodeId]); } this.#images.set(map); } get apiResponse(): figma.GetFileResponse | null { return this.#resp.once(); } set apiResponse(resp: figma.GetFileResponse | undefined) { if (!resp) { return; } this.#resp.set(resp); } static observedAttributes = ["link"] as const; public attributeChangedCallback( name: (typeof FigspecFileViewer.observedAttributes)[number], oldValue: string | null, newValue: string | null, ): void { if (newValue === oldValue) { return; } switch (name) { case "link": { this.#link.set(newValue || null); return; } } } }
-
-
-
@@ -0,0 +1,237 @@import { attr, el } from "./dom"; import type * as figma from "./figma"; import { FrameCanvas } from "./FrameCanvas/FrameCanvas"; import { defaultPreferenecs, isEqual as isEqualPreferences, type Preferences, } from "./preferences"; import { compute, effect, Signal } from "./signal"; import { styles } from "./styles"; import * as state from "./state"; import { ui } from "./ui/ui"; import { infoItems } from "./ui/infoItems/infoItems"; export class FigspecFrameViewer extends HTMLElement { #link = new Signal<string | null>(null); #resp = new Signal<figma.GetFileNodesResponse | null>(null); #image = new Signal<string | null>(null); #state: Signal< state.State<[figma.GetFileNodesResponse, figma.Node, string]> > = compute(() => { const resp = this.#resp.get(); const image = this.#image.get(); if (!resp && !image) { return state.idle; } if (!image) { return state.setupError(new Error("Image file URI is required")); } if (!resp) { return state.setupError( new Error("Returned result of Get File Nodes API is required"), ); } const node = findMainNode(resp); if (!node) { return state.setupError( new Error("No renderable node is found in the data"), ); } return state.loaded([resp, node, image]); }); #givenPreferences: Readonly<Preferences> = { ...defaultPreferenecs }; #preferences = new Signal<Preferences>({ ...this.#givenPreferences }); set preferences(value: Readonly<Preferences>) { this.#givenPreferences = value; this.#preferences.set({ ...value }); } get preferences(): Readonly<Preferences> { return this.#preferences.once(); } #ui = ui({ caller: "frame", state: this.#state, preferences: this.#preferences, infoContents: ([resp]) => { return infoItems([ { label: "Filename", content: [resp.name], }, { label: "Last modified", content: [new Date(resp.lastModified).toLocaleString()], }, compute(() => { const link = this.#link.get(); if (!link) { return null; } return { label: "Frame link", content: [ el( "a", [ attr("href", link), attr("target", "_blank"), attr("rel", "noopener"), ], [link], ), ], }; }), ]); }, frameCanvas: ([, node, image], $selected, $loadedState) => { const frameCanvas = new FrameCanvas(this.#preferences, $selected); frameCanvas.render([node], new Map([[node.id, image]])); let isFirstRun = true; effect(() => { const selected = $selected.get(); if (isFirstRun) { isFirstRun = false; return; } this.dispatchEvent( new CustomEvent("nodeselect", { detail: { node: selected, }, }), ); }); effect(() => { if (!state.isCanvas($loadedState.get())) { return; } frameCanvas.connectedCallback(); return () => { frameCanvas.disconnectedCallback(); }; }); effect(() => { return () => { frameCanvas.clear(); }; }); return frameCanvas.container; }, }); constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); const styleEl = document.createElement("style"); styleEl.textContent += styles; styleEl.textContent += FrameCanvas.styles; shadow.appendChild(styleEl); effect(() => { const node = this.#ui.get(); shadow.appendChild(node); return () => { shadow.removeChild(node); }; }); // Emit `preferencesupdate` event on preferences updates effect(() => { const preferences = this.#preferences.get(); if (!isEqualPreferences(this.#givenPreferences, preferences)) { this.dispatchEvent( new CustomEvent("preferencesupdate", { detail: { preferences }, }), ); } }); } get renderedImage(): string | null { return this.#image.once(); } set renderedImage(uri: string | undefined) { if (uri) { this.#image.set(uri); } } get apiResponse(): figma.GetFileNodesResponse | null { return this.#resp.once(); } set apiResponse(resp: figma.GetFileNodesResponse | undefined) { if (!resp) { return; } this.#resp.set(resp); } static observedAttributes = ["link"] as const; public attributeChangedCallback( name: (typeof FigspecFrameViewer.observedAttributes)[number], oldValue: string | null, newValue: string | null, ): void { if (newValue === oldValue) { return; } switch (name) { case "link": { this.#link.set(newValue || null); return; } } } } function findMainNode(resp: figma.GetFileNodesResponse): figma.Node | null { for (const key in resp.nodes) { const node = resp.nodes[key]; switch (node.document.type) { case "CANVAS": case "FRAME": case "GROUP": case "COMPONENT": case "COMPONENT_SET": { return node.document; } } } return null; }
-
-
src/FigspecViewer/DistanceGuide.ts (deleted)
-
@@ -1,181 +0,0 @@import { css, svg } from "lit"; import { styleMap } from "lit/directives/style-map.js"; 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; } const guidesCache = new Map<string, readonly DistanceGuide[]>(); export const Guides = ({ node, distanceTo, reverseScale, fontSize, }: GuidesProps) => { const combinedId = node.id + "\n" + distanceTo.id; let guides = guidesCache.get(combinedId); if (!guides) { guides = getDistanceGuides( node.absoluteBoundingBox, distanceTo.absoluteBoundingBox ); guidesCache.set(combinedId, guides); } 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; } `;
-
-
src/FigspecViewer/ErrorMessage.ts (deleted)
-
@@ -1,62 +0,0 @@import { css, html, TemplateResult } from "lit"; export interface ErrorMessageProps { title: string; children?: string | TemplateResult; } export const ErrorMessage = ({ title, children }: ErrorMessageProps) => html` <div class="error-background"> <div class="error-container"> <span class="error-title" ><span class="error-badge">Error</span>${title}</span > <span class="error-description">${children}</span> </div> </div> `; export const styles = css` .error-background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: var(--error-bg); color: var(--error-fg); } .error-container { max-width: 800px; margin: auto; padding: 1em; } .error-badge { display: inline-block; font-size: 0.8em; padding: 0.2em 0.5em; margin-inline-end: 0.5em; background: var(--error-color); border-radius: 2px; color: var(--error-bg); text-transform: uppercase; } .error-title { display: block; font-size: 1.2em; font-weight: bold; text-transform: capitalize; } .error-description { display: block; margin-block-start: 1em; } `;
-
-
src/FigspecViewer/FigspecFileViewer.ts (deleted)
-
@@ -1,239 +0,0 @@import type * as Figma from "figma-js"; import { LitElement, css, html } from "lit"; import { property } from "lit/decorators.js"; import * as ErrorMessage from "./ErrorMessage"; import { ViewerMixin } from "./ViewerMixin"; import { extendStyles } from "./utils"; // TODO: Move docs for props in mixins (waiting for support at web-component-analyzer) /** * A Figma spec viewer. Displays a rendered image alongside sizing guides. * @element figspec-file-viewer * * @property {number} [panX=0] * Current pan offset in px for X axis. * This is a "before the scale" value. * * @property {number} [panY=0] * Current pan offset in px for Y axis. * This is a "before the scale" value. * * @property {number} [scale=1] * Current zoom level, where 1.0 = 100%. * * @property {number} [zoomSpeed=500] * How fast zooming when do ctrl+scroll / pinch gestures. * Available values: 1 ~ 1000 * @attr [zoom-speed=500] See docs for `zoomSpeed` property. * * @property {number} [panSpeed=500] * How fast panning when scroll vertically or horizontally. * 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 {string} [link=null] * Figma link for the given project/node. If passed, figspec will present a footer with metadata and a link to figma. * * @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 FigspecFileViewer extends ViewerMixin(LitElement) { /** * A response of "GET file nodes" API. * https://www.figma.com/developers/api#get-file-nodes-endpoint */ @property({ type: Object, attribute: "document-node", }) documentNode: Figma.FileResponse | null = null; /** * A record of rendered images, where key is an ID of the node, * value is an URI of the image. * https://www.figma.com/developers/api#get-images-endpoint */ @property({ type: Object, attribute: "rendered-images", }) renderedImages: Record<string, string> | null = null; /** * Current selected page (node whose type is "CANVAS"). */ selectedPage: Figma.Canvas | null = null; /** @private */ get isMovable(): boolean { return !!(this.renderedImages && this.documentNode); } /** @private */ get __images() { return this.renderedImages || {}; } /** @private */ get error() { if (!this.documentNode || !this.renderedImages) { return ErrorMessage.ErrorMessage({ title: "Parameter error", children: html`<span> Both <code>document-node</code> and <code>rendered-images</code> are required. </span>`, }); } if (super.error) { return super.error; } } static get styles() { return extendStyles(super.styles, [ css` :host { --figspec-control-bg-default: #fcfcfc; --figspec-control-fg-default: #333; --control-bg: var( --figspec-control-bg, var(--figspec-control-bg-default) ); --control-fg: var( --figspec-control-bg, var(--figspec-control-fg-default) ); --control-shadow: var( --figspec-control-shadow, 0 2px 4px rgba(0, 0, 0, 0.3) ); --padding: var(--figspec-control-padding, 8px 16px); display: flex; flex-direction: column; } @media (prefers-color-scheme: dark) { :host { --figspec-control-bg-default: #222; --figspec-control-fg-default: #fff; } } .controls { flex-shrink: 0; padding: var(--padding); background-color: var(--control-bg); box-shadow: var(--control-shadow); color: var(--control-fg); z-index: 1; } .view { position: relative; flex-grow: 1; flex-shrink: 1; } `, ]); } render() { return html` <div class="controls"> <select @change=${this.#handlePageChange}> ${this.documentNode?.document.children.map( (c) => html`<option value=${c.id}>${c.name}</option>` )} </select> </div> <div class="view">${super.render()}</div> `; } getMetadata() { return { fileName: this.documentNode!.name, timestamp: this.documentNode!.lastModified, link: this.link, }; } connectedCallback() { super.connectedCallback(); if (this.documentNode) { this.#selectFirstPage(); if (this.selectedPage) { this.__updateTree(this.selectedPage); this.resetZoom(); } } } updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); if (changedProperties.has("documentNode")) { this.#selectFirstPage(); if (this.selectedPage) { this.__updateTree(this.selectedPage); this.resetZoom(); } } if (changedProperties.has("renderedImages")) { this.__updateEffectMargins(); } } #selectFirstPage = () => { if (!this.documentNode) { this.selectedPage = null; return; } this.selectedPage = this.documentNode.document.children.filter( (c): c is Figma.Canvas => c.type === "CANVAS" )[0] ?? null; }; #handlePageChange = (ev: Event) => { const target = ev.currentTarget as HTMLSelectElement; this.selectedPage = (this.documentNode?.document.children.find( (c) => c.id === target.value ) as Figma.Canvas) ?? null; if (this.selectedPage) { this.__updateTree(this.selectedPage); this.resetZoom(); this.__updateEffectMargins(); this.panX = 0; this.panY = 0; } }; }
-
-
src/FigspecViewer/FigspecFrameViewer.ts (deleted)
-
@@ -1,165 +0,0 @@import type * as Figma from "figma-js"; import { LitElement, html } from "lit"; import { property } from "lit/decorators.js"; import * as ErrorMessage from "./ErrorMessage"; import { ViewerMixin } from "./ViewerMixin"; import { SizedNode } from "./utils"; // TODO: Move docs for props in mixins (waiting for support at web-component-analyzer) /** * A Figma spec viewer. Displays a rendered image alongside sizing guides. * @element figspec-frame-viewer * * @property {number} [panX=0] * Current pan offset in px for X axis. * This is a "before the scale" value. * * @property {number} [panY=0] * Current pan offset in px for Y axis. * This is a "before the scale" value. * * @property {number} [scale=1] * Current zoom level, where 1.0 = 100%. * * @property {number} [zoomSpeed=500] * How fast zooming when do ctrl+scroll / pinch gestures. * Available values: 1 ~ 1000 * @attr [zoom-speed=500] See docs for `zoomSpeed` property. * * @property {number} [panSpeed=500] * How fast panning when scroll vertically or horizontally. * 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 {string} [link=null] * Figma link for the given project/node. If passed, figspec will present a footer with metadata and a link to figma. * * @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 FigspecFrameViewer extends ViewerMixin(LitElement) { /** * A response of "GET file nodes" API. * https://www.figma.com/developers/api#get-file-nodes-endpoint */ @property({ type: Object, }) nodes: Figma.FileNodesResponse | null = null; /** * An image rendered by "GET image" API. * https://www.figma.com/developers/api#get-images-endpoint */ @property({ type: String, attribute: "rendered-image", }) renderedImage: string | null = null; /** @private */ get isMovable(): boolean { return !!(this.nodes && this.renderedImage && this.documentNode); } /** * Readonly. Document node (= root drawable node). * @readonly */ get documentNode(): SizedNode | null { if (!this.nodes) { return null; } const documentNode = Object.values(this.nodes.nodes)[0]; if (!documentNode || !("absoluteBoundingBox" in documentNode.document)) { return null; } return documentNode.document; } /** @private */ get __images() { if (!this.documentNode || !this.renderedImage) { return {}; } return { [this.documentNode.id]: this.renderedImage, }; } /** @private */ get error() { if (!this.nodes || !this.renderedImage) { return ErrorMessage.ErrorMessage({ title: "Parameter error", children: html`<span> Both <code>nodes</code> and <code>rendered-image</code> are required. </span>`, }); } if (!this.documentNode) { return ErrorMessage.ErrorMessage({ title: "Parameter Error", children: html` <span> Document node is empty or does not have size. </span> `, }); } if (super.error) { return super.error; } } getMetadata() { return { fileName: this.nodes!.name, timestamp: this.nodes!.lastModified, link: this.link, }; } connectedCallback() { super.connectedCallback(); if (this.documentNode) { this.__updateTree(this.documentNode); this.__updateEffectMargins(); this.resetZoom(); } } updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); if (changedProperties.has("nodes")) { if (!this.documentNode) return; this.__updateTree(this.documentNode); this.resetZoom(); } if (changedProperties.has("renderedImage")) { this.__updateEffectMargins(); } } }
-
-
src/FigspecViewer/Footer/Footer.ts (deleted)
-
@@ -1,77 +0,0 @@import { css, html } from "lit"; import { FigmaIcon } from "../Icons"; import { fromNow } from "./utils"; export const styles = css` .figma-footer { flex: 0; z-index: calc(var(--z-index) + 1); border-top: 1px solid #ccc; min-height: 48px; padding: 0 16px; text-decoration: none; display: flex; flex-direction: row; justify-content: start; align-items: center; background-color: #fff; overflow-x: auto; cursor: pointer; font-size: 12px; color: rgba(0, 0, 0, 0.8); } .figma-footer--icon { margin-right: 12px; } .figma-footer--title { font-weight: 600; margin-right: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .figma-footer--timestamp { white-space: nowrap; overflow: hidden; } `; export const Footer = (metadata?: { link: string; timestamp: Date | string; fileName: string; }) => { // Do not render in case there is no metadata or a link is not passed if ( !metadata || !metadata.link || metadata.link === undefined || metadata.link === "undefined" ) { return null; } const { link, timestamp, fileName } = metadata; return html`<a class="figma-footer" target="_blank" rel="noopener" title="Open in Figma" href="${link}" > <span class="figma-footer--icon"> ${FigmaIcon()} </span> <span class="figma-footer--title"> ${fileName} </span> <span title="Last time edited: ${new Date(timestamp).toUTCString()}" class="figma-footer--timestamp" > Edited ${fromNow(timestamp)} </span> </a>`; };
-
-
src/FigspecViewer/Footer/utils.ts (deleted)
-
@@ -1,52 +0,0 @@const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const WEEK = 7 * DAY; const MONTH = 30 * DAY; const YEAR = 365 * DAY; const intervals = [ { gte: YEAR, divisor: YEAR, unit: "year" }, { gte: MONTH, divisor: MONTH, unit: "month" }, { gte: WEEK, divisor: WEEK, unit: "week" }, { gte: DAY, divisor: DAY, unit: "day" }, { gte: HOUR, divisor: HOUR, unit: "hour" }, { gte: MINUTE, divisor: MINUTE, unit: "minute" }, { gte: 30 * SECOND, divisor: SECOND, unit: "seconds" }, { gte: 0, divisor: 1, text: "just now" }, ]; const getTime = (targetDate: Date | number | string) => { const date = typeof targetDate === "object" ? (targetDate as Date) : new Date(targetDate); return date.getTime(); }; /** * Receives two dates to compare and returns "time ago" based on them * example: 4 weeks ago * * Heavily inspired by https://stackoverflow.com/a/67338038/938822 */ export const fromNow = ( date: Date | number | string, nowDate: Date | number | string = Date.now(), rft = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) ) => { const now = getTime(nowDate); const diff = now - getTime(date); const diffAbs = Math.abs(diff); for (const interval of intervals) { if (diffAbs >= interval.gte) { const x = Math.round(Math.abs(diff) / interval.divisor); const isInFuture = diff < 0; const intervalUnit = interval.unit as Intl.RelativeTimeFormatUnit; return intervalUnit ? rft.format(isInFuture ? x : -x, intervalUnit) : interval.text; } } };
-
-
src/FigspecViewer/Icons.ts (deleted)
-
@@ -1,41 +0,0 @@import { svg } from "lit"; 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> `; export const FigmaIcon = () => svg` <svg title="figma logo" width="11" height="16" viewBox="0 0 12 17" xmlns="http://www.w3.org/2000/svg"> <path d="M5.5 1.5h-2c-1.105 0-2 .895-2 2 0 1.105.895 2 2 2h2v-4zm-5 2c0 1.043.533 1.963 1.341 2.5C1.033 6.537.5 7.457.5 8.5c0 1.043.533 1.963 1.341 2.5C1.033 11.537.5 12.457.5 13.5c0 1.657 1.343 3 3 3 1.657 0 3-1.343 3-3V10.736c.53.475 1.232.764 2 .764 1.657 0 3-1.343 3-3 0-1.043-.533-1.963-1.341-2.5.808-.537 1.341-1.457 1.341-2.5 0-1.657-1.343-3-3-3h-5c-1.657 0-3 1.343-3 3zm1 5c0-1.105.895-2 2-2h2v4h-2c-1.105 0-2-.895-2-2zm0 5c0-1.105.895-2 2-2h2v2c0 1.105-.895 2-2 2-1.105 0-2-.895-2-2zm7-3c-1.105 0-2-.895-2-2 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2zm0-5h-2v-4h2c1.105 0 2 .895 2 2 0 1.105-.895 2-2 2z" fill-rule="evenodd" fill-opacity="1" fill="#000" stroke="none" ></path> </svg> `;
-
-
-
@@ -1,230 +0,0 @@import { css, html } from "lit"; import { HorizontalPaddingIcon, VerticalPaddingIcon, CloseIcon, CopyIcon, } from "../Icons"; import { FigmaNode, getStyleRule, NodeStyles } from "./utils"; import type { CSSRule } from "./utils"; const copy = async (text: string) => { await navigator.clipboard.writeText(text); }; export type InspectorViewProps = { node: FigmaNode; onClose: () => void; }; export const View = ({ node, onClose }: InspectorViewProps) => { if (!node) { return null; } const nodeStyles = new NodeStyles(node); // In order to disable canvas interactions (e.g. pan, click to // deselect), we need to cancel JavaScript event propagation // on the root element. const stopPropagation = (ev: Event) => ev.stopPropagation(); return html` <div class="inspector-view" @click=${stopPropagation} @wheel=${stopPropagation} @keydown=${stopPropagation} @keyup=${stopPropagation} @pointermove=${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: 100%; width: 300px; position: absolute; right: 0; background: white; border-left: 1px solid #ccc; overflow-y: auto; z-index: calc(var(--z-index) + 2); } .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; } `;
-
-
src/FigspecViewer/InspectorView/utils.ts (deleted)
-
@@ -1,271 +0,0 @@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};`;
-
-
src/FigspecViewer/Node.ts (deleted)
-
@@ -1,158 +0,0 @@import * as Figma from "figma-js"; import { css, html, svg } from "lit"; import { styleMap, StyleInfo } from "lit/directives/style-map.js"; import { round } from "./utils"; export interface OutlineProps { node: Extract<Figma.Node, { absoluteBoundingBox: any }>; computedThickness: number; selected?: boolean; onClick?(ev: MouseEvent): void; } export const Outline = ({ node, selected = false, computedThickness, onClick, }: OutlineProps) => { const { x, y, width, height } = node.absoluteBoundingBox; const radius: { topLeft: number; topRight: number; bottomRight: number; bottomLeft: number; } = "cornerRadius" in node && node.cornerRadius ? { topLeft: node.cornerRadius, topRight: node.cornerRadius, bottomRight: node.cornerRadius, bottomLeft: node.cornerRadius, } : "rectangleCornerRadii" in node && node.rectangleCornerRadii ? { topLeft: node.rectangleCornerRadii[0], topRight: node.rectangleCornerRadii[1], bottomRight: node.rectangleCornerRadii[2], bottomLeft: node.rectangleCornerRadii[3], } : { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0, }; // Since SVG can't control where to draw borders (I mean you can't draw inset borders), we need to // shift each drawing points by the half of the border width. const shift = computedThickness / 2; // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d // [M] ... Move to // [L] ... Line to // [A] ... Arc to // [Z] ... Close path const moveTo = (x: number, y: number) => `M${x},${y}`; const lineTo = (x: number, y: number) => `L${x},${y}`; const arcTo = (r: number, x: number, y: number) => `A${r},${r} 0 0 1 ${x},${y}`; const boxPath = [ moveTo(radius.topLeft + shift, shift), lineTo(width - radius.topRight, shift), arcTo(radius.topRight - shift, width - shift, radius.topRight), lineTo(width - shift, height - radius.bottomRight), arcTo( radius.bottomRight - shift, width - radius.bottomRight, height - shift ), lineTo(radius.bottomLeft, height - shift), arcTo(radius.bottomLeft - shift, shift, height - radius.bottomLeft), lineTo(shift, radius.topLeft), arcTo(radius.topLeft - shift, radius.topLeft, shift), "Z", ].join(" "); return svg` <path class="guide" d=${boxPath} shape-rendering="geometricPrecision" fill="none" transform="translate(${x}, ${y})" ?data-selected=${selected} @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 { /* * SVGs cannot be pixel perfect, especially floating values. * Since many platform renders them visually incorrectly (probably they * 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 { opacity: 1; } .guide[data-selected] { 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; } `;
-
-
src/FigspecViewer/NodeSelectableMixin.ts (deleted)
-
@@ -1,40 +0,0 @@import type * as Figma from "figma-js"; import { LitElement } from "lit"; import { property } from "lit/decorators.js"; 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; };
-
-
src/FigspecViewer/PositionedMixin.ts (deleted)
-
@@ -1,243 +0,0 @@import { LitElement } from "lit"; import { property } from "lit/decorators.js"; import { TouchGestureMixin, TouchGestureMixinProps } from "./TouchGestureMixin"; import type { Constructor, Point2D } from "./utils"; interface GestureEvent<E extends Element = HTMLElement> extends UIEvent { /** * The distance between two fingers since the start of an event, as a multiplier of the initial distance. * * # Discussion * The initial value is 1.0. If less than 1.0, the gesture is pinch close (to zoom out). * If greater than 1.0, the gesture is pinch open (to zoom in). * https://developer.apple.com/documentation/webkitjs/gestureevent/1632653-scale */ readonly scale: number; readonly target: E; } export interface Positioned { panX: number; panY: number; scale: number; zoomSpeed: number; panSpeed: number; readonly isMovable: boolean; readonly canvasTransform: readonly string[]; } export const PositionedMixin = <T extends Constructor<LitElement>>( superClass: T ): T & Constructor<Positioned & TouchGestureMixinProps> => { class Positioned extends TouchGestureMixin(superClass) { @property({ attribute: false, }) panX: number = 0; @property({ attribute: false, }) panY: number = 0; @property({ attribute: false, }) scale: number = 1; @property({ type: Number, attribute: "zoom-speed", }) zoomSpeed: number = 500; @property({ type: Number, attribute: "pan-speed", }) panSpeed: number = 500; get isMovable() { return true; } get canvasTransform() { return [ `scale(${this.scale})`, `translate(${this.panX}px, ${this.panY}px)`, ]; } #isDragModeOn: boolean = false; constructor(...args: any[]) { super(...args); this.addEventListener( "wheel", (ev) => { if (!this.isMovable) return; ev.preventDefault(); if (ev.ctrlKey) { // Performs zoom when ctrl key is pressed. let { deltaY } = ev; if (ev.deltaMode === 1) { // Firefox quirk deltaY *= 15; } const prevScale = this.scale; this.scale *= 1 - deltaY / ((1000 - this.zoomSpeed) * 0.5); // Performs pan to archive "zoom at the point" behavior (I don't know how to call it). const offsetX = ev.offsetX - this.offsetWidth / 2; const offsetY = ev.offsetY - this.offsetHeight / 2; this.panX += offsetX / this.scale - offsetX / prevScale; this.panY += offsetY / this.scale - offsetY / prevScale; } else { // Performs pan otherwise (to be close to native behavior) // Adjusting panSpeed in order to make panSpeed=500 to match to the Figma's one. const speed = this.panSpeed * 0.002; this.panX -= (ev.deltaX * speed) / this.scale; this.panY -= (ev.deltaY * speed) / this.scale; } }, // This component prevents every native wheel behavior on it. { passive: false } ); // Base scale for Safari's GestureEvents let gestureStartScale = 1; this.addEventListener("gesturestart", (ev) => { ev.preventDefault(); gestureStartScale = this.scale; }); this.addEventListener("gesturechange", (_ev) => { const ev = _ev as GestureEvent; ev.preventDefault(); // We can't perform zoom-at-the-point due to lack of offsetX/Y in GestureEvent this.scale = gestureStartScale * ev.scale; }); this.addEventListener("pointermove", (ev) => { // Performs pan only when middle buttons is pressed. // // 4 ... Auxiliary button (usually the mouse wheel button or middle button) // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons if (!(ev.buttons & 4)) return; ev.preventDefault(); // Moving amount of middle button+pointer move panning should matches to the actual // pointer travel distance. Since translate goes after scaling, we need to scale // delta too. this.#movePanel(ev.movementX, ev.movementY); }); // Listen to keyboard events to enable dragging when Space is pressed, just like in Figma this.#listenToKeyboardEvents(); /** @private */ this.onmousedown = () => { if (this.#isDragModeOn) { document.body.style.cursor = "grabbing"; this.onmousemove = ({ movementX, movementY }: MouseEvent) => { this.#movePanel(movementX, movementY); }; // cleanup unnecessary listeners when user stops dragging this.onmouseup = () => { document.body.style.cursor = "grab"; this.onmousemove = null; this.onmouseup = null; }; } }; } disconnectedCallback() { document.removeEventListener("keyup", this.#keyUp); document.removeEventListener("keydown", this.#keyDown); super.disconnectedCallback(); } // Dispatch events when the position-related value changes. updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); if (changedProperties.has("scale")) { this.dispatchEvent( new CustomEvent<{ scale: number }>("scalechange", { detail: { scale: this.scale, }, }) ); } if (changedProperties.has("panX") || changedProperties.has("panY")) { this.dispatchEvent( new CustomEvent<{ x: number; y: number }>("positionchange", { detail: { x: this.panX, y: this.panY, }, }) ); } } onTouchPan(delta: Point2D) { this.panX += delta.x / this.scale; this.panY += delta.y / this.scale; } onTouchPinch(delta: number) { // TODO: Remove this no-brainer magic number this.scale *= 1 - delta / 1000; } #movePanel = (shiftX: number, shiftY: number) => { this.panX += shiftX / this.scale / window.devicePixelRatio; this.panY += shiftY / this.scale / window.devicePixelRatio; }; // Enable drag mode when holding the spacebar #keyDown = (event: KeyboardEvent) => { if (event.code === "Space" && !this.#isDragModeOn) { this.#isDragModeOn = true; document.body.style.cursor = "grab"; } }; // Disable drag mode when space lets the spacebar go #keyUp = (event: KeyboardEvent) => { if (event.code === "Space" && this.#isDragModeOn) { this.#isDragModeOn = false; document.body.style.cursor = "auto"; } }; #listenToKeyboardEvents = () => { document.addEventListener("keyup", this.#keyUp); document.addEventListener("keydown", this.#keyDown); }; } return Positioned; };
-
-
src/FigspecViewer/TouchGestureMixin.ts (deleted)
-
@@ -1,107 +0,0 @@import { LitElement } from "lit"; import type { Constructor, Point2D } from "./utils"; function shouldSkipEvent(ev: TouchEvent): boolean { return ev.touches.length === 0 || ev.touches.length > 2; } function getDistance(a: Point2D, b: Point2D): number { return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); } export interface TouchGestureMixinProps { isTouching: boolean; onTouchPan(delta: Point2D): void; onTouchPinch(delta: number): void; } export const TouchGestureMixin = <T extends Constructor<LitElement>>( superClass: T ): T & Constructor<TouchGestureMixinProps> => class CTouchGesture extends superClass { private previousTouches: TouchList | null = null; constructor(...args: any[]) { super(...args); this.addEventListener("touchstart", (ev) => { if (shouldSkipEvent(ev)) { return; } ev.preventDefault(); this.previousTouches = ev.touches; }); this.addEventListener("touchend", (ev) => { if (shouldSkipEvent(ev)) { return; } ev.preventDefault(); this.previousTouches = null; }); this.addEventListener("touchcancel", (ev) => { if (shouldSkipEvent(ev)) { return; } ev.preventDefault(); this.previousTouches = null; }); this.addEventListener("touchmove", (ev) => { if (shouldSkipEvent(ev)) { return; } const previousTouches = Array.from(this.previousTouches || []); const currentTouches = Array.from(ev.touches); this.previousTouches = ev.touches; // When one or more than one of touch input sources differs, skip processing. if ( currentTouches.length !== previousTouches.length || !currentTouches.every((t) => previousTouches.some((pt) => pt.identifier === t.identifier) ) ) { return; } // Pan if (currentTouches.length === 1) { this.onTouchPan({ x: currentTouches[0].pageX - previousTouches[0].pageX, y: currentTouches[0].pageY - previousTouches[0].pageY, }); return; } // Pinch this.onTouchPinch( getDistance( { x: currentTouches[0].pageX, y: currentTouches[0].pageY, }, { x: previousTouches[0].pageX, y: previousTouches[0].pageY, } ) ); return; }); } get isTouching() { return !!(this.previousTouches && this.previousTouches.length > 0); } onTouchPan(delta: Point2D) {} onTouchPinch(delta: number) {} };
-
-
src/FigspecViewer/ViewerMixin.ts (deleted)
-
@@ -1,570 +0,0 @@import type * as Figma from "figma-js"; import { LitElement, css, html, svg, TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; import { Constructor, extendStyles, SizedNode } from "./utils"; 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"; import * as FigmaFooter from "./Footer/Footer"; interface Margin { top: number; right: number; bottom: number; left: number; } export interface IViewer { zoomMargin: number; link: string; /** * 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; __updateEffectMargins(): void; resetZoom(): void; getMetadata(): | { fileName: string; timestamp: Date | string; link: string } | undefined; } 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; @property({ type: String, attribute: "link", }) link: string = ""; static get styles() { // @ts-ignore const styles = super.styles; return extendStyles(styles, [ css` :host { --default-error-bg: #fff; --default-error-fg: #333; --bg: var(--figspec-viewer-bg, #e5e5e5); --z-index: var(--figspec-viewer-z-index, 0); --error-bg: var(--figspec-viewer-error-bg, var(--default-error-bg)); --error-fg: var(--figspec-viewer-error-fg, var(--default-error-fg)); --error-color: var(--figspec-viewer-error-color, tomato); --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); } @media (prefers-color-scheme: dark) { :host { --default-error-bg: #222; --default-error-fg: #fff; } } .spec-canvas-wrapper { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column-reverse; } .canvas { position: absolute; top: 50%; left: 50%; flex: 1; } .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, InspectorView.styles, FigmaFooter.styles, ]); } get __images(): Record<string, string> { return {}; } // Cached values #canvasSize?: Figma.Rect; #effectMargins?: Record<string, Margin>; #flattenedNodes?: readonly SizedNode[]; constructor(...args: any[]) { super(...args); } deselectNode() { this.selectedNode = null; } get error(): string | Error | null | TemplateResult | undefined { if (!this.#canvasSize || !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 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="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); if ( !node || !("absoluteBoundingBox" in node) || !this.#effectMargins?.[node.id] ) { return null; } 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` <svg class="guides" viewBox="0 0 ${canvasSize.width} ${canvasSize.height}" width=${canvasSize.width} height=${canvasSize.height} 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> ${InspectorView.View({ node: this.selectedNode as FigmaNode, onClose: this.deselectNode, })} ${FigmaFooter.Footer(this.getMetadata())} </div> `; } // implemented in FileViewer/FrameViewer getMetadata() { return undefined; } connectedCallback() { super.connectedCallback(); this.resetZoom(); } updated(changedProperties: Parameters<LitElement["updated"]>[0]) { super.updated(changedProperties); } __updateTree(node: Figma.Node) { if ( !( node.type === "CANVAS" || node.type === "FRAME" || node.type === "COMPONENT" || //@ts-ignore NOTE: figma-js does not implement COMPONENT_SET type (yet?) node.type === "COMPONENT_SET" ) ) { throw new Error( "Cannot update node tree: Top level node MUST be one of CANVAS, FRAME, COMPONENT, or COMPONENT_SET" ); } this.#canvasSize = node.type === "CANVAS" ? getCanvasSize(node) : node.absoluteBoundingBox; this.#flattenedNodes = flattenNode(node); // Since above properties aren't "attribute", their changes does not // trigger an update. We need to manually request an update. this.requestUpdate(); } __updateEffectMargins() { if (!this.__images) { return; } const containers = Object.keys(this.__images) .map(this.#getNodeById) .filter((n): n is NonNullable<typeof n> => !!n); this.#effectMargins = containers.reduce<Record<string, Margin>>( (margin, node) => { if (!("absoluteBoundingBox" in node)) { return margin; } return { ...margin, [node.id]: getEffectMargin(node, flattenNode(node)), }; }, {} ); this.requestUpdate(); } 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); } } #handleNodeClick = (node: SizedNode) => (ev: MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); this.selectedNode = node; }; #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 getEffectMargin( container: 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, }; } 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: container.absoluteBoundingBox.y - bounds.top, right: bounds.right - container.absoluteBoundingBox.x - container.absoluteBoundingBox.width, bottom: bounds.bottom - container.absoluteBoundingBox.y - container.absoluteBoundingBox.height, left: container.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(), ]; }
-
-
src/FigspecViewer/types.ts (deleted)
-
@@ -1,3 +0,0 @@import * as Figma from "figma-js"; export type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>;
-
-
-
@@ -1,14 +1,11 @@import * as Figma from "figma-js"; import { CSSResultArray, LitElement } from "lit"; import type * as figma from "../figma"; export type SizedNode = Extract<Figma.Node, { absoluteBoundingBox: any }>; export interface Point2D { interface Point2D { x: number; y: number; } export type DistanceGuide = { type DistanceGuide = { /** * Solid line */
-
@@ -46,7 +43,7 @@ */left: number; } function absRect(rect: Figma.Rect): AbsRect { function absRect(rect: figma.Rectangle): AbsRect { return { top: rect.y, right: rect.x + rect.width,
-
@@ -56,8 +53,8 @@ };} export function getDistanceGuides( selected: Figma.Rect, compared: Figma.Rect selected: figma.Rectangle, compared: figma.Rectangle, ): readonly DistanceGuide[] { const a = absRect(selected); const b = absRect(compared);
-
@@ -154,38 +151,3 @@ ];return guides.filter((x): x is DistanceGuide => !!x); } /** * x.xxxxx... -> x.xx */ export function round(n: number) { return Math.round(n * 100) / 100; } /** * Utility type for creating constructor type from an interface. * @example * function FooMixin<T extends Constructor<LitElement>>(Base: T): T & Constructor<MixinInterface> { * // ... * } */ 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]; }
-
-
-
@@ -0,0 +1,51 @@import { describe, expect, it } from "vitest"; import { BoundingBoxMeasurement } from "./BoundingBoxMeasurement"; describe("BoundingBoxMeasurement", () => { it("Should measure bounding box for nodes", () => { const bbox = new BoundingBoxMeasurement(); bbox.addNode({ type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: -50, y: -50, width: 1, height: 1, }, }); bbox.addNode({ type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 40, y: 40, width: 10, height: 10, }, }); expect(bbox.measure()).toMatchObject({ x: -50, y: -50, width: 100, height: 100, }); }); it("Should use NaN if no nodes were added", () => { const bbox = new BoundingBoxMeasurement(); expect(bbox.measure()).toMatchObject({ x: NaN, y: NaN, width: NaN, height: NaN, }); }); });
-
-
-
@@ -0,0 +1,45 @@import type * as figma from "../figma"; /** * Measure bounding box for nodes. */ export class BoundingBoxMeasurement { #minX = Infinity; #maxX = -Infinity; #minY = Infinity; #maxY = -Infinity; /** * Add a node to the measurement. */ addNode(node: figma.Node & figma.HasBoundingBox): void { if (node.visible === false) { return; } const box = node.absoluteRenderBounds || node.absoluteBoundingBox; this.#minX = Math.min(this.#minX, box.x); this.#maxX = Math.max(this.#maxX, box.x + box.width); this.#minY = Math.min(this.#minY, box.y); this.#maxY = Math.max(this.#maxY, box.y + box.height); } /** * Returns a bounding box for added nodes. */ measure(): figma.Rectangle { return { x: Number.isFinite(this.#minX) ? this.#minX : NaN, y: Number.isFinite(this.#minY) ? this.#minY : NaN, width: Number.isFinite(this.#maxX) && Number.isFinite(this.#minX) ? this.#maxX - this.#minX : NaN, height: Number.isFinite(this.#maxY) && Number.isFinite(this.#minY) ? this.#maxY - this.#minY : NaN, }; } }
-
-
-
@@ -0,0 +1,779 @@import { attr, className, el, on, style, svg } from "../dom"; import * as figma from "../figma"; import { roundTo } from "../math"; import { type Preferences } from "../preferences"; import { effect, Signal } from "../signal"; import { BoundingBoxMeasurement } from "./BoundingBoxMeasurement"; import { getDistanceGuides } from "./distanceGuide"; import { getRenderBoundingBox } from "./getRenderBoundingBox"; import * as TooltipLayer from "./TooltipLayer"; // <State diagram> // // ┌──────────────────┐ // │ keyup │ // │ │ // ┌─────▼────┐ keydown ┌───┴──┐ // │ Disabled ├─────────► Idle │ // └─────▲────┘ └─┬───▲┘ // │ pointerdown │ │ // │ │ │ pointerup // │ ┌──▼───┴───┐ // │ │ Dragging │ // │ └─────┬────┘ // │ keyup │ // └───────────────────┘ const enum DragState { Disabled = 0, Idle, Dragging, } export class FrameCanvas { static get styles(): string { return ( /* css */ ` .fc-viewport { --tooltip-font-size: var(--guide-tooltip-font-size); position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column-reverse; background-color: var(--canvas-bg); } .fc-canvas { position: absolute; top: 50%; left: 50%; flex: 1; overflow: visible; } .fc-rendered-image { position: absolute; top: 0; left: 0; overflow: hidden; } .fc-guide-canvas { position: absolute; overflow: visible; fill: none; stroke-width: calc(var(--guide-thickness) / var(--_scale, 1)); pointer-events: none; z-index: calc(var(--z-index) + 3); } .fc-guide-selection-layer { stroke: var(--guide-selected-color); } .fc-tooltip-selection-layer { --tooltip-bg: var(--guide-selected-tooltip-bg); --tooltip-fg: var(--guide-selected-tooltip-fg); } .fc-guide-hover-layer { stroke: var(--guide-color); } .fc-tooltip-hover-layer { --tooltip-bg: var(--guide-tooltip-bg); --tooltip-fg: var(--guide-tooltip-fg); } .fc-hitbox-layer, .fc-hitbox { position: absolute; } .fc-hitbox-layer { top: 0; left: 0; } .fc-hitbox[data-select-mute] { pointer-events: none; } ` + TooltipLayer.styles ); } static DRAG_MODE_KEY = " "; #container: HTMLElement; #canvas = el("div", [className("fc-canvas")]); #viewport: HTMLElement; #hitboxToNodeMap: WeakMap<Element, figma.Node> = new WeakMap(); #x = 0; #y = 0; #scale = 1; #preferences!: Readonly<Preferences>; #selected: Signal<figma.Node | null>; #hovered = new Signal<figma.Node | null>(null); #dragState = new Signal<DragState>(DragState.Disabled); #isActive = false; get container() { return this.#container; } constructor( preferences: Signal<Readonly<Preferences>>, selected: Signal<figma.Node | null>, container: HTMLElement = el("div"), ) { effect(() => { this.#preferences = preferences.get(); }); this.#container = container; this.#selected = selected; this.#viewport = el( "div", [ className("fc-viewport"), // Some UA defaults to passive (breaking but they did it anyway). // This component prevents every native wheel behavior on it. on("wheel", this.#onWheel, { passive: false }), on("pointerdown", this.#onPointerDown), on("pointerup", this.#onPointerUp), on("pointermove", this.#onPointerMove), on("pointerover", this.#onPointerOver), on("pointerout", this.#onPointerLeave), ], [this.#canvas], ); this.#container.appendChild(this.#viewport); } /** * Render a Figma frame to a DOM element. * * @param nodes * @param renderedImages */ render( nodes: readonly figma.Node[], renderedImages: Map<string, string>, backgroundColor?: figma.Color, ): void { this.clear(); const bbox = new BoundingBoxMeasurement(); const hitboxLayer = el("div", [className("fc-hitbox-layer")], []); for (const child of nodes) { for (const node of figma.walk(child)) { if (!figma.hasBoundingBox(node)) { continue; } bbox.addNode(node); const renderedImage = renderedImages.get(node.id); if (renderedImage) { const box = getRenderBoundingBox(node); const img = el("img", [ attr("src", renderedImage), className("fc-rendered-image"), style({ left: box.x + "px", top: box.y + "px", width: box.width + "px", height: box.height + "px", }), ]); this.#canvas.appendChild(img); } const { x, y, width, height } = node.absoluteBoundingBox; const radius: { topLeft: number; topRight: number; bottomRight: number; bottomLeft: number; } = figma.hasRadius(node) ? { topLeft: node.cornerRadius, topRight: node.cornerRadius, bottomRight: node.cornerRadius, bottomLeft: node.cornerRadius, } : figma.hasRadii(node) ? { topLeft: node.rectangleCornerRadii[0], topRight: node.rectangleCornerRadii[1], bottomRight: node.rectangleCornerRadii[2], bottomLeft: node.rectangleCornerRadii[3], } : { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0, }; const hitbox = el( "div", [ className("fc-hitbox"), attr("data-node-id", node.id), style({ top: y + "px", left: x + "px", width: width + "px", height: height + "px", "border-top-left-radius": radius.topLeft + "px", "border-top-right-radius": radius.topRight + "px", "border-bottom-right-radius": radius.bottomRight + "px", "border-bottom-left-radius": radius.bottomLeft + "px", }), ], [], ); this.#hitboxToNodeMap.set(hitbox, node); hitboxLayer.appendChild(hitbox); } } const boundingRect = bbox.measure(); requestAnimationFrame(() => { const viewportSize = this.#viewport.getBoundingClientRect(); this.#scale = Math.min( viewportSize.width / boundingRect.width, viewportSize.height / boundingRect.height, ) * 0.75; this.#applyTransform(); }); if (backgroundColor) { const { r, g, b, a } = backgroundColor; this.#viewport.style.backgroundColor = `rgb(${(r * 0xff) | 0} ${ (g * 0xff) | 0 } ${(b * 0xff) | 0} / ${a})`; } this.#canvas.style.width = boundingRect.width + "px"; this.#canvas.style.height = boundingRect.height + "px"; hitboxLayer.style.width = boundingRect.width + "px"; hitboxLayer.style.height = boundingRect.height + "px"; this.#canvas.appendChild(hitboxLayer); this.#x = -boundingRect.x; this.#y = -boundingRect.y; const hoverGuideLayer = svg("g", [className("fc-guide-hover-layer")]); const hoverTooltipLayer = new TooltipLayer.TooltipLayer( svg("g", [className("fc-tooltip-hover-layer")]), ); const selectionGuideLayer = svg("g", [ className("fc-guide-selection-layer"), ]); const selectionTooltipLayer = new TooltipLayer.TooltipLayer( svg("g", [className("fc-tooltip-selection-layer")]), ); this.#canvas.appendChild( svg( "svg", [ className("fc-guide-canvas"), attr( "viewBox", [ boundingRect.x, boundingRect.y, boundingRect.width, boundingRect.height, ].join(" "), ), style({ left: boundingRect.x + "px", top: boundingRect.y + "px", width: boundingRect.width + "px", height: boundingRect.height + "px", }), ], [ hoverGuideLayer, selectionGuideLayer, selectionTooltipLayer.container, hoverTooltipLayer.container, ], ), ); // Draw guides on select effect(() => { const selected = this.#selected.get(); selectionGuideLayer.replaceChildren(); this.#drawGuide(selected, selectionGuideLayer); selectionTooltipLayer.clear(); if (selected && figma.hasBoundingBox(selected)) { const { absoluteBoundingBox: { x, y, width, height }, } = selected; selectionTooltipLayer.show( `${roundTo(width, this.#preferences.decimalPlaces)} × ${roundTo( height, this.#preferences.decimalPlaces, )}`, x + width * 0.5, y + height, TooltipLayer.BOTTOM, ); } // Disable selected hitbox so a user can click backward elements with // exact same position/size. if (selected) { const hitbox = hitboxLayer.querySelector( `[data-node-id="${selected.id}"]`, ); if (hitbox) { hitbox.setAttribute("data-select-mute", ""); return () => { hitbox.removeAttribute("data-select-mute"); }; } } }); // Draw guides on hover effect(() => { const selected = this.#selected.get(); const hovered = this.#hovered.get(); if (!hovered) { return; } hoverGuideLayer.replaceChildren(); this.#drawGuide(hovered, hoverGuideLayer); if (selected) { hoverTooltipLayer.clear(); this.#drawDistance( selected, hovered, hoverGuideLayer, hoverTooltipLayer, ); } return () => { hoverGuideLayer.replaceChildren(); hoverTooltipLayer.clear(); }; }); // Change cursor based on drag state effect(() => { switch (this.#dragState.get()) { case DragState.Dragging: { document.body.style.cursor = "grabbing"; return () => { document.body.style.cursor = "auto"; }; } case DragState.Idle: { document.body.style.cursor = "grab"; return () => { document.body.style.cursor = "auto"; }; } } }); this.#isActive = true; this.#applyTransform(); } /** * Single-element update queue for viewport CSS transform. */ #transformQueue: string | null = null; #applyTransform() { // Schedule an update for next frame. // Probably it's safe to schedule without `if` guard, but there is no reason // to push unnecessary no-op callbacks to the RAF queue. if (!this.#transformQueue) { requestAnimationFrame(() => { if (!this.#transformQueue) { return; } this.#canvas.style.transform = this.#transformQueue; this.#canvas.style.setProperty("--_scale", this.#scale.toPrecision(5)); this.#transformQueue = null; }); } // prettier-ignore // Prettier breaks down this into fucking ugly multiline code if we omit the above line, // but I don't wanna add an unnecessary runtime computation (`[...].join("\n")`) // just because source code is ugly. It seems newer Prettier versions changed this // behaviour so I should try it. Still no option to disable this feature, though. this.#transformQueue = `translate(-50%, -50%) scale(${this.#scale}) translate(${this.#x}px, ${this.#y}px)`; } /** * Clear the viewport. */ clear(): void { this.#canvas.replaceChildren(); this.#isActive = false; } /** * Reset the canvas state. * This method does not clear the canvas. */ reset(): void { this.#x = 0; this.#y = 0; this.#scale = 1; this.#applyTransform(); } #drawGuide(node: figma.Node | null, layer: SVGElement): void { if (!node || !figma.hasBoundingBox(node)) { return; } const { x, y, width, height } = node.absoluteBoundingBox; const radius: { topLeft: number; topRight: number; bottomRight: number; bottomLeft: number; } = figma.hasRadius(node) ? { topLeft: node.cornerRadius, topRight: node.cornerRadius, bottomRight: node.cornerRadius, bottomLeft: node.cornerRadius, } : figma.hasRadii(node) ? { topLeft: node.rectangleCornerRadii[0], topRight: node.rectangleCornerRadii[1], bottomRight: node.rectangleCornerRadii[2], bottomLeft: node.rectangleCornerRadii[3], } : { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0, }; // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d // [M] ... Move to // [L] ... Line to // [A] ... Arc to // [Z] ... Close path const moveTo = (tx: number, ty: number) => `M${x + tx},${y + ty}`; const lineTo = (tx: number, ty: number) => `L${x + tx},${y + ty}`; const arcTo = (r: number, tx: number, ty: number) => `A${r},${r} 0 0 1 ${x + tx},${y + ty}`; const boxPath = [ moveTo(radius.topLeft, 0), lineTo(width - radius.topRight, 0), arcTo(radius.topRight, width, radius.topRight), lineTo(width, height - radius.bottomRight), arcTo(radius.bottomRight, width - radius.bottomRight, height), lineTo(radius.bottomLeft, height), arcTo(radius.bottomLeft, 0, height - radius.bottomLeft), lineTo(0, radius.topLeft), arcTo(radius.topLeft, radius.topLeft, 0), "Z", ].join(" "); const guide = svg("path", [attr("d", boxPath)], []); layer.appendChild(guide); } #drawDistance( from: figma.Node, to: figma.Node, guideLayer: SVGElement, tooltipLayer?: TooltipLayer.TooltipLayer, ): void { if (!figma.hasBoundingBox(from) || !figma.hasBoundingBox(to)) { return; } const guides = getDistanceGuides( from.absoluteBoundingBox, to.absoluteBoundingBox, ); guides.forEach(({ points, bisector }) => { const hl = Math.abs(points[0].x - points[1].x); const vl = Math.abs(points[0].y - points[1].y); if (hl === 0 && vl === 0) { return null; } guideLayer.appendChild( svg( "line", [ attr("x1", points[0].x.toString()), attr("y1", points[0].y.toString()), attr("x2", points[1].x.toString()), attr("y2", points[1].y.toString()), ], [], ), ); tooltipLayer?.show( roundTo(Math.max(hl, vl), this.#preferences.decimalPlaces).toString(10), hl > vl ? (points[0].x + points[1].x) * 0.5 : points[0].x, vl > hl ? (points[0].y + points[1].y) * 0.5 : points[0].y, hl > vl ? TooltipLayer.BOTTOM : TooltipLayer.RIGHT, ); if (bisector) { guideLayer.appendChild( svg( "line", [ attr("x1", bisector[0].x.toString()), attr("y1", bisector[0].y.toString()), attr("x2", bisector[1].x.toString()), attr("y2", bisector[1].y.toString()), style({ "stroke-dasharray": "calc(4px / var(--_scale))", }), attr("shape-rendering", "geometricPrecision"), ], [], ), ); } }); } select(node: figma.Node | null): void { this.#selected.set(node); } connectedCallback() { document.addEventListener("keydown", this.#onKeyDown); document.addEventListener("keyup", this.#onKeyUp); } disconnectedCallback() { document.removeEventListener("keydown", this.#onKeyDown); document.removeEventListener("keyup", this.#onKeyUp); } #onPointerDown = (ev: MouseEvent) => { if (this.#dragState.once() !== DragState.Idle) { return; } ev.preventDefault(); ev.stopPropagation(); this.#dragState.set(DragState.Dragging); }; #onPointerUp = (ev: MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); if (this.#dragState.once() !== DragState.Dragging) { const node = (ev.target && ev.target instanceof Element && this.#hitboxToNodeMap.get(ev.target)) || null; this.select(node); return; } this.#dragState.set(DragState.Idle); }; #onPointerMove = (ev: MouseEvent) => { // Performs pan when middle button is pressed or component is in Dragging state. // // 4 ... Auxiliary button (usually the mouse wheel button or middle button) // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons if (!(ev.buttons & 4 || this.#dragState.once() === DragState.Dragging)) { return; } ev.preventDefault(); this.#x += ev.movementX / this.#scale; this.#y += ev.movementY / this.#scale; this.#applyTransform(); }; #onPointerOver = (ev: Event) => { if (!ev.target || !(ev.target instanceof Element)) { return; } const node = this.#hitboxToNodeMap.get(ev.target); if (!node) { return; } ev.stopPropagation(); this.#hovered.set(node); }; #onPointerLeave = (_ev: Event) => { this.#hovered.set(null); }; #onWheel = (ev: WheelEvent) => { if (!this.#isActive) { return; } ev.preventDefault(); if (ev.ctrlKey) { // Performs zoom when ctrl key is pressed. let { deltaY } = ev; switch (ev.deltaMode) { // DOM_DELTA_LINE case 1: { // Hard-coded because it's nearly impossible to obtain scroll amount in pixel // when UA uses DOM_DELTA_LINE (technically possible but it's too hacky and // comes with huge performance penalty). deltaY *= 15; break; } // DOM_DELTA_PAGE case 2: { // Honestly, I don't know how to deal with this one... // 100 because it's larger than 15 :) deltaY *= 100; break; } } const prevScale = this.#scale; this.#scale *= 1 - deltaY / ((1000 - this.#preferences.viewportZoomSpeed) * 0.5); // Calling layout-read method on every `wheel` event is not desirable. // While `getBoundingClientRect` in here runs immediately according to Chrome performance // profiler (Firefox profiler is hot garbage), not accessing layout-related // properties and methods is easy to estimate and optimise. // However, this call is necessary due to the stupid standard provides no way to // explicitly limit `wheel` event target and/or prevent `wheel` action happening from // a particular element, like `touch-action`. Because of this shitty situation, // the `offsetX` and `offsetY` change their value semantics ("where is the origin?"") // based on now-hovered element. So most of the time the origin point would be // `.fc-rendered-image`'s left-top point, not `.fc-viewport`'s one. // Subtracting boundingClientRect's x/y from `MouseEvent.clientX/Y` is the // simplest and most perfomant way to calculate "offsetX/Y for currentTarget" I can // think of. If there is a better way to compute a relative pointer position // inside `.fc-viewport` without perfomance compromise, you should rewrite these // logic and remove this comment to bring calm and peace to the project. const viewport = this.#viewport.getBoundingClientRect(); const [offsetX, offsetY] = !ev.target || ev.target === ev.currentTarget ? [ev.offsetX, ev.offsetY] : [ev.clientX - viewport.x, ev.clientY - viewport.y]; const pointerOffsetX = offsetX - viewport.width * 0.5; const pointerOffsetY = offsetY - viewport.height * 0.5; // Performs pan to archive "zoom at the pointer" behavior. this.#x += pointerOffsetX / this.#scale - pointerOffsetX / prevScale; this.#y += pointerOffsetY / this.#scale - pointerOffsetY / prevScale; } else { // Performs pan otherwise (to be close to native behavior) // Adjusting panSpeed in order to make panSpeed=500 to match to the Figma's one. const speed = this.#preferences.viewportPanSpeed * 0.002; this.#x -= (ev.deltaX * speed) / this.#scale; this.#y -= (ev.deltaY * speed) / this.#scale; } this.#applyTransform(); }; #onKeyDown = (ev: KeyboardEvent) => { if (ev.key !== FrameCanvas.DRAG_MODE_KEY) { return; } ev.preventDefault(); ev.stopPropagation(); if (this.#dragState.once() === DragState.Disabled) { this.#dragState.set(DragState.Idle); } }; #onKeyUp = (ev: KeyboardEvent) => { if (ev.key !== FrameCanvas.DRAG_MODE_KEY) { return; } ev.preventDefault(); ev.stopPropagation(); this.#dragState.set(DragState.Disabled); }; }
-
-
-
@@ -0,0 +1,100 @@import { attr, className, svg } from "../dom"; export const CENTER = 0x0; export const LEFT = 0x1; export const RIGHT = 0x2; export const TOP = 0x10; export const BOTTOM = 0x20; export const styles = /* css */ ` .tl-bg { stroke: none; fill: var(--tooltip-bg); } .tl-text { font-size: var(--tooltip-font-size); stroke: none; fill: var(--tooltip-fg); } `; export class TooltipLayer { #container: SVGElement; get container(): SVGElement { return this.#container; } constructor(container: SVGElement = svg("g")) { this.#container = container; } show(text: string, x: number, y: number, placement: number) { // 0 ... center, 1 ... left, 2 ... right const hp = placement & 0xf; // 0 ... center, 1 ... top, 2 ... bottom const vp = (placement & 0xf0) >> 4; // Elements should be added to DOM tree as soon as it created: // `SVGGraphicsElement.getBBox` does not work for orphan element (does not throw an error!) const group = svg("g"); this.#container.appendChild(group); const bg = svg("rect", [attr("rx", "2"), className("tl-bg")]); group.appendChild(bg); const el = svg( "text", [ attr("text-anchor", "middle"), // By default, `<text>` locates it's baseline on `y`. // This attribute changes that stupid default attr("dominant-baseline", "central"), className("tl-text"), ], [text], ); group.appendChild(el); const bbox = el.getBBox(); const margin = bbox.height * 0.25; const vPadding = bbox.height * 0.15; const hPadding = bbox.height * 0.25; const px = hp === 1 ? x - bbox.width * 0.5 - (margin + hPadding) : hp === 2 ? x + bbox.width * 0.5 + (margin + hPadding) : x; const py = vp === 1 ? y - bbox.height * 0.5 - (margin + vPadding) : vp === 2 ? y + bbox.height * 0.5 + (margin + vPadding) : y; el.setAttribute("x", px.toString()); el.setAttribute("y", py.toString()); bg.setAttribute("x", (px - bbox.width * 0.5 - hPadding).toString()); bg.setAttribute("y", (py - bbox.height * 0.5 - vPadding).toString()); bg.setAttribute("width", (bbox.width + hPadding * 2).toString()); bg.setAttribute("height", (bbox.height + vPadding * 2).toString()); group.style.transform = `scale(calc(1 / var(--_scale)))`; const tox = hp === 1 ? x - margin : hp === 2 ? x + margin : x; const toy = vp === 1 ? y - margin : vp === 2 ? y + margin : y; group.style.transformOrigin = `${tox}px ${toy}px`; } clear() { this.#container.replaceChildren(); } }
-
-
-
@@ -0,0 +1,212 @@import { describe, expect, it } from "vitest"; import type * as figma from "../figma"; import { getRenderBoundingBox } from "./getRenderBoundingBox"; describe("getRenderBoundingBox", () => { it("Should accumulate blur effect radius", () => { const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, effects: [ { type: "LAYER_BLUR", radius: 5, visible: true, }, ], }; expect(getRenderBoundingBox(node)).toMatchObject({ x: -5, y: -5, width: 110, height: 110, }); }); it("Should accumulate the size of drop shadow effect", () => { const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, effects: [ { type: "DROP_SHADOW", radius: 2, visible: true, offset: { x: 3, y: 3, }, color: { r: 0, g: 0, b: 0, a: 0.3 }, blendMode: "NORMAL", }, ], }; expect(getRenderBoundingBox(node)).toMatchObject({ x: 0, y: 0, width: 105, height: 105, }); }); it("Should skip invisible effects", () => { const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, effects: [ { type: "LAYER_BLUR", radius: 5, visible: false, }, { type: "DROP_SHADOW", radius: 2, visible: false, offset: { x: 5, y: 0, }, color: { r: 0, g: 0, b: 0, a: 0.3 }, blendMode: "NORMAL", }, ], }; expect(getRenderBoundingBox(node)).toMatchObject({ x: 0, y: 0, width: 100, height: 100, }); }); it("Should include desendants' effects too", () => { const grandChild: figma.Node & figma.HasBoundingBox & figma.HasEffects = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, effects: [ { type: "DROP_SHADOW", radius: 5, visible: true, offset: { x: 5, y: 5, }, color: { r: 0, g: 0, b: 0, a: 0.3 }, blendMode: "NORMAL", }, ], }; const child: figma.Node & figma.HasChildren & figma.HasBoundingBox & figma.HasEffects = { type: "DUMMY", id: "DUMMY", name: "DUMMY", effects: [ { type: "LAYER_BLUR", radius: 3, visible: true, }, ], absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, children: [grandChild], }; const parent: figma.Node & figma.HasChildren & figma.HasBoundingBox = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, children: [child], }; expect(getRenderBoundingBox(parent)).toMatchObject({ x: -3, y: -3, width: 113, height: 113, }); }); it("Should skip node without bounding box", () => { const child: figma.Node & figma.HasEffects = { type: "DUMMY", id: "DUMMY", name: "DUMMY", effects: [ { type: "LAYER_BLUR", radius: 3, visible: true, }, ], }; const parent: figma.Node & figma.HasChildren & figma.HasBoundingBox = { type: "DUMMY", id: "DUMMY", name: "DUMMY", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100, }, children: [child], }; expect(getRenderBoundingBox(parent)).toMatchObject({ x: 0, y: 0, width: 100, height: 100, }); }); });
-
-
-
@@ -0,0 +1,108 @@import * as figma from "../figma"; // Intermediate value, context object. interface MinMaxXY { minX: number; maxX: number; minY: number; maxY: number; } /** * Calculate the size and position of where to put an API rendered image. * Ealier API did not return `absoluteRenderBounds`, so in order to place an API rendered image * to correct position, client need to measure the effect radius of LAYER_BLUR and DROP_SHADOW. */ export function getRenderBoundingBox( node: figma.Node & figma.HasBoundingBox, ): figma.Rectangle { if (node.absoluteRenderBounds) { return node.absoluteRenderBounds; } let current: MinMaxXY | null = null; for (const target of figma.walk(node)) { if (target.visible === false || !figma.hasBoundingBox(target)) { continue; } const minmax = calculateRenderingBoundingBox(target); if (!current) { current = minmax; continue; } current.minX = Math.min(current.minX, minmax.minX); current.minY = Math.min(current.minY, minmax.minY); current.maxX = Math.max(current.maxX, minmax.maxX); current.maxY = Math.max(current.maxY, minmax.maxY); } return current ? { x: current.minX, y: current.minY, width: current.maxX - current.minX, height: current.maxY - current.minY, } : node.absoluteBoundingBox; } function calculateRenderingBoundingBox( node: figma.Node & figma.HasBoundingBox, ): MinMaxXY { if (!figma.hasEffects(node)) { return { minX: node.absoluteBoundingBox.x, maxX: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width, minY: node.absoluteBoundingBox.y, maxY: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height, }; } // If the frame has effects, the size of rendered image is larger than frame's size // because of rendered effects. const margins = { top: 0, right: 0, bottom: 0, left: 0 }; for (const effect of node.effects) { if (effect.visible === false) { continue; } if (figma.isShadowEffect(effect) && effect.type === "DROP_SHADOW") { margins.left = Math.max(margins.left, effect.radius - effect.offset.x); margins.top = Math.max(margins.top, effect.radius - effect.offset.y); margins.right = Math.max(margins.right, effect.radius + effect.offset.x); margins.bottom = Math.max( margins.bottom, effect.radius + effect.offset.y, ); continue; } if (effect.type === "LAYER_BLUR") { margins.top = Math.max(margins.top, effect.radius); margins.right = Math.max(margins.right, effect.radius); margins.bottom = Math.max(margins.bottom, effect.radius); margins.left = Math.max(margins.left, effect.radius); continue; } // Other effects does not changes a size of rendered image } return { minX: node.absoluteBoundingBox.x - margins.left, maxX: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width + margins.right, minY: node.absoluteBoundingBox.y - margins.top, maxY: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height + margins.bottom, }; }
-
-
src/dom.ts (new)
-
@@ -0,0 +1,290 @@import { effect, Signal } from "./signal"; export type ElementFn<T extends HTMLElement | SVGElement> = (el: T) => void; // TypeScript somehow rejects Signal<HTMLElement | SVGELement> (maybe due to their web typings?) // A | B | C -> Signal<A> | Signal<B> | Signal<C> type ToSignal<T> = T extends any ? Signal<T> : never; type AttrValue = string | boolean; /** * Set or remove an attribute. * * @param name - An attribute name. * @param value - `string` is set as-is. `boolean` follows HTML's boolean attribute semantics: * `true` sets an empty string and `false` removes the attribute itself. */ export function attr<T extends HTMLElement | SVGElement>( name: string, value: AttrValue | ToSignal<AttrValue> | Signal<AttrValue>, ): ElementFn<T> { return (el) => { if (value instanceof Signal) { effect(() => { const v = value.get(); if (typeof v === "string") { el.setAttribute(name, v); } else if (v === true) { el.setAttribute(name, ""); } else { el.removeAttribute(name); } }); } else if (typeof value === "string") { el.setAttribute(name, value); } else if (value === true) { el.setAttribute(name, ""); } }; } /** * Assign a value to the property. */ export function prop<T extends HTMLElement | SVGElement, K extends keyof T>( key: K, value: T[K] | Signal<T[K]>, ): ElementFn<T> { return (el) => { if (value instanceof Signal) { effect(() => { el[key] = value.get(); }); } else { el[key] = value; } }; } /** * Invoke the given callback after `requestAnimationFrame`. * * Provided as an escape-hatch for DOM quirks. * * @example * el("select", [ * raf(compute(() => (el) => { * el.value = value.get(); * })) * ]) */ export function raf<T extends HTMLElement | SVGElement>( f: ((el: T) => void) | Signal<(el: T) => void>, ): ElementFn<T> { return (el) => { requestAnimationFrame(() => { if (f instanceof Signal) { effect(() => { f.get()(el); }); } else { f(el); } }); }; } /** * Set element's inline style. * * This is not same as `HTMLElement.style.foo`: under the hood, `CSSStyleDeclaration.setProperty` is used. * Hence, property name must be hyphen-cased. * Property value can be one of `string`, `null`, or `undefined`. * * - `string` ... Sets the value to the property. * - `null` ... Removes the property from stylesheet. * - `undefined` ... Does nothing. * * When used with Signal, use of `undefined` would lead to confusing behavor. * * ```ts * const border = signal<string | undefined>("1px solid #000"); * style({ border }); * border.set(undefined) * ``` * * In the above code, setting `undefined` does nothing: the actual border property's value * is still `1px solid #000`. In order to avoid these kind of surprising situation, use of * `string` is always recommended. * * ```ts * const border = signal("1px solid #000"); * style({ border }); * border.set("none") * ``` */ export function style<T extends HTMLElement | SVGElement>( style: Record< string, string | null | undefined | Signal<string | null | undefined> >, ): ElementFn<T> { return (el) => { for (const key in style) { const value = style[key]; if (typeof value === "string") { el.style.setProperty(key, value); } else if (value instanceof Signal) { effect(() => { const v = value.get(); if (typeof v === "string") { el.style.setProperty(key, v); } else if (v === null) { el.style.removeProperty(key); } }); } else if (value === null) { el.style.removeProperty(key); } } }; } /** * Sets a class or a list of classes. * * This function does not accept Signal. * Use `data-*` attribute or property for dynamic values. */ export function className<T extends HTMLElement | SVGElement>( ...value: readonly string[] ): ElementFn<T> { return (el) => { el.classList.add(...value); }; } /** * Attach an event listener. */ export function on<T extends HTMLElement, E extends keyof HTMLElementEventMap>( eventName: E, callback: (event: HTMLElementEventMap[E]) => void, options?: AddEventListenerOptions, ): ElementFn<HTMLElement>; export function on<T extends SVGElement, E extends keyof SVGElementEventMap>( eventName: E, callback: (event: SVGElementEventMap[E]) => void, options?: AddEventListenerOptions, ): ElementFn<SVGElement>; export function on< T extends HTMLElement | SVGElement, E extends keyof HTMLElementEventMap | keyof SVGElementEventMap, >( eventName: E, callback: (event: (HTMLElementEventMap & SVGElementEventMap)[E]) => void, options?: AddEventListenerOptions, ): ElementFn<T> { return (el) => { // @ts-expect-error: This is a limit coming from TS being dirty hack illusion. el.addEventListener(eventName, callback, options); }; } type ElementChild = HTMLElement | SVGElement | string | null | undefined; function appendChild(parent: Element, child: ElementChild): void { if (child === null || typeof child === "undefined") { return; } if (typeof child === "string") { parent.appendChild(document.createTextNode(child)); } else { parent.appendChild(child); } } // `el` is parameterized because a function to create an `Element` depends on Element types. (sub-types?) function provision<T extends HTMLElement | SVGElement>( el: T, attrs: readonly ElementFn<T>[], children: readonly ( | ElementChild | ToSignal<ElementChild> | Signal<ElementChild> )[], ): T { for (const attr of attrs) { attr(el); } for (const child of children) { if (child instanceof Signal) { const start = document.createTextNode(""); const end = document.createTextNode(""); el.appendChild(start); el.appendChild(end); effect(() => { const childNode = child.get(); const prevNode = !start.nextSibling || start.nextSibling === end ? null : start.nextSibling; if (childNode === null || typeof childNode === "undefined") { if (prevNode) { prevNode.remove(); } return; } const node = typeof childNode === "string" ? document.createTextNode(childNode) : childNode; if (prevNode) { prevNode.replaceWith(node); } else { el.insertBefore(node, end); } }); } else { appendChild(el, child); } } return el; } /** * Create a HTML element. */ export function el<TagName extends keyof HTMLElementTagNameMap>( tagName: TagName, attrs: readonly ElementFn<HTMLElementTagNameMap[TagName]>[] = [], children: readonly ( | ElementChild | ToSignal<ElementChild> | Signal<ElementChild> )[] = [], ): HTMLElementTagNameMap[TagName] { return provision(document.createElement(tagName), attrs, children); } /** * Create a SVG element. * * You don't need to set `xmlns` attribute for elements created by this function. */ export function svg<TagName extends keyof SVGElementTagNameMap>( tagName: TagName, attrs: readonly ElementFn<SVGElementTagNameMap[TagName]>[] = [], children: readonly ( | ElementChild | ToSignal<ElementChild> | Signal<ElementChild> )[] = [], ): SVGElementTagNameMap[TagName] { return provision( document.createElementNS("http://www.w3.org/2000/svg", tagName), attrs, children, ); }
-
-
src/figma.ts (new)
-
@@ -0,0 +1,649 @@// This module defines data types used in Figma API. // The purpose of these type definition is for our rendering and inspector // panel only. Properties not used in those feature would be omitted. /** * https://www.figma.com/developers/api#color-type */ export interface Color { readonly r: number; readonly g: number; readonly b: number; readonly a: number; } function isColor(x: unknown): x is Color { return ( !!x && typeof x === "object" && "r" in x && Number.isFinite(x.r) && "g" in x && Number.isFinite(x.g) && "b" in x && Number.isFinite(x.b) && "a" in x && Number.isFinite(x.a) ); } /** * https://www.figma.com/developers/api#blendmode-type */ export type BlendMode = | "PASS_THROUGH" | "NORMAL" | "DARKEN" | "MULTIPLY" | "LINEAR_BURN" | "COLOR_BURN" | "LIGHTEN" | "SCREEN" | "LINEAR_DODGE" | "COLOR_DODGE" | "OVERLAY" | "SOFT_LIGHT" | "HARD_LIGHT" | "DIFFERENCE" | "EXCLUSION" | "HUE" | "SATURATION" | "COLOR" | "LUMINOSITY"; function isBlendMode(x: unknown): x is BlendMode { switch (x) { case "PASS_THROUGH": case "NORMAL": case "DARKEN": case "MULTIPLY": case "LINEAR_BURN": case "COLOR_BURN": case "LIGHTEN": case "SCREEN": case "LINEAR_DODGE": case "COLOR_DODGE": case "OVERLAY": case "SOFT_LIGHT": case "HARD_LIGHT": case "DIFFERENCE": case "EXCLUSION": case "HUE": case "SATURATION": case "COLOR": case "LUMINOSITY": return true; default: return false; } } /** * https://www.figma.com/developers/api#vector-type */ export interface Vector { readonly x: number; readonly y: number; } function isVector(x: unknown): x is Vector { return ( !!x && typeof x === "object" && "x" in x && Number.isFinite(x.x) && "y" in x && Number.isFinite(x.y) ); } export interface Effect { readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR" | string; readonly visible: boolean; readonly radius: number; } function isEffect(x: unknown): x is Effect { return ( !!x && typeof x === "object" && "type" in x && typeof x.type === "string" && "visible" in x && typeof x.visible === "boolean" && "radius" in x && typeof x.radius === "number" ); } export interface ShadowEffect { readonly type: "INNER_SHADOW" | "DROP_SHADOW"; readonly visible: boolean; readonly radius: number; readonly color: Color; readonly blendMode: BlendMode; readonly offset: Vector; readonly spread?: number; readonly showShadowBehindNode?: boolean; } export function isShadowEffect(x: Effect): x is ShadowEffect { if (x.type !== "INNER_SHADOW" && x.type !== "DROP_SHADOW") { return false; } return ( "color" in x && isColor(x.color) && "blendMode" in x && isBlendMode(x.blendMode) && "offset" in x && isVector(x.offset) && (!("spread" in x) || Number.isFinite(x.spread)) && (!("showShadowBehindNode" in x) || typeof x.showShadowBehindNode === "boolean") ); } /** * https://www.figma.com/developers/api#rectangle-type */ export interface Rectangle { readonly x: number; readonly y: number; readonly width: number; readonly height: number; } function isRectangle(x: unknown): x is Rectangle { return ( !!x && typeof x === "object" && "x" in x && typeof x.x === "number" && "y" in x && typeof x.y === "number" && "width" in x && typeof x.width === "number" && "height" in x && typeof x.height === "number" ); } /** * https://www.figma.com/developers/api#colorstop-type */ export interface ColorStop { readonly position: number; readonly color: Color; } function isColorStop(x: unknown): x is ColorStop { return ( !!x && typeof x === "object" && "position" in x && typeof x.position === "number" && "color" in x && isColor(x.color) ); } export interface PaintGlobalProperties { readonly type: string; /** * @default true */ readonly visible?: boolean; /** * @default 1 */ readonly opacity?: number; readonly blendMode: BlendMode; } function isPaintGlobalProperties(x: unknown): x is PaintGlobalProperties { return ( !!x && typeof x === "object" && "type" in x && typeof x.type === "string" && (!("visible" in x) || typeof x.visible === "boolean") && (!("opacity" in x) || typeof x.opacity === "number") && "blendMode" in x && isBlendMode(x.blendMode) ); } export interface SolidPaint extends PaintGlobalProperties { readonly type: "SOLID"; readonly color: Color; } function isSolidPaint(x: PaintGlobalProperties): x is SolidPaint { return x.type === "SOLID" && "color" in x && isColor(x.color); } export interface GradientPaint extends PaintGlobalProperties { readonly type: | "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND"; readonly gradientHandlePositions: readonly [Vector, Vector, Vector]; readonly gradientStops: readonly ColorStop[]; } const GRADIENT_TYPE_PATTERN = /^GRADIENT_(LINEAR|RADIAL|ANGULAR|DIAMOND)$/; function isGradientPaint(x: PaintGlobalProperties): x is GradientPaint { return ( GRADIENT_TYPE_PATTERN.test(x.type) && "gradientHandlePositions" in x && Array.isArray(x.gradientHandlePositions) && x.gradientHandlePositions.every(isVector) && "gradientStops" in x && Array.isArray(x.gradientStops) && x.gradientStops.every(isColorStop) ); } export interface ImagePaint extends PaintGlobalProperties { readonly type: "IMAGE"; readonly scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; } function isImagePaint(x: PaintGlobalProperties): x is ImagePaint { if (!("scaleMode" in x)) { return false; } switch (x.scaleMode) { case "FILL": case "FIT": case "TILE": case "STRETCH": return true; default: return false; } } export interface OtherPaint extends PaintGlobalProperties { readonly type: "VIDEO" | "EMOJI"; } function isOtherPaint(x: PaintGlobalProperties): x is OtherPaint { switch (x.type) { case "VIDEO": case "EMOJI": return true; default: return false; } } /** * https://www.figma.com/developers/api#paint-type */ export type Paint = SolidPaint | GradientPaint | ImagePaint | OtherPaint; function isPaint(x: unknown): x is Paint { if (!isPaintGlobalProperties(x)) { return false; } return ( isSolidPaint(x) || isGradientPaint(x) || isImagePaint(x) || isOtherPaint(x) ); } interface HasBackgroundColor { /** * Background color of the canvas. */ backgroundColor: Color; } export function hasBackgroundColor( node: Node, ): node is Node & HasBackgroundColor { return "backgroundColor" in node && isColor(node.backgroundColor); } interface HasFills { /** * @default [] */ readonly fills: Paint[]; } export function hasFills(node: Node): node is Node & HasFills { return ( "fills" in node && Array.isArray(node.fills) && node.fills.every(isPaint) ); } interface HasStroke { /** * @default [] */ readonly strokes: readonly Paint[]; readonly strokeWeight: number; readonly strokeAlign: "INSIDE" | "OUTSIDE" | "CENTER"; /** * @default [] */ readonly strokeDashes?: readonly number[]; } export function hasStroke(node: Node): node is Node & HasStroke { if (!("strokeAlign" in node)) { return false; } switch (node.strokeAlign) { case "INSIDE": case "OUTSIDE": case "CENTER": break; default: return false; } return ( "strokes" in node && Array.isArray(node.strokes) && node.strokes.every(isPaint) && "strokeWeight" in node && Number.isFinite(node.strokeWeight) && (!("strokeDashes" in node) || (Array.isArray(node.strokeDashes) && node.strokeDashes.every(Number.isFinite))) ); } export interface HasEffects { effects: readonly (Effect | ShadowEffect)[]; } export function hasEffects(node: Node): node is Node & HasEffects { return ( "effects" in node && Array.isArray(node.effects) && node.effects.every(isEffect) ); } interface HasCharacters { readonly characters: string; } export function hasCharacters(node: Node): node is Node & HasCharacters { return "characters" in node && typeof node.characters === "string"; } // https://www.figma.com/developers/api#typestyle-type interface HasTypeStyle { readonly style: { readonly fontFamily: string; readonly fontPostScriptName?: string; readonly italic: boolean; readonly fontWeight: number; readonly fontSize: number; readonly textCase?: | "ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED"; readonly textDecoration?: "NONE" | "STRIKETHROUGH" | "UNDERLINE"; readonly textAlignHorizontal: "LEFT" | "RIGHT" | "CENTER" | "JUSTIFIED"; readonly letterSpacing: number; readonly lineHeightPx: number; readonly lineHeightPercentFontSize?: number; readonly lineHeightUnit: "PIXELS" | "FONT_SIZE_%" | "INTRINSIC_%"; }; } export function hasTypeStyle(node: Node): node is Node & HasTypeStyle { return ( "style" in node && typeof node.style === "object" && !!node.style && "fontFamily" in node.style && typeof node.style.fontFamily === "string" ); } export interface HasBoundingBox { readonly absoluteBoundingBox: Rectangle; /** * Old data may not have this property. */ readonly absoluteRenderBounds?: Rectangle; } export function hasBoundingBox(node: Node): node is Node & HasBoundingBox { return ( "absoluteBoundingBox" in node && isRectangle(node.absoluteBoundingBox) && (!("absoluteRenderBounds" in node) || isRectangle(node.absoluteRenderBounds)) ); } export interface HasPadding { paddingTop: number; paddingRight: number; paddingBottom: number; paddingLeft: number; } export function hasPadding(node: Node): node is Node & HasPadding { return ( "paddingTop" in node && Number.isFinite(node.paddingTop) && "paddingRight" in node && Number.isFinite(node.paddingRight) && "paddingBottom" in node && Number.isFinite(node.paddingBottom) && "paddingLeft" in node && Number.isFinite(node.paddingLeft) ); } export interface HasLegacyPadding { horizontalPadding: number; verticalPadding: number; } export function hasLegacyPadding(node: Node): node is Node & HasLegacyPadding { return ( "horizontalPadding" in node && Number.isFinite(node.horizontalPadding) && "verticalPadding" in node && Number.isFinite(node.verticalPadding) ); } export interface HasChildren { readonly children: readonly Node[]; } export function hasChildren(node: Node): node is Node & HasChildren { return ( "children" in node && Array.isArray(node.children) && node.children.every(isNode) ); } interface HasRadius { readonly cornerRadius: number; } export function hasRadius(node: Node): node is Node & HasRadius { return "cornerRadius" in node && typeof node.cornerRadius === "number"; } interface HasRadii { readonly rectangleCornerRadii: readonly [number, number, number, number]; } export function hasRadii(node: Node): node is Node & HasRadii { return ( "rectangleCornerRadii" in node && Array.isArray(node.rectangleCornerRadii) && node.rectangleCornerRadii.length === 4 ); } export type KnownNodeType = | "DOCUMENT" | "CANVAS" | "FRAME" | "GROUP" | "SECTION" | "VECTOR" | "BOOLEAN_OPERATION" | "STAR" | "LINE" | "ELLIPSE" | "REGULAR_POLYGON" | "RECTANGLE" | "TABLE" | "TABLE_CELL" | "TEXT" | "SLICE" | "COMPONENT" | "COMPONENT_SET" | "INSTANCE" | "STICKY" | "SHAPE_WITH_TEXT" | "CONNECTOR" | "WASHI_TAPE"; /** * https://www.figma.com/developers/api#global-properties */ export interface Node<Type extends string = KnownNodeType | string> { readonly id: string; readonly name: string; /** * @default true */ readonly visible?: boolean; readonly type: Type; } export function isNode(x: unknown): x is Node { return ( typeof x === "object" && !!x && "id" in x && typeof x.id === "string" && "name" in x && typeof x.name === "string" && (!("visible" in x) || typeof x.visible === "boolean") && "type" in x && typeof x.type === "string" ); } /** * Walk over the node and its descendants. * Iterator is superior to the common callback-style walk function: * - Ability to abort the traverse with standard language feature (return, break) * - No implicit call timing convention - TypeScript's poor inference engine completely * ignores assignments even if the callback function will be called immediately. * The inference system is built upon unrealistic illusion. * https://github.com/microsoft/TypeScript/issues/9998 * - (subjective) `for ~ of` over iterator is way more readable and easier to grasp than * function invocation with unknown callback. When will the callback be invoked? * What will happen when the callback function returned something? * * @param node - The root node to start traversing from. This function returns this parameter as a result at the very first. * @example * for (const node of walk(root)) { * console.log(node.id) * } */ export function* walk(node: Node): Generator<Node, void, undefined> { yield node; if (hasChildren(node)) { for (const child of node.children) { for (const iter of walk(child)) { yield iter; } } } } export type Canvas = Node & HasChildren & HasBackgroundColor; function isCanvas(node: Node): node is Canvas { return ( node.type === "CANVAS" && hasChildren(node) && hasBackgroundColor(node) ); } /** * Returns an iterator of CANVAS nodes. */ export function* getCanvases(node: Node): Generator<Canvas, void, undefined> { if (isCanvas(node)) { yield node; // CANVAS cannot be nested, so safe to quit lookup return; } if (!hasChildren(node)) { return; } for (const child of node.children) { for (const iter of getCanvases(child)) { yield iter; } } } export interface GetFileNodesResponse { readonly name: string; readonly lastModified: string; readonly nodes: Record< string, { readonly document: Node; } >; } export interface GetFileResponse { readonly name: string; readonly lastModified: string; readonly document: Node & HasChildren; }
-
-
-
@@ -1,5 +1,5 @@import { FigspecFrameViewer } from "./FigspecViewer/FigspecFrameViewer"; import { FigspecFileViewer } from "./FigspecViewer/FigspecFileViewer"; import { FigspecFrameViewer } from "./FigspecFrameViewer"; import { FigspecFileViewer } from "./FigspecFileViewer"; if (!customElements.get("figspec-file-viewer")) { customElements.define("figspec-file-viewer", FigspecFileViewer);
-
-
src/math.spec.ts (new)
-
@@ -0,0 +1,15 @@import { describe, expect, it } from "vitest"; import { roundTo } from "./math"; describe("roundTo", () => { it("Should round to int without `at` parameter", () => { expect(roundTo(1.23456789)).toBe(1); expect(roundTo(9.87654321)).toBe(10); }); it("Should round to specified decimal", () => { expect(roundTo(1.23456789, 2)).toBe(1.23); expect(roundTo(9.87654321, 2)).toBe(9.88); }); });
-
-
src/math.ts (new)
-
@@ -0,0 +1,9 @@export function roundTo(x: number, to: number = 0) { if (to === 0) { return Math.round(x); } const p = Math.pow(10, to); return Math.round(x * p) / p; }
-
-
src/preferences.ts (new)
-
@@ -0,0 +1,71 @@export interface Preferences { /** * For future changes. */ version: 1; /** * How many decimal places are shown? * NOTE: Some properties uses fixed decimal places. */ decimalPlaces: number; viewportZoomSpeed: number; viewportPanSpeed: number; /** * What unit should be used in generated CSS code? */ lengthUnit: "px" | "rem"; rootFontSizeInPx: number; /** * How to display a color in generated CSS code? */ cssColorNotation: // #rrggbb / #rrggbbaa | "hex" // rgb(r g b / a) | "rgb" // hsl(h s l / a) | "hsl" // color(srgb r g b / a) | "color-srgb" // color(display-p3 r g b / a) // For showing colors in Display P3 color space, where the Figma file uses Display P3. // If a Figma file uses sRGB, colors are stretched to full Display P3 space. | "display-p3" // color(display-p3 r g b / a) // For showing colors in Display P3 color space, where the Figma file uses sRGB. // If a Figma file uses Display P3, colors are unnaturally compressed to sRGB space (same as other sRGB notations). | "srgb-to-display-p3"; enableColorPreview: boolean; } export const defaultPreferenecs = Object.freeze<Preferences>({ version: 1, decimalPlaces: 2, viewportPanSpeed: 500, viewportZoomSpeed: 500, cssColorNotation: "hex", lengthUnit: "px", rootFontSizeInPx: 16, enableColorPreview: true, }); export function isEqual( a: Readonly<Preferences>, b: Readonly<Preferences>, ): boolean { if (a.version !== b.version) { return false; } for (const [key, value] of Object.entries(a)) { if (b[key as keyof typeof a] !== value) { return false; } } return true; }
-
-
src/signal.spec.ts (new)
-
@@ -0,0 +1,177 @@import { describe, expect, it, vi } from "vitest"; import { compute, Signal, effect } from "./signal"; describe("Signal", () => { it("Should execute callback", () => { const a = new Signal(5); const b = new Signal(6); expect(compute(() => a.get() + b.get()).get()).toBe(11); }); it("Should update callback", () => { const s = new Signal("foo"); expect(s.get()).toBe("foo"); s.set("bar"); expect(s.get()).toBe("bar"); }); it("Should run effect", () => { const s = new Signal("foo"); const fn = vi.fn(); effect(() => { fn(s.get()); }); s.set("bar"); s.set("baz"); expect(fn).toHaveBeenNthCalledWith(1, "foo"); expect(fn).toHaveBeenNthCalledWith(2, "bar"); expect(fn).toHaveBeenNthCalledWith(3, "baz"); expect(fn).toHaveBeenCalledTimes(3); }); it("Should discard child computation", () => { const s = new Signal("foo"); const first = vi.fn(); const second = vi.fn(); const third = vi.fn(); effect(() => { first(); s.get(); effect(() => { second(); s.get(); effect(() => { third(); }); }); }); s.set("bar"); s.set("baz"); expect(first).toHaveBeenCalledTimes(3); expect(second).toHaveBeenCalledTimes(3); expect(third).toHaveBeenCalledTimes(3); }); it("Should run effect minimally", () => { const s1 = new Signal("foo"); const s2 = new Signal(1); const first = vi.fn(); const second = vi.fn(); const third = vi.fn(); const fourth = vi.fn(); effect(() => { first(); s1.get(); effect(() => { second(); const s3 = compute(() => { const v2 = s2.get(); third(v2); return v2; }); effect(() => { fourth(s3.get()); }); }); }); s2.set(2); s2.set(3); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); expect(third).toHaveBeenNthCalledWith(1, 1); expect(third).toHaveBeenNthCalledWith(2, 2); expect(third).toHaveBeenNthCalledWith(3, 3); expect(third).toHaveBeenCalledTimes(3); expect(fourth).toHaveBeenNthCalledWith(1, 1); expect(fourth).toHaveBeenNthCalledWith(2, 2); expect(fourth).toHaveBeenNthCalledWith(3, 3); expect(fourth).toHaveBeenCalledTimes(3); }); it("Signal#once Should not update dependency graph", () => { const s = new Signal(1); const fn = vi.fn(); effect(() => { fn(s.once()); }); s.set(2); expect(fn).toBeCalledWith(1); expect(fn).toBeCalledTimes(1); }); it("Should skip re-calculation if the value is same", () => { const s = new Signal(1); const fn = vi.fn(); effect(() => { fn(s.get()); }); s.set(1); s.set(1); expect(fn).toBeCalledWith(1); expect(fn).toBeCalledTimes(1); }); it("Should run cleanup function", () => { const s1 = new Signal(1); const s2 = new Signal("foo"); const f1 = vi.fn(); const f2 = vi.fn(); const f3 = vi.fn(); effect(() => { s1.get(); effect(() => { return () => { f2(s2.get()); }; }); effect(() => { return () => { f3(); }; }); return () => { f1(s1.get()); }; }); s1.set(2); s2.set("bar"); expect(f1).toHaveBeenCalledOnce(); expect(f1).toHaveBeenCalledWith(1); expect(f2).toHaveBeenCalledTimes(1); expect(f3).toHaveBeenCalledTimes(1); }); });
-
-
src/signal.ts (new)
-
@@ -0,0 +1,123 @@const stack: Computation[] = []; const DEPENDANTS = Symbol(); export class Signal<T> { [DEPENDANTS] = new Set<Computation>(); constructor(private value: T) {} set(value: T) { if (value === this.value) { return; } for (const computation of this[DEPENDANTS]) { computation.runCleanup(); } this.value = value; const dependantComputations = Array.from(this[DEPENDANTS]); this[DEPENDANTS].clear(); for (const computation of dependantComputations) { if (computation.isDestroyed) { continue; } computation.run(true); } } /** * Get a current value of the signal. * This method updates dependency graph. */ get(): T { if (stack.length > 0) { this[DEPENDANTS].add(stack[stack.length - 1]); } return this.value; } /** * Get a current value of the signal. * This method does not update dependency graph. */ once(): T { return this.value; } } type ComputationFn = () => void | (() => void); class Computation { isDestroyed: boolean = false; childComputations = new Set<Computation>(); cleanup: ComputationFn | null = null; constructor(private fn: ComputationFn) {} run(isolated = false): void { this.runCleanup(); this.destroyChildren(); if (stack.length > 0 && !isolated) { stack[stack.length - 1].childComputations.add(this); } stack.push(this); try { this.cleanup = this.fn() ?? null; } finally { stack.pop(); } } runCleanup(): void { for (const child of this.childComputations) { child.runCleanup(); } if (!this.cleanup) { return; } this.cleanup(); this.cleanup = null; } destroy() { this.runCleanup(); this.isDestroyed = true; this.destroyChildren(); } destroyChildren() { for (const child of this.childComputations) { child.destroy(); } this.childComputations.clear(); } } export function compute<T>(f: () => T): Signal<T> { const signal = new Signal(undefined as any); effect(() => { signal.set(f()); }); return signal; } export function effect(f: ComputationFn): void { new Computation(f).run(); }
-
-
src/state.ts (new)
-
@@ -0,0 +1,89 @@const enum Types { Idle, SetupError, Loaded, Canvas, Info, Preferences, } interface Idle { type: Types.Idle; } export const idle: Idle = { type: Types.Idle, }; export function isIdle(state: State<unknown>): state is Idle { return state.type === Types.Idle; } interface SetupError { type: Types.SetupError; error: Error; } export function setupError(error: Error): SetupError { return { type: Types.SetupError, error, }; } export function isSetupError(state: State<unknown>): state is SetupError { return state.type === Types.SetupError; } interface Loaded<T> { type: Types.Loaded; data: T; } export function loaded<T>(data: T): Loaded<T> { return { type: Types.Loaded, data, }; } export function isLoaded<T>(state: State<T>): state is Loaded<T> { return state.type === Types.Loaded; } export type State<T> = Idle | SetupError | Loaded<T>; interface Canvas { type: Types.Canvas; } export const canvas: Canvas = { type: Types.Canvas, }; export function isCanvas(state: LoadedState): state is Canvas { return state.type === Types.Canvas; } interface Info { type: Types.Info; } export const info: Info = { type: Types.Info }; export function isInfo(state: LoadedState): state is Info { return state.type === Types.Info; } interface Preferences { type: Types.Preferences; } export const preferences: Preferences = { type: Types.Preferences }; export function isPreferences(state: LoadedState): state is Preferences { return state.type === Types.Preferences; } export type LoadedState = Canvas | Info | Preferences;
-
-
src/styles.ts (new)
-
@@ -0,0 +1,160 @@import { styles as uiStyles } from "./ui/styles"; const commonHostStyles = /* css */ ` :host { /* Palette from: https://yeun.github.io/open-color/ https://github.com/yeun/open-color/blob/3a716ee1f5ff5456db33cb8a6e964afdca1e7bc3/LICENSE */ --color-gray-0: 248 249 250; --color-gray-1: 241 243 245; --color-gray-2: 233 236 239; --color-gray-3: 222 226 230; --color-gray-5: 173 181 189; --color-gray-6: 134 142 150; --color-gray-7: 73 80 87; --color-gray-8: 52 58 64; --color-gray-9: 33 37 41; --color-red-4: 255 135 135; --color-red-9: 201 42 42; --color-grape-3: 229 153 247; --color-grape-8: 156 54 181; --color-blue-9: 24 100 171; --color-cyan-3: 102 217 232; --color-cyan-8: 12 133 153; --color-green-3: 140 233 154; --color-green-8: 47 158 68; --color-yellow-2: 255 236 153; --color-orange-8: 232 89 12; --color-orange-9: 217 72 15; /* Typography */ --font-family-sans: var(--figspec-font-family-sans, system-ui, ui-sans-serif, sans-serif); --font-family-mono: var(--figspec-font-family-mono, monospace); --font-size: var(--figspec-font-size, 1rem); /* Action */ --default-action-overlay: rgb(var(--color-gray-8) / 0.1); --default-action-border: rgb(var(--color-gray-5) / 0.5); --default-action-horizontal-padding: 6px; --default-action-vertical-padding: 4px; --action-overlay: var(--figspec-action-overlay, var(--default-action-overlay)); --action-border: var(--figspec-action-border, var(--default-action-border)); --action-horizontal-padding: var(--figspec-action-horizontal-padding, var(--default-action-horizontal-padding)); --action-vertical-padding: var(--figspec-action-vertical-padding, var(--default-action-vertical-padding)); --action-radius: var(--figspec-action-radius, 4px); /* Canvas */ --default-canvas-bg: #e5e5e5; --canvas-bg: var(--figspec-canvas-bg, var(--default-canvas-bg)); /* Base styles */ --default-fg: rgb(var(--color-gray-9)); --default-bg: rgb(var(--color-gray-0)); --default-subtle-fg: rgb(var(--color-gray-7)); --default-success-fg: rgb(var(--color-green-8)); --default-error-fg: rgb(var(--color-red-9)); --fg: var(--figspec-fg, var(--default-fg)); --bg: var(--figspec-bg, var(--default-bg)); --subtle-fg: var(--figspec-subtle-fg, var(--default-subtle-fg)); --success-fg: var(--figspec-success-fg, var(--default-success-fg)); --error-fg: var(--figspec-error-fg, var(--default-error-fg)); --z-index: var(--figspec-viewer-z-index, 0); /* Code, syntax highlighting */ /* https://yeun.github.io/open-color/ */ --default-code-bg: rgb(var(--color-gray-2)); --default-code-text: rgb(var(--color-gray-9)); --default-code-keyword: rgb(var(--color-grape-8)); --default-code-string: rgb(var(--color-green-8)); --default-code-number: rgb(var(--color-cyan-8)); --default-code-list: rgb(var(--color-gray-6)); --default-code-comment: rgb(var(--color-gray-5)); --default-code-literal: var(--default-code-number); --default-code-function: var(--default-code-keyword); --default-code-unit: rgb(var(--color-orange-8)); --code-bg: var(--figspec-code-bg, var(--default-code-bg)); --code-text: var(--figspec-code-text, var(--default-code-text)); --code-keyword: var(--figspec-code-keyword, var(--default-code-keyword)); --code-string: var(--figspec-code-string, var(--default-code-string)); --code-number: var(--figspec-code-number, var(--default-code-number)); --code-list: var(--figspec-code-list, var(--default-code-list)); --code-comment: var(--figspec-code-comment, var(--default-code-comment)); --code-literal: var(--figspec-code-literal, var(--default-code-literal)); --code-function: var(--figspec-code-function, var(--default-code-function)); --code-unit: var(--figspec-code-unit, var(--default-code-unit)); /* Panel */ --panel-border: 1px solid rgb(var(--color-gray-5) / 0.5); --panel-radii: 2px; --guide-thickness: var(--figspec-guide-thickness, 1.5px); --guide-color: var(--figspec-guide-color, rgb(var(--color-orange-9))); --guide-selected-color: var( --figspec-guide-selected-color, rgb(var(--color-blue-9)) ); --guide-tooltip-fg: var(--figspec-guide-tooltip-fg, rgb(var(--color-gray-0))); --guide-selected-tooltip-fg: var( --figspec-guide-selected-tooltip-fg, rgb(var(--color-gray-0)) ); --guide-tooltip-bg: var( --figspec-guide-tooltip-bg, var(--guide-color) ); --guide-selected-tooltip-bg: var( --figspec-guide-selected-tooltip-bg, var(--guide-selected-color) ); --guide-tooltip-font-size: var( --figspec-guide-tooltip-font-size, calc(var(--font-size) * 0.8) ); position: relative; display: block; font-size: var(--font-size); font-family: var(--font-family-sans); background-color: var(--bg); color: var(--fg); user-select: none; overflow: hidden; z-index: var(--z-index); } @media (prefers-color-scheme: dark) { :host { --default-action-overlay: rgb(var(--color-gray-0) / 0.15); --default-fg: rgb(var(--color-gray-0)); --default-bg: rgb(var(--color-gray-9)); --default-subtle-fg: rgb(var(--color-gray-5)); --default-success-fg: rgb(var(--color-green-3)); --default-error-fg: rgb(var(--color-red-4)); --default-code-bg: rgb(var(--color-gray-8)); --default-code-text: rgb(var(--color-gray-1)); --default-code-keyword: rgb(var(--color-grape-3)); --default-code-string: rgb(var(--color-green-3)); --default-code-number: rgb(var(--color-cyan-3)); --default-code-list: rgb(var(--color-gray-3)); --default-code-comment: rgb(var(--color-gray-6)); --default-code-unit: rgb(var(--color-yellow-2)); } } @media (pointer: coarse) { :host { --default-action-horizontal-padding: 8px; --default-action-vertical-padding: 6px; } } `; export const styles = commonHostStyles + uiStyles;
-
-
src/ui/empty/empty.ts (new)
-
@@ -0,0 +1,73 @@import { className, el } from "../../dom"; import { fullscreenPanel } from "../fullscreenPanel/fullscreenPanel"; export const styles = /* css */ ` .em-container { margin: 0 auto; max-width: calc(var(--font-size) * 30); margin-top: calc(var(--font-size) * 2); display: flex; flex-direction: column; align-items: flex-start; gap: 1em; user-select: text; } .em-title { margin: 0; font-size: calc(var(--font-size) * 1.2); font-weight: bold; color: var(--fg); } .em-body { margin: 0; width: 100%; } .em-body p { margin: 0; margin-bottom: calc(var(--font-size) * 1); font-size: calc(var(--font-size) * 1); color: var(--subtle-fg); } .em-body pre { align-self: stretch; display: block; width: 100%; font-family: var(--font-family-mono); font-size: calc(var(--font-size) * 0.9); padding: 8px; tab-size: 2; background-color: var(--code-bg); border-radius: var(--panel-radii); color: var(--code-fg); overflow: auto; } `; type ElementChildren = NonNullable<Parameters<typeof el>[2]>; interface EmptyProps { title: ElementChildren; body: ElementChildren; } export function empty({ title, body }: EmptyProps): HTMLElement { return fullscreenPanel({ body: [ el( "div", [className("em-container")], [ el("p", [className("em-title")], title), el("div", [className("em-body")], body), ], ), ], }); }
-
-
-
@@ -0,0 +1,111 @@import { attr, el, className, svg } from "../../dom"; import { effect } from "../../signal"; import { iconButton } from "../iconButton/iconButton"; export const styles = /* css */ ` .fp-root { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: var(--bg); color: var(--fg); } .fp-close { position: absolute; right: 8px; top: 8px; background-color: inherit; border-radius: var(--panel-radii); z-index: calc(var(--z-index) + 5); opacity: 0.5; } .fp-close:hover, .fp-close:focus-within { opacity: 1; } .fp-close-icon { font-size: calc(var(--font-size) * 1.1); } .fp-body { max-width: 100%; max-height: 100%; padding: 16px; box-sizing: border-box; overflow-y: auto; } `; interface FullscreenPanelProps { body: Parameters<typeof el>[2]; onClose?(): void; } export function fullscreenPanel({ body, onClose, }: FullscreenPanelProps): HTMLElement { effect(() => { if (!onClose) { return; } const onEsc = (ev: KeyboardEvent) => { if (ev.key !== "Escape") { return; } ev.preventDefault(); ev.stopPropagation(); onClose(); }; document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("keydown", onEsc); }; }); return el( "div", [className("fp-root")], [ onClose ? el( "div", [className("fp-close")], [ iconButton({ title: "Close", icon: svg( "svg", [ className("fp-close-icon"), attr("viewBox", "0 0 10 10"), attr("fill", "none"), attr("stroke", "currentColor"), ], [svg("path", [attr("d", "M2,2 L8,8 M8,2 L2,8")])], ), onClick() { onClose(); }, }), ], ) : null, el("div", [className("fp-body")], body), ], ); }
-
-
-
@@ -0,0 +1,52 @@import { attr, className, el, on } from "../../dom"; export const styles = /* css */ ` .ib-button { appearance: none; display: inline-flex; border: none; padding: var(--action-vertical-padding) var(--action-horizontal-padding); margin: 0; font-size: calc(var(--font-size) * 0.9); background: transparent; border-radius: var(--action-radius); color: inherit; cursor: pointer; outline: none; } .ib-button:hover { background: var(--action-overlay); } .ib-button:focus { outline: none; } .ib-button:focus-visible { outline: 2px solid SelectedItem; } .ib-button > svg { width: auto; height: 1em; } `; interface IconButtonProps { title: string; icon: SVGElement; onClick(): void; } export function iconButton({ title, icon, onClick, }: IconButtonProps): HTMLElement { return el( "button", [className("ib-button"), attr("title", title), on("click", onClick)], [icon], ); }
-
-
-
@@ -0,0 +1,83 @@import { attr, className, el } from "../../dom"; import { compute, Signal } from "../../signal"; export const styles = /* css */ ` .ii-list { padding: 0; margin: 0; user-select: text; } .ii-label { padding: 0; margin: 0; margin-bottom: 4px; font-size: calc(var(--font-size) * 0.8); font-weight: bold; color: var(--subtle-fg); } .ii-content { padding: 0; margin: 0; margin-bottom: 24px; font-size: var(--font-size); font-weight: normal; color: var(--fg); } `; type Children = NonNullable<Parameters<typeof el>[2]>; interface Item { label: Children[number]; content: Children; } export function infoItems( items: readonly (Item | Signal<Item | null> | null)[], ): HTMLElement { return el( "dl", [className("ii-list")], items .map((item) => { if (!item) { return []; } if (item instanceof Signal) { return [map(label, item), map(content, item)]; } return [label(item), content(item)]; }) .flat(), ); } function map<T, P>(f: (v: T) => P, s: Signal<T | null>): Signal<P | null> { return compute(() => { const v = s.get(); if (v === null) { return null; } return f(v); }); } function label(item: Item): HTMLElement { return el( "dt", [className("ii-label")], [item.label, el("span", [attr("aria-hidden", "true")], [":"])], ); } function content(item: Item): HTMLElement { return el("dd", [className("ii-content")], item.content); }
-
-
-
@@ -0,0 +1,189 @@import { attr, className, el, style } from "../../dom"; import { roundTo } from "../../math"; import { type Preferences } from "../../preferences"; import { type CSSStyle, type CSSStyleValue, CSSStyleValueTypes, } from "./cssgen/cssgen"; export const styles = /* css */ ` .cc-container { margin: 0; padding: 8px; background: var(--code-bg); border-radius: var(--panel-radii); color: var(--code-text); overflow: auto; user-select: text; } .cc-code { font-family: var(--font-family-mono); font-size: calc(var(--font-size) * 0.8); } .cc-color-preview { --_size: calc(var(--font-size) * 0.8); position: relative; display: inline-flex; width: var(--_size); height: var(--_size); border-radius: calc(var(--_size) / 6); margin: 0 4px; background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect x="0" y="0" width="10" height="10" fill="%23fff" /><rect x="5" y="0" width="5" height="5" fill="%23ccc" /><rect x="0" y="5" width="5" height="5" fill="%23ccc" /></svg>'); overflow: hidden; vertical-align: middle; } .cc-color-preview::after { content: ""; display: block; position: absolute; top: 0; right: 0; bottom: 0; left: 0; border: 1px solid rgb(var(--color-gray-5) / 0.7); background: var(--_bg, transparent); border-radius: inherit; } .cc-token-string { color: var(--code-string); } .cc-token-number { color: var(--code-number); } .cc-token-comment { color: var(--code-comment); font-style: italic; } .cc-token-keyword { color: var(--code-keyword); } .cc-token-list { color: var(--code-list); } .cc-token-function { color: var(--code-function); } .cc-token-unit { color: var(--code-unit); } `; export function cssCode( styles: readonly CSSStyle[], preferences: Readonly<Preferences>, ): HTMLElement { return el( "pre", [className("cc-container")], [ el( "code", [className("cc-code")], styles.map((style) => { return el( "span", [], [ style.propertyName, ": ", cssValue(style.value, preferences), ";\n", ], ); }), ), ], ); } function cssValue( value: CSSStyleValue, preferences: Readonly<Preferences>, ): HTMLElement { switch (value.type) { case CSSStyleValueTypes.Comment: return el( "span", [className("cc-token-comment")], ["/* ", value.text, " */"], ); case CSSStyleValueTypes.Color: return el( "span", [], [ el( "i", [ className("cc-color-preview"), style({ "--_bg": value.color }), attr("aria-hidden", "true"), ], [], ), cssValue(value.value, preferences), ], ); case CSSStyleValueTypes.Keyword: return el("span", [className("cc-token-keyword")], [value.ident]); case CSSStyleValueTypes.List: return el( "span", [], [ [cssValue(value.head, preferences)], ...value.tail.map((v) => [ el("span", [className("cc-token-list")], [value.separator]), cssValue(v, preferences), ]), ].flat(), ); case CSSStyleValueTypes.Number: const precision = value.precision ?? preferences.decimalPlaces; return el( "span", [className("cc-token-number")], [ roundTo(value.value, precision).toString(10), el("span", [className("cc-token-unit")], [value.unit || ""]), ], ); case CSSStyleValueTypes.String: return el( "span", [className("cc-token-string")], [`"`, value.value.replace(/"/g, `\\"`), `"`], ); case CSSStyleValueTypes.Literal: return el("span", [className("cc-token-literal")], [value.text]); case CSSStyleValueTypes.FunctionCall: return el( "span", [], [ el("span", [className("cc-token-function")], [value.functionName]), "(", cssValue(value.args, preferences), ")", ], ); case CSSStyleValueTypes.Unknown: return el("span", [className("cc-token-unknown")], [value.text]); } }
-
-
-
@@ -0,0 +1,93 @@export enum CSSStyleValueTypes { Color, Number, String, List, Keyword, Comment, Literal, FunctionCall, Unknown, } interface CSSStyleColorValue { type: CSSStyleValueTypes.Color; color: string; value: CSSStyleValue; } interface CSSStyleNumberValue { type: CSSStyleValueTypes.Number; value: number; precision?: number; unit?: string; } interface CSSStyleStringValue { type: CSSStyleValueTypes.String; value: string; } interface CSSStyleKeywordValue { type: CSSStyleValueTypes.Keyword; ident: string; } interface CSSStyleUnknownValue { type: CSSStyleValueTypes.Unknown; text: string; } interface CSSStyleFunctionCallValue { type: CSSStyleValueTypes.FunctionCall; functionName: string; args: CSSStyleValue; } interface CSSStyleLiteralValue { type: CSSStyleValueTypes.Literal; text: string; } interface CSSStyleListValue { type: CSSStyleValueTypes.List; head: CSSStyleValue; tail: CSSStyleValue[]; separator: string; } interface CSSStyleCommentInValue { type: CSSStyleValueTypes.Comment; text: string; } export type CSSStyleValue = | CSSStyleColorValue | CSSStyleNumberValue | CSSStyleStringValue | CSSStyleKeywordValue | CSSStyleUnknownValue | CSSStyleLiteralValue | CSSStyleFunctionCallValue | CSSStyleListValue | CSSStyleCommentInValue; export interface CSSStyle { propertyName: string; value: CSSStyleValue; }
-
-
-
@@ -0,0 +1,17 @@import { describe, expect, it } from "vitest"; import { isTransparent } from "./colors"; describe("#isTransparent", () => { it("Should return `true` for transparent black", () => { expect(isTransparent({ r: 0, g: 0, b: 0, a: 0 })).toBe(true); }); it("Should return `false` for non-transparent black", () => { expect(isTransparent({ r: 0, g: 0, b: 0, a: 0.5 })).toBe(false); }); it("Should return `false` for transparent white", () => { expect(isTransparent({ r: 1, g: 1, b: 1, a: 0 })).toBe(false); }); });
-
-
-
@@ -0,0 +1,80 @@import type * as figma from "../../../figma"; /** * Returns whether the given color is a _transparent black_ (value of the `transparent` keyword). * * https://www.w3.org/TR/css-color-3/#transparent */ export function isTransparent({ r, g, b, a }: figma.Color): boolean { return !r && !g && !b && !a; } // https://drafts.csswg.org/css-color-4/#predefined-sRGB function toLinearLight(c: number): number { const abs = Math.abs(c); return abs < 0.04045 ? c / 12.92 : (c < 0 ? -1 : 1) * Math.pow((abs + 0.055) / 1.055, 2.4); } // https://drafts.csswg.org/css-color-4/#color-conversion-code function gammaEncode(c: number): number { const abs = Math.abs(c); return abs > 0.0031308 ? (c < 0 ? -1 : 1) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055) : 12.92 * c; } type Vec3 = readonly [number, number, number]; export function mmul(a: readonly [Vec3, Vec3, Vec3], b: Vec3): Vec3 { return [ a[0][0] * b[0] + a[0][1] * b[1] + a[0][2] * b[2], a[1][0] * b[0] + a[1][1] * b[1] + a[1][2] * b[2], a[2][0] * b[0] + a[2][1] * b[1] + a[2][2] * b[2], ]; } // https://drafts.csswg.org/css-color-4/#color-conversion-code function srgbToXYZ(c: figma.Color): Vec3 { const r = toLinearLight(c.r); const g = toLinearLight(c.g); const b = toLinearLight(c.b); return mmul( [ [506752 / 1228815, 87881 / 245763, 12673 / 70218], [87098 / 409605, 175762 / 245763, 12673 / 175545], [7918 / 409605, 87881 / 737289, 1001167 / 1053270], ], [r, g, b], ); } // https://drafts.csswg.org/css-color-4/#color-conversion-code function xyzToDisplayP3(xyz: Vec3): figma.Color { const [r, g, b] = mmul( [ [446124 / 178915, -333277 / 357830, -72051 / 178915], [-14852 / 17905, 63121 / 35810, 423 / 17905], [11844 / 330415, -50337 / 660830, 316169 / 330415], ], xyz, ); return { r: gammaEncode(r), g: gammaEncode(g), b: gammaEncode(b), a: 1, }; } export function srgbToDisplayP3(srgb: figma.Color): figma.Color { return { ...xyzToDisplayP3(srgbToXYZ(srgb)), a: srgb.a, }; }
-
-
-
@@ -0,0 +1,3 @@export * from "./CSSStyle"; export { fromNode } from "./fromNode"; export { serializeStyle, serializeValue } from "./serialize";
-
-
-
@@ -0,0 +1,119 @@import { describe, expect, it } from "vitest"; import type * as figma from "../../../figma"; import { defaultPreferenecs, type Preferences } from "../../../preferences"; import { fromNode } from "./fromNode"; import { serializeStyle } from "./serialize"; describe("fromNode", () => { describe("padding", () => { it("Should have v/h padding rule for v/h paddings", () => { const input: figma.Node & figma.HasLegacyPadding = { id: "", name: "", type: "", horizontalPadding: 1, verticalPadding: 2, }; expect( fromNode(input, defaultPreferenecs).map((s) => serializeStyle(s, defaultPreferenecs), ), ).toContain("padding: 2px 1px;"); }); it("Should have single-value padding rule for v/h paddings are the same value", () => { const input: figma.Node & figma.HasLegacyPadding = { id: "", name: "", type: "", horizontalPadding: 5, verticalPadding: 5, }; expect( fromNode(input, defaultPreferenecs).map((s) => serializeStyle(s, defaultPreferenecs), ), ).toContain("padding: 5px;"); }); it("Should have four-value padding", () => { const input: figma.Node & figma.HasPadding = { id: "", name: "", type: "", paddingTop: 1, paddingRight: 2, paddingBottom: 3, paddingLeft: 4, }; expect( fromNode(input, defaultPreferenecs).map((s) => serializeStyle(s, defaultPreferenecs), ), ).toContain("padding: 1px 2px 3px 4px;"); }); }); describe("effects", () => { it("Should convert inner shadow effect", () => { const input: figma.Node & figma.HasEffects = { id: "", name: "", type: "", effects: [ { type: "INNER_SHADOW", radius: 5, spread: 1, visible: true, offset: { x: 3, y: 4 }, color: { r: 1, g: 1, b: 1, a: 1 }, blendMode: "NORMAL", } satisfies figma.ShadowEffect, ], }; const preferences: Preferences = { ...defaultPreferenecs, cssColorNotation: "hex", }; expect( fromNode(input, preferences).map((s) => serializeStyle(s, preferences)), ).toContain("box-shadow: 3px 4px 5px 1px #ffffff inset;"); }); it("Should skip invisible effects", () => { const input: figma.Node & figma.HasEffects = { id: "", name: "", type: "", effects: [ { type: "INNER_SHADOW", radius: 5, spread: 1, visible: false, offset: { x: 3, y: 4 }, color: { r: 1, g: 1, b: 1, a: 1 }, blendMode: "NORMAL", } satisfies figma.ShadowEffect, ], }; const preferences: Preferences = { ...defaultPreferenecs, cssColorNotation: "hex", }; expect( fromNode(input, preferences).map((s) => serializeStyle(s, preferences)), ).not.toContain("box-shadow: 3px 4px 5px 1px #ffffff inset;"); }); }); });
-
-
-
@@ -0,0 +1,786 @@import * as figma from "../../../figma"; import { type Preferences } from "../../../preferences"; import { isTransparent, srgbToDisplayP3 } from "./colors"; import { CSSStyle, CSSStyleValue, CSSStyleValueTypes } from "./CSSStyle"; import { getLinearGradientAngle } from "./gradient"; import { serializeValue } from "./serialize"; export function fromNode( node: figma.Node, preferences: Readonly<Preferences>, ): readonly CSSStyle[] { let styles: CSSStyle[] = []; if (figma.hasBoundingBox(node)) { styles.push( { propertyName: "width", value: px(node.absoluteBoundingBox.width, preferences), }, { propertyName: "height", value: px(node.absoluteBoundingBox.height, preferences), }, ); } if (figma.hasPadding(node)) { styles.push({ propertyName: "padding", value: padding( node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft, preferences, ), }); } else if (figma.hasLegacyPadding(node)) { styles.push({ propertyName: "padding", value: padding( node.verticalPadding, node.horizontalPadding, node.verticalPadding, node.horizontalPadding, preferences, ), }); } if (figma.hasTypeStyle(node)) { styles.push( { propertyName: "font-family", value: { type: CSSStyleValueTypes.String, value: node.style.fontFamily, }, }, { propertyName: "font-size", value: px(node.style.fontSize, preferences), }, { propertyName: "font-weight", value: { type: CSSStyleValueTypes.Number, value: node.style.fontWeight, precision: 0, }, }, { propertyName: "line-height", value: typeof node.style.lineHeightPercentFontSize === "number" ? { type: CSSStyleValueTypes.List, separator: " ", head: { type: CSSStyleValueTypes.Number, value: node.style.lineHeightPercentFontSize / 100, precision: 3, }, tail: [ { type: CSSStyleValueTypes.Comment, text: `or ${node.style.lineHeightPx}px`, }, ], } : px(node.style.lineHeightPx, preferences), }, { // NOTE: This CSS generation uses physical properties instead of logical properties, // due to Figma API does not have/return text flow related information. propertyName: "text-align", value: { type: CSSStyleValueTypes.Keyword, ident: node.style.textAlignHorizontal === "JUSTIFIED" ? "justify" : node.style.textAlignHorizontal.toLowerCase(), }, }, ); if (node.style.letterSpacing) { styles.push({ propertyName: "letter-spacing", value: px(node.style.letterSpacing, preferences), }); } if (node.style.italic) { styles.push({ propertyName: "font-style", value: { type: CSSStyleValueTypes.Keyword, ident: "italic", }, }); } // There is no equivalent `text-transform` value for `SMALL_CAPS(_FORCED)` switch (node.style.textCase) { case "LOWER": styles.push({ propertyName: "text-transform", value: { type: CSSStyleValueTypes.Keyword, ident: "lowercase", }, }); break; case "UPPER": styles.push({ propertyName: "text-transform", value: { type: CSSStyleValueTypes.Keyword, ident: "uppercase", }, }); break; case "TITLE": styles.push({ propertyName: "text-transform", value: { type: CSSStyleValueTypes.Keyword, ident: "capitalize", }, }); break; } } if (figma.hasStroke(node) && node.strokes[0]) { // Outputting `border-image` is impossible due to lacking of source value(s) for // `border-image-slice`. if (node.strokes[0].type === "SOLID") { styles.push({ propertyName: "border", value: { type: CSSStyleValueTypes.List, separator: " ", head: px(node.strokeWeight, preferences), tail: [ { type: CSSStyleValueTypes.Keyword, ident: "solid", }, figmaPaintToCssPaint(node.strokes[0], preferences), ], }, }); } } if (figma.hasFills(node) && node.fills.length > 0) { const props = fills(node, preferences); if (props.length > 0) { styles.push(...props); } } if (figma.hasRadius(node) && node.cornerRadius > 0) { styles.push({ propertyName: "border-radius", value: px(node.cornerRadius, preferences), }); } else if (figma.hasRadii(node)) { const [head, ...tail] = node.rectangleCornerRadii; styles.push({ propertyName: "border-radius", value: { type: CSSStyleValueTypes.List, head: px(head, preferences), tail: tail.map((value) => { return px(value, preferences); }), separator: " ", }, }); } if (figma.hasEffects(node)) { const { shadows, layerBlurs, bgBlurs } = node.effects.reduce<{ shadows: readonly figma.ShadowEffect[]; layerBlurs: readonly figma.Effect[]; bgBlurs: readonly figma.Effect[]; }>( ({ shadows, layerBlurs, bgBlurs }, effect) => { if (!effect.visible) { return { shadows, layerBlurs, bgBlurs }; } if (figma.isShadowEffect(effect)) { return { shadows: [...shadows, effect], layerBlurs, bgBlurs }; } switch (effect.type) { case "LAYER_BLUR": return { shadows, layerBlurs: [...layerBlurs, effect], bgBlurs }; case "BACKGROUND_BLUR": return { shadows, layerBlurs, bgBlurs: [...bgBlurs, effect] }; default: return { shadows, layerBlurs, bgBlurs }; } }, { shadows: [], layerBlurs: [], bgBlurs: [] }, ); if (shadows.length > 0) { const [head, ...tail] = shadows; styles.push({ propertyName: "box-shadow", value: { type: CSSStyleValueTypes.List, separator: ", ", head: shadow(head, preferences), tail: tail.map((s) => shadow(s, preferences)), }, }); } if (layerBlurs.length > 0) { const [head, ...tail] = layerBlurs; styles.push({ propertyName: "filter", value: { type: CSSStyleValueTypes.List, separator: " ", head: effectToBlur(head, preferences), tail: tail.map((blur) => effectToBlur(blur, preferences)), }, }); } if (bgBlurs.length > 0) { const [head, ...tail] = bgBlurs; styles.push({ propertyName: "backdrop-filter", value: { type: CSSStyleValueTypes.List, separator: " ", head: effectToBlur(head, preferences), tail: tail.map((blur) => effectToBlur(blur, preferences)), }, }); } } return styles; } function padding( top: number, right: number, bottom: number, left: number, preferences: Readonly<Preferences>, ): CSSStyleValue { if (top === bottom && right === left) { if (top !== right) { return { type: CSSStyleValueTypes.List, separator: " ", head: px(top, preferences), tail: [px(right, preferences)], }; } return px(top, preferences); } return { type: CSSStyleValueTypes.List, separator: " ", head: px(top, preferences), tail: [ px(right, preferences), px(bottom, preferences), px(left, preferences), ], }; } function effectToBlur( effect: figma.Effect, preferences: Readonly<Preferences>, ): CSSStyleValue { return { type: CSSStyleValueTypes.FunctionCall, functionName: "blur", args: px(effect.radius, preferences), }; } function shadow( shadow: figma.ShadowEffect, preferences: Readonly<Preferences>, ): CSSStyleValue { const tail: (CSSStyleValue | null)[] = [ px(shadow.offset.y, preferences), px(shadow.radius, preferences), shadow.spread ? px(shadow.spread, preferences) : null, withColorPreview(colorToValue(shadow.color, preferences), preferences), shadow.type === "INNER_SHADOW" ? { type: CSSStyleValueTypes.Keyword, ident: "inset", } : null, ]; return { type: CSSStyleValueTypes.List, separator: " ", head: px(shadow.offset.x, preferences), tail: tail.filter((value): value is CSSStyleValue => !!value), }; } function fills( node: figma.Node, preferences: Readonly<Preferences>, ): readonly CSSStyle[] { if (!figma.hasFills(node)) { return []; } const visibleFills = node.fills.filter((fill) => fill.visible !== false); if (!visibleFills.length) { return []; } if (node.type === "TEXT") { const fill = visibleFills[0]!; // Text fill can't be other than solid if (fill.type !== "SOLID") { return []; } return [ { propertyName: "color", value: withColorPreview( colorToValue(fill.color, preferences), preferences, ), }, ]; } // `fills` is bloated property: it is foreground color of TEXT, background color // of rectangle shaped nodes, and fill color of Vector (not limited to VECTOR) nodes. // In order to make generated code both correct and relatable, only support background // color usage. (Foreground handling is already done, see above) switch (node.type) { case "FRAME": case "COMPONENT": case "COMPONENT_SET": case "INSTANCE": case "RECTANGLE": break; default: return []; } const [head, ...tail] = visibleFills; if (tail.length === 0) { switch (head.type) { case "SOLID": { return [ { propertyName: "background-color", value: figmaPaintToCssPaint(head, preferences), }, ]; } case "IMAGE": case "GRADIENT_ANGULAR": case "GRADIENT_DIAMOND": case "GRADIENT_LINEAR": case "GRADIENT_RADIAL": { return [ { propertyName: "background-image", value: figmaPaintToCssPaint(head, preferences), }, ]; } default: { return []; } } } return [ { propertyName: "background", value: { type: CSSStyleValueTypes.List, separator: ", ", head: figmaPaintToCssPaint(head, preferences), tail: tail.map((paint) => figmaPaintToCssPaint(paint, preferences)), }, }, ]; } function withColorPreview( value: CSSStyleValue, preferences: Readonly<Preferences>, ): CSSStyleValue { if (!preferences.enableColorPreview) { return value; } return { type: CSSStyleValueTypes.Color, value, color: serializeValue(value, preferences), }; } function px(size: number, preferences: Readonly<Preferences>): CSSStyleValue { if (preferences.lengthUnit === "px") { return { type: CSSStyleValueTypes.Number, value: size, unit: "px", }; } return { type: CSSStyleValueTypes.Number, value: size / preferences.rootFontSizeInPx, unit: "rem", precision: 2 + preferences.decimalPlaces, }; } function figmaPaintToCssPaint( paint: figma.Paint, preferences: Readonly<Preferences>, ): CSSStyleValue { switch (paint.type) { case "SOLID": { const opacity = paint.opacity ?? 1; return withColorPreview( colorToValue( { ...paint.color, a: paint.color.a * opacity }, preferences, ), preferences, ); } case "EMOJI": case "VIDEO": { return { type: CSSStyleValueTypes.Comment, text: `This fill is unavailable as a CSS background (${paint.type})`, }; } case "IMAGE": { return { type: CSSStyleValueTypes.FunctionCall, functionName: "url", args: { type: CSSStyleValueTypes.Comment, text: "image file", }, }; } case "GRADIENT_LINEAR": { return withColorPreview( { type: CSSStyleValueTypes.FunctionCall, functionName: "linear-gradient", args: { type: CSSStyleValueTypes.List, separator: ", ", head: { type: CSSStyleValueTypes.Number, value: getLinearGradientAngle( paint.gradientHandlePositions[0], paint.gradientHandlePositions[1], ), precision: 2, unit: "deg", }, tail: paint.gradientStops.map((stop) => { return { type: CSSStyleValueTypes.List, separator: " ", head: withColorPreview( colorToValue(stop.color, preferences), preferences, ), tail: [ { type: CSSStyleValueTypes.Number, value: stop.position * 100, precision: 2, unit: "%", }, ], }; }), }, }, preferences, ); } case "GRADIENT_ANGULAR": case "GRADIENT_DIAMOND": case "GRADIENT_RADIAL": { return { type: CSSStyleValueTypes.Comment, text: "Not implemented", }; } } } function colorToValue( color: figma.Color, preferences: Readonly<Preferences>, ): CSSStyleValue { if (isTransparent(color)) { return { type: CSSStyleValueTypes.Keyword, ident: "transparent", }; } switch (preferences.cssColorNotation) { case "hex": { return toHex(color); } case "color-srgb": { return toColorSrgb(color); } case "rgb": { return toRgb(color); } case "hsl": { return toHsl(color); } case "display-p3": { return toDisplayP3(color); } case "srgb-to-display-p3": { return toDisplayP3SrgbSrc(color); } } } function toHex(color: figma.Color): CSSStyleValue { const r = (color.r * 0xff) | 0; const g = (color.g * 0xff) | 0; const b = (color.b * 0xff) | 0; const a = color.a; return { type: CSSStyleValueTypes.Literal, text: "#" + r.toString(16).padStart(2, "0") + g.toString(16).padStart(2, "0") + b.toString(16).padStart(2, "0") + (a === 1 ? "" : ((a * 0xff) | 0).toString(16).padStart(2, "0")), }; } function toRgb(color: figma.Color): CSSStyleValue { return { type: CSSStyleValueTypes.FunctionCall, functionName: "rgb", args: { type: CSSStyleValueTypes.List, separator: " ", head: { type: CSSStyleValueTypes.Number, value: color.r * 0xff, precision: 0, }, tail: [ { type: CSSStyleValueTypes.Number, value: color.g * 0xff, precision: 0, }, { type: CSSStyleValueTypes.Number, value: color.b * 0xff, precision: 0, }, { type: CSSStyleValueTypes.Literal, text: "/", }, { type: CSSStyleValueTypes.Number, value: color.a, precision: 2, }, ], }, }; } function toColorSrgb(color: figma.Color): CSSStyleValue { return { type: CSSStyleValueTypes.FunctionCall, functionName: "color", args: { type: CSSStyleValueTypes.List, separator: " ", head: { type: CSSStyleValueTypes.Keyword, ident: "srgb", }, tail: [ { type: CSSStyleValueTypes.Number, value: color.r, precision: 6, }, { type: CSSStyleValueTypes.Number, value: color.g, precision: 6, }, { type: CSSStyleValueTypes.Number, value: color.b, precision: 6, }, { type: CSSStyleValueTypes.Literal, text: "/", }, { type: CSSStyleValueTypes.Number, value: color.a, precision: 6, }, ], }, }; } /** * Generate `color(display-p3 r g b)` assuming the `color` being Display P3. */ function toDisplayP3(color: figma.Color): CSSStyleValue { return { type: CSSStyleValueTypes.FunctionCall, functionName: "color", args: { type: CSSStyleValueTypes.List, separator: " ", head: { type: CSSStyleValueTypes.Keyword, ident: "display-p3", }, tail: [ { type: CSSStyleValueTypes.Number, value: color.r, precision: 6, }, { type: CSSStyleValueTypes.Number, value: color.g, precision: 6, }, { type: CSSStyleValueTypes.Number, value: color.b, precision: 6, }, { type: CSSStyleValueTypes.Literal, text: "/", }, { type: CSSStyleValueTypes.Number, value: color.a, precision: 6, }, ], }, }; } /** * Generate `color(display-p3 r g b)` assuming the `color` being sRGB. * This produces a perceptually identical color in Display P3 space. * * Implements color conversion defined in CSS Color Module Level 4 draft. * https://drafts.csswg.org/css-color-4/#color-conversion */ function toDisplayP3SrgbSrc(color: figma.Color): CSSStyleValue { return toDisplayP3(srgbToDisplayP3(color)); } function toHsl(color: figma.Color): CSSStyleValue { // https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB const v = Math.max(color.r, color.g, color.b); const c = v - Math.min(color.r, color.g, color.b); const l = v - c / 2; const h = 60 * (c === 0 ? 0 : v === color.r ? ((color.g - color.b) / c) % 6 : v === color.g ? (color.b - color.r) / c + 2 : (color.r - color.g) / c + 4); const s = l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l); return { type: CSSStyleValueTypes.FunctionCall, functionName: "hsl", args: { type: CSSStyleValueTypes.List, separator: " ", head: { type: CSSStyleValueTypes.Number, value: h, unit: "deg", }, tail: [ { type: CSSStyleValueTypes.Number, value: s * 100, unit: "%", }, { type: CSSStyleValueTypes.Number, value: l * 100, unit: "%", }, { type: CSSStyleValueTypes.Literal, text: "/", }, { type: CSSStyleValueTypes.Number, value: color.a, }, ], }, }; }
-
-
-
@@ -0,0 +1,21 @@import { describe, expect, it } from "vitest"; import { getLinearGradientAngle, radToDeg } from "./gradient"; describe("radToDeg", () => { it("πrad = 180deg", () => { expect(radToDeg(Math.PI)).toBeCloseTo(180); }); it("π/2rad = 90deg", () => { expect(radToDeg(Math.PI / 2)).toBeCloseTo(90); }); }); describe("getLinearGradientAngle", () => { it("(0.5,0) to (0.5,1) = 270deg", () => { expect(getLinearGradientAngle({ x: 0.5, y: 0 }, { x: 0.5, y: 1 })).toBe( 270, ); }); });
-
-
-
@@ -0,0 +1,25 @@import type * as figma from "../../../figma"; /** * @returns Angle in degrees */ export function getLinearGradientAngle( start: figma.Vector, end: figma.Vector, ): number { return radToDeg(getAngle(start, end)); } /** * Get angle of the vector in radian * @returns Angle in radian */ function getAngle(start: figma.Vector, end: figma.Vector): number { return Math.atan(((end.y - start.y) / (end.x - start.x)) * -1); } export function radToDeg(rad: number): number { const deg = ((180 * rad) / Math.PI) | 0; return deg < 0 ? 360 + deg : deg; }
-
-
-
@@ -0,0 +1,144 @@import { describe, expect, it } from "vitest"; import { defaultPreferenecs } from "../../../preferences"; import { CSSStyleValueTypes } from "./CSSStyle"; import { serializeStyle, serializeValue } from "./serialize"; describe("serializeValue", () => { it("Should output literals/unknowns as-is", () => { expect( serializeValue( { type: CSSStyleValueTypes.Unknown, text: "unknown-value", }, defaultPreferenecs, ), ).toBe("unknown-value"); expect( serializeValue( { type: CSSStyleValueTypes.Literal, text: "literal-value", }, defaultPreferenecs, ), ).toBe("literal-value"); }); it("Should quote string", () => { expect( serializeValue( { type: CSSStyleValueTypes.String, value: "Foo Bar", }, defaultPreferenecs, ), ).toBe(`"Foo Bar"`); }); it("Should escape double-quotes in string", () => { expect( serializeValue( { type: CSSStyleValueTypes.String, value: `"Foo Bar"`, }, defaultPreferenecs, ), ).toBe(`"\\"Foo Bar\\""`); }); it("Should output comment", () => { expect( serializeValue( { type: CSSStyleValueTypes.Comment, text: "Comment String", }, defaultPreferenecs, ), ).toBe("/* Comment String */"); }); it("Should ignore color wrapper and uses its contents only", () => { expect( serializeValue( { type: CSSStyleValueTypes.Color, color: "????", value: { type: CSSStyleValueTypes.Literal, text: "--bar", }, }, defaultPreferenecs, ), ).toBe("--bar"); }); it("Should join list items with the separator", () => { expect( serializeValue( { type: CSSStyleValueTypes.List, separator: " / ", head: { type: CSSStyleValueTypes.List, separator: ", ", head: { type: CSSStyleValueTypes.Number, value: 1 }, tail: [{ type: CSSStyleValueTypes.Literal, text: "foo" }], }, tail: [ { type: CSSStyleValueTypes.List, separator: " |> ", head: { type: CSSStyleValueTypes.Literal, text: "bar", }, tail: [{ type: CSSStyleValueTypes.Literal, text: "baz" }], }, ], }, defaultPreferenecs, ), ).toBe("1, foo / bar |> baz"); }); it("Should construct function call", () => { expect( serializeValue( { type: CSSStyleValueTypes.FunctionCall, functionName: "var", args: { type: CSSStyleValueTypes.Literal, text: "--_foo", }, }, defaultPreferenecs, ), ).toBe("var(--_foo)"); }); }); describe("serializeStyle", () => { it("Should serialize a style object into valid CSS rule", () => { const str = serializeStyle( { propertyName: "foo", value: { type: CSSStyleValueTypes.Literal, text: "#fff", }, }, defaultPreferenecs, ); expect(str).toBe("foo: #fff;"); }); });
-
-
-
@@ -0,0 +1,46 @@import { roundTo } from "../../../math"; import { type Preferences } from "../../../preferences"; import { CSSStyle, CSSStyleValue, CSSStyleValueTypes } from "./CSSStyle"; export function serializeValue( value: CSSStyleValue, preferences: Readonly<Preferences>, ): string { switch (value.type) { case CSSStyleValueTypes.Color: return serializeValue(value.value, preferences); case CSSStyleValueTypes.Comment: return `/* ${value.text} */`; case CSSStyleValueTypes.FunctionCall: return ( value.functionName + "(" + serializeValue(value.args, preferences) + ")" ); case CSSStyleValueTypes.Keyword: return value.ident; case CSSStyleValueTypes.List: return [value.head, ...value.tail] .map((v) => serializeValue(v, preferences)) .join(value.separator); case CSSStyleValueTypes.Literal: return value.text; case CSSStyleValueTypes.Number: return ( roundTo(value.value, value.precision ?? preferences.decimalPlaces) + (value.unit || "") ); case CSSStyleValueTypes.String: return `"${value.value.replace(/"/g, `\\"`)}"`; case CSSStyleValueTypes.Unknown: return value.text; } } export function serializeStyle( style: CSSStyle, preferences: Readonly<Preferences>, ): string { return ( style.propertyName + ": " + serializeValue(style.value, preferences) + ";" ); }
-
-
-
@@ -0,0 +1,43 @@import { attr, svg, type ElementFn } from "../../dom"; export function close( attrs: readonly ElementFn<SVGSVGElement>[] = [], ): SVGSVGElement { return svg( "svg", [...attrs, attr("viewBox", "0 0 20 20"), attr("aria-label", "Close icon")], [ svg( "path", [ attr("d", "M1 19L19 1M19 19L1 1"), attr("stroke-width", "2"), attr("stroke", "currentColor"), ], [], ), ], ); } export function copy( attrs: readonly ElementFn<SVGSVGElement>[] = [], ): SVGSVGElement { return svg( "svg", [...attrs, attr("viewBox", "0 0 30 30"), attr("aria-label", "Copy icon")], [ svg( "path", [ attr( "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", ), attr("fill", "currentColor"), ], [], ), ], ); }
-
-
-
@@ -0,0 +1,423 @@import { attr, className, el, on } from "../../dom"; import * as figma from "../../figma"; import { roundTo } from "../../math"; import { type Preferences } from "../../preferences"; import { compute, effect, Signal } from "../../signal"; import { cssCode, styles as cssCodeStyles } from "./cssCode"; import * as cssgen from "./cssgen/cssgen"; import { section } from "./section"; export const styles = /* css */ ` .ip-root { position: absolute; height: 100%; width: 300px; right: 0; border-left: var(--panel-border); background: var(--bg); color: var(--fg); overflow-y: auto; z-index: calc(var(--z-index) + 10); } .ip-root:focus-visible { box-shadow: inset 0 0 0 2px SelectedItem; outline: none; } .ip-section { padding: 16px; border-bottom: var(--panel-border); } .ip-section-heading { display: flex; align-items: center; margin: 0; margin-bottom: 12px; } .ip-section-heading-title { flex-grow: 1; flex-shrink: 1; font-size: calc(var(--font-size) * 1); margin: 0; } .ip-style-section { margin-bottom: 12px; } .ip-overview { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: 0; margin-top: 16px; } .ip-prop { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; margin: 0; gap: 2px; } .ip-prop-label { font-weight: bold; font-size: calc(var(--font-size) * 0.7); color: var(--subtle-fg); } .ip-prop-value { font-size: calc(var(--font-size) * 0.9); color: var(--fg); user-select: text; } .ip-text-content { display: block; width: 100%; padding: 8px; box-sizing: border-box; font-family: var(--font-family-mono); font-size: calc(var(--font-size) * 0.8); background: var(--code-bg); border-radius: var(--panel-radii); color: var(--code-text); user-select: text; } .ip-options { display: flex; justify-content: flex-end; margin-top: 8px; } .ip-pref-action { appearance: none; border: 1px solid var(--action-border); padding: var(--action-vertical-padding) var(--action-horizontal-padding); background: transparent; border-radius: var(--action-radius); color: var(--fg); cursor: pointer; } .ip-pref-action:hover { background-color: var(--action-overlay); } .ip-pref-action:focus { outline: none; } .ip-pref-action:focus-visible { border-color: SelectedItem; outline: 1px solid SelectedItem; } ` + cssCodeStyles; interface InspectorPanelProps { preferences: Signal<Readonly<Preferences>>; selected: Signal<figma.Node | null>; onOpenPreferencesPanel(): void; } export function inspectorPanel({ preferences: $preferences, selected: $selected, onOpenPreferencesPanel, }: InspectorPanelProps): Signal<HTMLElement | null> { effect(() => { // No need to rerun this effect on node-to-node changes if (!compute(() => !!$selected.get()).get()) { return; } const onEsc = (ev: KeyboardEvent) => { if (ev.key !== "Escape" || ev.isComposing) { return; } ev.preventDefault(); ev.stopPropagation(); $selected.set(null); }; document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("keydown", onEsc); }; }); return compute(() => { const node = $selected.get(); if (!node) { return null; } return el( "div", [className("ip-root")], [ section({ title: node.name, body: [ el( "div", [className("ip-overview")], [ el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Type:"]), el("span", [className("ip-prop-value")], [node.type]), ], ), ], ), figma.hasBoundingBox(node) ? el( "div", [className("ip-overview")], [ el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Width:"]), el( "span", [className("ip-prop-value")], [ compute( () => roundTo( node.absoluteBoundingBox.width, $preferences.get().decimalPlaces, ) + "px", ), ], ), ], ), el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Height:"]), el( "span", [className("ip-prop-value")], [ compute( () => roundTo( node.absoluteBoundingBox.height, $preferences.get().decimalPlaces, ) + "px", ), ], ), ], ), ], ) : null, figma.hasTypeStyle(node) ? el( "div", [className("ip-overview")], [ el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Font:"]), el( "span", [className("ip-prop-value")], [ node.style.fontPostScriptName || node.style.fontFamily, ], ), ], ), ], ) : null, ], icon: "close", onIconClick: () => { $selected.set(null); }, }), figma.hasPadding(node) && (node.paddingTop > 0 || node.paddingRight > 0 || node.paddingBottom > 0 || node.paddingLeft > 0) ? section({ title: "Padding", body: [ el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Top:"]), el( "span", [className("ip-prop-value")], [node.paddingTop.toString(10)], ), ], ), el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Right:"]), el( "span", [className("ip-prop-value")], [node.paddingRight.toString(10)], ), ], ), el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Bottom:"]), el( "span", [className("ip-prop-value")], [node.paddingBottom.toString(10)], ), ], ), el( "p", [className("ip-prop")], [ el("span", [className("ip-prop-label")], ["Left:"]), el( "span", [className("ip-prop-value")], [node.paddingLeft.toString(10)], ), ], ), ], }) : figma.hasLegacyPadding(node) && (node.horizontalPadding > 0 || node.verticalPadding > 0) ? section({ title: "Layout", body: [ node.horizontalPadding > 0 ? el( "p", [className("ip-prop")], [ el("span", [], ["Padding(H): "]), node.horizontalPadding.toString(10), ], ) : null, node.verticalPadding > 0 ? el( "p", [className("ip-prop")], [ el("span", [], ["Padding(V): "]), node.verticalPadding.toString(10), ], ) : null, ], }) : null, figma.hasCharacters(node) ? section({ title: "Content", body: [ el("code", [className("ip-text-content")], [node.characters]), ], icon: "copy", onIconClick() { // TODO: Show feedback UI after success/failure navigator.clipboard .writeText(node.characters) .then(() => { console.info("Copied to clipboard"); }) .catch((error) => { console.error("Failed to copy to clipboard", error); }); }, }) : null, section({ title: "CSS", body: [ compute(() => { const preferences = $preferences.get(); const css = cssgen.fromNode(node, preferences); return cssCode(css, preferences); }), el( "div", [className("ip-options")], [ el( "button", [ className("ip-pref-action"), on("click", () => { onOpenPreferencesPanel(); }), ], ["Customize"], ), ], ), ], icon: "copy", onIconClick: () => { const preferences = $preferences.once(); const css = cssgen.fromNode(node, preferences); const code = css .map((style) => cssgen.serializeStyle(style, preferences)) .join("\n"); // TODO: Show feedback UI after success/failure navigator.clipboard .writeText(code) .then(() => { console.info("Copied to clipboard"); }) .catch((error) => { console.error("Failed to copy to clipboard", error); }); }, }), ], ); }); }
-
-
-
@@ -0,0 +1,56 @@import { attr, className, el, on } from "../../dom"; import { iconButton } from "../iconButton/iconButton"; import * as icons from "./icons"; interface SectionProps { title: string; body: NonNullable<Parameters<typeof el>[2]>; icon?: "close" | "copy"; onIconClick?(): void; } export function section({ title, body, icon: iconType, onIconClick, }: SectionProps): HTMLElement { const iconAttrs = [ attr("role", "img"), className("ip-section-heading-button-icon"), ]; const icon = iconType === "close" ? icons.close(iconAttrs) : iconType === "copy" ? icons.copy(iconAttrs) : null; return el( "div", [className("ip-section")], [ el( "div", [className("ip-section-heading")], [ el("h4", [className("ip-section-heading-title")], [title]), icon && onIconClick && iconButton({ title: iconType === "close" ? "Close" : "Copy", icon, onClick: onIconClick, }), ], ), ...body, ], ); }
-
-
src/ui/menuBar/icons.ts (new)
-
@@ -0,0 +1,65 @@import { attr, className, svg, style } from "../../dom"; export const icons = { info: () => svg( "svg", [ attr("viewBox", "0 0 100 100"), className("mb-icon"), attr("fill", "none"), attr("stroke-width", "8"), ], [ svg("circle", [ attr("cx", "50"), attr("cy", "50"), attr("r", "42"), attr("stroke", "currentColor"), ]), svg("circle", [ attr("cx", "50"), attr("cy", "30"), attr("r", "9"), attr("fill", "currentColor"), ]), svg("rect", [ attr("x", "44"), attr("y", "45"), attr("width", "12"), attr("height", "35"), attr("fill", "currentColor"), ]), ], ), preferences: () => svg( "svg", [ attr("viewBox", "0 0 100 100"), className("mb-icon"), attr("fill", "none"), attr("stroke-width", "10"), ], [ svg("circle", [ attr("cx", "50"), attr("cy", "50"), attr("r", "30"), attr("stroke", "currentColor"), ]), ...Array.from({ length: 8 }).map((_, i) => { const deg = 45 * i; return svg("path", [ attr("d", "M45,2 l10,0 l5,15 l-20,0 Z"), attr("fill", "currentColor"), style({ transform: `rotate(${deg}deg)`, "transform-origin": "50px 50px", }), ]); }), ], ), } as const;
-
-
-
@@ -0,0 +1,94 @@import { attr, className, el } from "../../dom"; import { iconButton } from "../iconButton/iconButton"; import { icons } from "./icons"; export const styles = /* css */ ` .mb-root { position: absolute; top: 0; width: 100%; } .mb-root:hover > .mb-menubar[data-autohide] { transition-delay: 0s; transform: translateY(0px); } .mb-menubar { padding: 8px; display: flex; background-color: var(--bg); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); color: var(--fg); z-index: 1; } .mb-menubar[data-autohide] { transition: transform 0.15s 0.5s ease-out; transform: translateY(-100%); } .mb-menubar .sl-select { --action-border: transparent; } .mb-slots { flex: 1; } .mb-actions { flex-grow: 0; flex-shrink: 0; display: flex; gap: 8px; } `; interface MenuBarProps { slot?: Parameters<typeof el>[2]; onOpenInfo(): void; onOpenPreferences(): void; } export function menuBar({ slot, onOpenInfo, onOpenPreferences, }: MenuBarProps): HTMLElement { return el( "div", [className("mb-root")], [ el( "div", [className("mb-menubar"), attr("data-autohide", false)], [ el("div", [className("mb-slots")], slot), el( "div", [className("mb-actions")], [ iconButton({ title: "File info", icon: icons.info(), onClick: () => { onOpenInfo(); }, }), iconButton({ title: "Preferences", icon: icons.preferences(), onClick: () => { onOpenPreferences(); }, }), ], ), ], ), ], ); }
-
-
-
@@ -0,0 +1,156 @@import { attr, className, el, prop, on } from "../../dom"; import { compute, Signal } from "../../signal"; import { check } from "./icons"; export const styles = /* css */ ` .pp-choice-container { position: relative; } .pp-choice-input { position: absolute; top: 0; left: 0; width: 1px; height: 1px; opacity: 0; } .pp-choice-box { padding: var(--action-vertical-padding) var(--action-horizontal-padding); display: grid; grid-template-columns: min-content minmax(0, 1fr); grid-template-rows: max-content max-content; align-items: center; gap: 4px 8px; border: 1px solid var(--action-border); border: 1px solid transparent; border-radius: var(--action-radius); cursor: pointer; } .pp-choice-input:checked + .pp-choice-box { border-color: var(--action-border); } .pp-choice-input:checked + .pp-choice-box > .pp-choice-check { color: var(--success-fg); opacity: 1; } .pp-choice-input:focus-visible + .pp-choice-box { box-shadow: 0 0 0 1px inset SelectedItem; border-color: SelectedItem; } .pp-choice-box:hover { background-color: var(--action-overlay); } .pp-choice-check { height: calc(var(--font-size) * 0.8); width: auto; color: var(--subtle-fg); opacity: 0.15; } .pp-choice-label { font-size: calc(var(--font-size) * 1); color: var(--fg); } .pp-choice-desc { grid-column: 1 / 3; margin: 0; font-size: calc(var(--font-size) * 0.8); color: var(--subtle-fg); } `; type ElementChildren = NonNullable<Parameters<typeof el>[2]>; interface ChoiceProps<T extends string> { value: T; label: ElementChildren[number]; description?: ElementChildren; selected: Signal<T>; group: string; onChange(value: T): void; } export function choice<T extends string>({ value, label, description, selected, group, onChange, }: ChoiceProps<T>): HTMLElement { const id = group + "_" + value; const labelId = id + "_label"; const descriptionId = id + "_description"; const input = el("input", [ attr("id", id), className("pp-choice-input"), attr("aria-labelledby", labelId), attr("aria-describedby", description ? descriptionId : false), attr("type", "radio"), attr("name", group), prop( "checked", compute(() => selected.get() === value), ), on("change", (ev) => { ev.preventDefault(); onChange(value); }), ]); return el( "div", [className("pp-choice-container")], [ input, el( "div", [ className("pp-choice-box"), on("click", (ev) => { if (ev.target instanceof HTMLAnchorElement) { return; } ev.preventDefault(); input.click(); }), ], [ check([className("pp-choice-check")]), el( "span", [attr("id", labelId), className("pp-choice-label")], [label], ), description && el( "p", [attr("id", descriptionId), className("pp-choice-desc")], description, ), ], ), ], ); }
-
-
-
@@ -0,0 +1,22 @@import { attr, svg, type ElementFn } from "../../dom"; export function check( attrs: readonly ElementFn<SVGSVGElement>[] = [], ): SVGSVGElement { return svg( "svg", [ ...attrs, attr("viewBox", "0 0 100 100"), attr("aria-label", "Check mark"), ], [ svg("path", [ attr("d", "M10,50 L40,80 L90,20"), attr("fill", "none"), attr("stroke", "currentColor"), attr("stroke-width", "15"), ]), ], ); }
-
-
-
@@ -0,0 +1,548 @@import { attr, className, el, type ElementFn, on, prop } from "../../dom"; import { roundTo } from "../../math"; import { type Preferences } from "../../preferences"; import { compute, Signal } from "../../signal"; import { choice, styles as choiceStyles } from "./choice"; export const styles = /* css */ ` .pp-root { overflow-y: auto; } .pp-section-header { display: block; margin: 0; margin-top: 2em; margin-bottom: 1em; font-size: calc(var(--font-size) * 1.1); font-weight: bold; } .pp-choice-list { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 8px; } .pp-input { appearance: none; border: 1px solid var(--action-border); padding: var(--action-vertical-padding) var(--action-horizontal-padding); display: inline-block; font-size: calc(var(--font-size) * 0.9); min-width: 6em; background: transparent; border-radius: var(--action-radius); color: var(--fg); } .pp-input:focus { outline: none; } .pp-input:focus-visible { border-color: SelectedItem; outline: 1px solid SelectedItem; } .pp-description, .pp-error { margin: 0; margin-top: 1em; font-size: calc(var(--font-size) * 0.8); color: var(--subtle-fg); } .pp-error { color: var(--error-fg); } ` + choiceStyles; interface PreferencesPanelProps { preferences: Signal<Readonly<Preferences>>; } export function preferencesPanel({ preferences: $preferences, }: PreferencesPanelProps): HTMLElement { return el( "div", [className("pp-root")], [ cssColorNotation($preferences), lengthUnit($preferences), rootFontSizeInPx($preferences), enableColorPreview($preferences), decimalPlaces($preferences), viewportPanSpeed($preferences), viewportZoomSpeed($preferences), ], ); } interface NumberInputProps { $error: Signal<string | null>; initialValue: number; min: number; max: number; step?: number; attrs?: readonly ElementFn<HTMLInputElement>[]; onChange(value: number): void; } function numberInput({ $error, initialValue, min, max, step = 1, attrs = [], onChange, }: NumberInputProps): HTMLInputElement { return el("input", [ ...attrs, className("pp-input"), attr("type", "number"), prop("value", initialValue.toString(10)), attr("min", min.toString(10)), attr("max", max.toString(10)), attr("step", step.toString(10)), attr( "aria-invalid", compute(() => ($error.get() ? "true" : false)), ), on("change", (ev) => { ev.preventDefault(); const value = parseInt((ev.currentTarget as HTMLInputElement).value, 10); if (!Number.isFinite(value)) { $error.set("Please input a valid number."); return; } if (value < min) { $error.set( "Input must be greater than or equal to " + min.toString(10) + ".", ); return; } if (value > max) { $error.set( "Input must be less than or equal to " + max.toString(10) + ".", ); return; } $error.set(null); onChange(value); }), ]); } function cssColorNotation( $preferences: Signal<Readonly<Preferences>>, ): HTMLElement { const selected = compute(() => $preferences.get().cssColorNotation); const onChange = (cssColorNotation: Preferences["cssColorNotation"]) => { $preferences.set({ ...$preferences.once(), cssColorNotation, }); }; return el( "div", [], [ el("span", [className("pp-section-header")], ["CSS color notation"]), el( "div", [className("pp-choice-list")], [ choice({ value: "hex", label: "RGB (Hex)", selected, group: "color_notation", onChange, }), choice({ value: "rgb", label: "RGB", selected, group: "color_notation", onChange, }), choice({ value: "hsl", label: "HSL", selected, group: "color_notation", onChange, }), choice({ value: "color-srgb", label: "sRGB", selected, description: [ "Display colors with ", el("code", [], ["color"]), " function in sRGB color space. When the Figma file is set to use Display P3 color space, ", "color gamut would be inaccurate.", ], group: "color_notation", onChange, }), choice({ value: "display-p3", label: "Display P3", selected, description: [ "Display colors in Display P3 color space. ", "Suitable for Figma files set to use Display P3 color space. ", "When the Figma file is set to use sRGB color space (default), resulting colors may be oversaturated. ", "If the user environment does not support Display P3 color space, out-of-gamut colors are clamped (CSS Gamut Mapping).", ], group: "color_notation", onChange, }), choice({ value: "srgb-to-display-p3", label: "Display P3 (sRGB range)", selected, description: [ "Display colors in Display P3 color space. ", "This mode treats original color as sRGB and converts it to Display P3 color using ", el( "a", [ attr( "href", "https://drafts.csswg.org/css-color-4/#predefined-to-predefined", ), attr("target", "_blank"), ], ["a method described in CSS Color Module 4 draft spec"], ), ". ", "When the Figma file is set to use Display P3 color space, resulting colors may be undersaturated. ", "The colors generated by this mode look same regardless of whether the user environment supports Display P3 color space or not. ", ], group: "color_notation", onChange, }), ], ), ], ); } function lengthUnit($preferences: Signal<Readonly<Preferences>>): HTMLElement { const selected = compute(() => $preferences.get().lengthUnit); const onChange = (lengthUnit: Preferences["lengthUnit"]) => { $preferences.set({ ...$preferences.once(), lengthUnit, }); }; return el( "div", [], [ el("span", [className("pp-section-header")], ["CSS length unit"]), el( "div", [className("pp-choice-list")], [ choice({ value: "px", label: "px", selected, group: "length_unit", onChange, }), choice({ value: "rem", label: "rem", selected, description: [ "Showing rem sizes to match the px sizes, assuming the ", el("code", [], [":root"]), " is set to ", compute(() => { const { rootFontSizeInPx, decimalPlaces } = $preferences.get(); return roundTo(rootFontSizeInPx, decimalPlaces).toString(10); }), "px. ", "This option changes ", el("b", [], ["every"]), " length unit to rem, even where the use of px is preferable.", ], group: "length_unit", onChange, }), ], ), ], ); } function rootFontSizeInPx( $preferences: Signal<Readonly<Preferences>>, ): Signal<HTMLElement | null> { const $isUsingRem = compute(() => $preferences.get().lengthUnit === "rem"); const $error = new Signal<string | null>(null); return compute(() => { if (!$isUsingRem.get()) { return null; } return el( "div", [], [ el( "span", [className("pp-section-header")], ["Root font size for rem calculation"], ), numberInput({ $error, initialValue: $preferences.once().rootFontSizeInPx, min: 1, max: 100, attrs: [attr("aria-describedby", "root_font_size_desc")], onChange(rootFontSizeInPx) { $preferences.set({ ...$preferences.once(), rootFontSizeInPx, }); }, }), compute(() => { const error = $error.get(); if (!error) { return null; } return el("p", [className("pp-error")], [error]); }), el( "p", [attr("id", "root_font_size_desc"), className("pp-description")], [ "Font size set to your page's ", el("code", [], [":root"]), " in px. ", "When unset, 16px (default value) is the recommended value as it is the default value most browser/platform uses. ", "When you set 62.5% (or similar) to make 1rem to match 10px, input 10px. ", "With the current setting, 1px = ", compute(() => { const { rootFontSizeInPx, decimalPlaces } = $preferences.get(); return roundTo(1 / rootFontSizeInPx, decimalPlaces + 2).toString( 10, ); }), "rem. ", ], ), ], ); }); } function enableColorPreview( $preferences: Signal<Readonly<Preferences>>, ): HTMLElement { const selected = compute(() => $preferences.get().enableColorPreview ? "true" : "false", ); const onChange = (enableColorPreview: "true" | "false") => { $preferences.set({ ...$preferences.once(), enableColorPreview: enableColorPreview === "true", }); }; return el( "div", [], [ el("span", [className("pp-section-header")], ["CSS color preview"]), el( "div", [className("pp-choice-list")], [ choice({ value: "true", label: "Enabled", selected, description: [ "Displays a color preview next to a CSS color value.", ], group: "color_preview", onChange, }), choice({ value: "false", label: "Disabled", selected, description: ["Do not display color previews inside CSS code."], group: "color_preview", onChange, }), ], ), ], ); } const ROUND_TEST_VALUE = 1.23456789123; function decimalPlaces( $preferences: Signal<Readonly<Preferences>>, ): HTMLElement { const $error = new Signal<string | null>(null); return el( "div", [], [ el("span", [className("pp-section-header")], ["Decimal places"]), numberInput({ $error, initialValue: $preferences.once().decimalPlaces, min: 0, max: 10, attrs: [attr("aria-describedby", "decimal_places_desc")], onChange(decimalPlaces) { $preferences.set({ ...$preferences.once(), decimalPlaces, }); }, }), compute(() => { const error = $error.get(); if (!error) { return null; } return el("p", [className("pp-error")], [error]); }), el( "p", [attr("id", "decimal_places_desc"), className("pp-description")], [ "The number of decimal places to show in UI and CSS code. Some parts ignore, add to, or subtract to this number. ", "With the current setting, ", ROUND_TEST_VALUE.toString(10), " would be rounded to ", compute(() => { const { decimalPlaces } = $preferences.get(); return ( roundTo( ROUND_TEST_VALUE, $preferences.get().decimalPlaces, ).toString(10) + (decimalPlaces === 0 ? " (integer)" : "") ); }), ". ", ], ), ], ); } function viewportZoomSpeed( $preferences: Signal<Readonly<Preferences>>, ): HTMLElement { const $error = new Signal<string | null>(null); return el( "div", [], [ el("span", [className("pp-section-header")], ["Viewport zoom speed"]), numberInput({ $error, initialValue: $preferences.once().viewportZoomSpeed, min: 0, max: 999, attrs: [attr("aria-describedby", "zoom_speed_desc")], onChange(viewportZoomSpeed) { $preferences.set({ ...$preferences.once(), viewportZoomSpeed, }); }, }), compute(() => { const error = $error.get(); if (!error) { return null; } return el("p", [className("pp-error")], [error]); }), el( "p", [attr("id", "zoom_speed_desc"), className("pp-description")], ["The speed of viewport scaling action."], ), ], ); } function viewportPanSpeed( $preferences: Signal<Readonly<Preferences>>, ): HTMLElement { const $error = new Signal<string | null>(null); return el( "div", [], [ el("span", [className("pp-section-header")], ["Viewport pan speed"]), numberInput({ $error, initialValue: $preferences.once().viewportPanSpeed, min: 0, max: 999, attrs: [attr("aria-describedby", "pan_speed_desc")], onChange(viewportPanSpeed) { $preferences.set({ ...$preferences.once(), viewportPanSpeed, }); }, }), compute(() => { const error = $error.get(); if (!error) { return null; } return el("p", [className("pp-error")], [error]); }), el( "p", [attr("id", "pan_speed_desc"), className("pp-description")], ["The speed of viewport pan/move action."], ), ], ); }
-
-
-
@@ -0,0 +1,111 @@import { attr, className, el, on, raf, svg, type ElementFn } from "../../dom"; import { compute, Signal } from "../../signal"; export const styles = /* css */ ` .sl-wrapper { --_caret-size: 10px; --_caret-width: 8px; position: relative; display: inline-flex; box-sizing: border-box; } .sl-select { appearance: none; padding: var(--action-vertical-padding) var(--action-horizontal-padding); padding-right: calc(var(--action-horizontal-padding) * 2 + var(--_caret-size)); margin: 0; border: 1px solid var(--action-border); border: none; box-sizing: border-box; font-size: calc(var(--font-size) * 0.8); width: 100%; background: transparent; border-radius: var(--action-radius); color: inherit; cursor: pointer; outline: none; } .sl-select:hover { background-color: var(--action-overlay); } .sl-select:focus { outline: none; } .sl-select:focus-visible { outline: 2px solid SelectedItem; } .sl-caret { position: absolute; right: var(--action-horizontal-padding); width: var(--_caret-size); height: var(--_caret-size); top: 0; bottom: 0; margin: auto 0; pointer-events: none; stroke: currentColor; stroke-width: var(--_caret-width); fill: none; } `; interface SelectboxProps { attrs?: readonly ElementFn<HTMLSelectElement>[]; options: readonly HTMLOptionElement[]; wrapperAttrs?: readonly ElementFn<HTMLElement>[]; value: string | undefined | Signal<string | undefined>; onChange?(value: string): void; } export function selectBox({ options, attrs = [], wrapperAttrs = [], value, onChange, }: SelectboxProps): HTMLElement { return el( "div", [className("sl-wrapper"), ...wrapperAttrs], [ el( "select", [ className("sl-select"), raf( compute(() => (el) => { el.value = (value instanceof Signal ? value.get() : value) || ""; }), ), on("change", (ev) => { if (!(ev.currentTarget instanceof HTMLSelectElement)) { return; } onChange?.(ev.currentTarget.value); }), ...attrs, ], options, ), svg( "svg", [ attr("viewBox", "0 0 100 100"), className("sl-caret"), attr("aria-hidden", "true"), ], [svg("path", [attr("d", "M0,25 l50,50 l50,-50")])], ), ], ); }
-
-
src/ui/styles.ts (new)
-
@@ -0,0 +1,18 @@import { styles as empty } from "./empty/empty"; import { styles as fullscreenPanel } from "./fullscreenPanel/fullscreenPanel"; import { styles as iconButton } from "./iconButton/iconButton"; import { styles as infoItems } from "./infoItems/infoItems"; import { styles as inspectorPanel } from "./inspectorPanel/inspectorPanel"; import { styles as menuBar } from "./menuBar/menuBar"; import { styles as preferencesPanel } from "./preferencesPanel/preferencesPanel"; import { styles as selectBox } from "./selectBox/selectBox"; export const styles: string = inspectorPanel + selectBox + iconButton + fullscreenPanel + menuBar + infoItems + empty + preferencesPanel;
-
-
src/ui/ui.ts (new)
-
@@ -0,0 +1,151 @@import { el } from "../dom"; import type * as figma from "../figma"; import { type Preferences } from "../preferences"; import { compute, Signal } from "../signal"; import { canvas, info, preferences, isIdle, isSetupError, isInfo, isPreferences, type LoadedState, type State, } from "../state"; import { empty } from "./empty/empty"; import { fullscreenPanel } from "./fullscreenPanel/fullscreenPanel"; import { inspectorPanel } from "./inspectorPanel/inspectorPanel"; import { menuBar } from "./menuBar/menuBar"; import { preferencesPanel } from "./preferencesPanel/preferencesPanel"; type ElementChild = NonNullable<Parameters<typeof el>[2]>[number]; interface UIProps<T> { state: Signal<State<T>>; preferences: Signal<Readonly<Preferences>>; infoContents: (data: T) => ElementChild; frameCanvas: ( data: T, selected: Signal<figma.Node | null>, loadedState: Signal<LoadedState>, ) => ElementChild; menuSlot?: (data: T) => ElementChild; caller: "frame" | "file"; } export function ui<T>({ state: $state, preferences: $preferences, infoContents: createinfoContents, frameCanvas: createFrameCanvas, menuSlot: createMenuSlot, caller, }: UIProps<T>): Signal<HTMLElement> { return compute(() => { const s = $state.get(); if (isIdle(s)) { if (caller === "file") { return empty({ title: ["No Figma file"], body: [ el( "p", [], [ "Both Figma file data and rendered images are missing. ", "Please provide those in order to start Figspec File Viewer. ", ], ), ], }); } return empty({ title: ["No Figma frame"], body: [ el( "p", [], [ "Both frame data and rendered image are missing. ", "Please provide those in order to start Figspec Frame Viewer. ", ], ), ], }); } if (isSetupError(s)) { return empty({ title: ["Failed to render Figma ", caller], body: [ el( "p", [], ["Couldn't render the Figma ", caller, " due to an error."], ), el("pre", [], [s.error.message, "\n\n", s.error.stack]), ], }); } const $loadedState = new Signal<LoadedState>(canvas); const $selected = new Signal<figma.Node | null>(null); const frameCanvas = createFrameCanvas(s.data, $selected, $loadedState); const perState = compute(() => { const loadedState = $loadedState.get(); if (isInfo(loadedState)) { return fullscreenPanel({ body: [createinfoContents(s.data)], onClose() { $loadedState.set(canvas); }, }); } if (isPreferences(loadedState)) { return fullscreenPanel({ body: [preferencesPanel({ preferences: $preferences })], onClose() { $loadedState.set(canvas); }, }); } return el( "div", [], [ menuBar({ slot: [createMenuSlot?.(s.data)], onOpenInfo() { $loadedState.set(info); }, onOpenPreferences() { $loadedState.set(preferences); }, }), inspectorPanel({ selected: $selected, preferences: $preferences, onOpenPreferencesPanel() { $loadedState.set(preferences); }, }), ], ); }); const layer = el("div", [], [frameCanvas, perState]); return layer; }); }
-
-
tsconfig.build.json (new)
-
@@ -0,0 +1,7 @@{ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false }, "exclude": ["**/*.spec.ts"] }
-
-
-
@@ -6,7 +6,7 @@ "declaration": true,"lib": ["ESNext", "DOM"], "strict": true, "moduleResolution": "Node", "experimentalDecorators": true "noEmit": true }, "include": ["./src/**/*.ts"] }
-
-
website/examples/custom-speed.html (deleted)
-
@@ -1,17 +0,0 @@<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Custom speed example | Figspec</title> <link rel="stylesheet" href="./style.css" /> <script type="module" src="./custom-speed.ts"></script> </head> <body> <figspec-frame-viewer id="demo" pan-speed="100" zoom-speed="100" ></figspec-frame-viewer> </body> </html>
-
-
-
@@ -1,12 +1,9 @@import { FigspecFrameViewer } from "../../src"; import * as demoFrame from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.json"; import demoImage from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.svg"; const el = document.getElementById("demo"); if (el && el instanceof FigspecFrameViewer) { // @ts-ignore el.nodes = demoFrame; el.renderedImage = demoImage; el.apiResponse = demoFrame; }
-
-
-
@@ -6,19 +6,14 @@const el = document.getElementById("demo"); if (el && el instanceof FigspecFrameViewer) { // @ts-ignore el.nodes = demoFrame; el.apiResponse = demoFrame; el.renderedImage = demoImage; el.addEventListener("positionchange", (ev) => { el.addEventListener("nodeselect", (ev) => { console.log(ev); }); el.addEventListener("scalechange", (ev) => { console.log(ev); }); el.addEventListener("nodeselect", (ev) => { el.addEventListener("preferencesupdate", (ev) => { console.log(ev); }); }
-
-
-
@@ -1,4 +1,4 @@<!DOCTYPE html> <!doctype html> <html> <head> <meta charset="utf-8" />
-
-
-
@@ -11,8 +11,7 @@const el = document.getElementById("demo"); if (el && el instanceof FigspecFileViewer) { // @ts-ignore el.documentNode = demoJson; el.apiResponse = demoJson; el.renderedImages = { "2:5": image2_5, "2:9": image2_9,
-
-
-
@@ -1,4 +1,4 @@<!DOCTYPE html> <!doctype html> <html> <head> <meta charset="utf-8" />
-
-
-
@@ -6,7 +6,7 @@const el = document.getElementById("demo"); if (el && el instanceof FigspecFrameViewer) { // @ts-ignore el.nodes = demoFrame; // @ts-ignore: TS can't handle large file el.apiResponse = demoFrame; el.renderedImage = demoImage; }
-
-
-
@@ -0,0 +1,13 @@<!doctype html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Image missing error example | Figspec</title> <link rel="stylesheet" href="./style.css" /> <script type="module" src="./missing-image.ts"></script> </head> <body> <figspec-frame-viewer id="demo"></figspec-frame-viewer> </body> </html>
-
-
-
@@ -1,4 +1,5 @@figspec-frame-viewer, figspec-frame-viewer-next, figspec-file-viewer { position: absolute; top: 0;
-
-
-
@@ -1,4 +1,4 @@<!DOCTYPE html> <!doctype html> <html> <head> <meta charset="utf-8" />
-
@@ -98,170 +98,269 @@ <code>link</code></td> <td><code>string</code></td> <td></td> <td>An URL for the Figma frame or containing file.</td> </tr> <tr> <td> An URL for the Figma frame or containing file. A footer will appear if this property is not empty. <code>preferences</code> </td> <td> <code ><a href="https://github.com/pocka/figspec/blob/master/src/preferences.ts" >Preferences</a ></code > </td> <td>Default preferences</td> <td> Preferences object. Figspec itself does not save/restore user preferences. A user of Figspec (page author) is responsible for preference persistence. </td> </tr> </tbody> </table> <h4>Attributes</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td> <code>link</code> </td> <td> A corresponding attribute for the <code>link</code> property. </td> </tr> </tbody> </table> <h4>Events</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td> <code>nodeselect</code> </td> <td> Fired when a user selects or unselects a node. <code>[event].detail.node</code> is a node object, <code>null</code> for unselects. </td> </tr> <tr> <td> <code>panX</code> <code>preferencesupdate</code> </td> <td><code>number</code></td> <td><code>0</code></td> <td> Current value of viewport translate for X axis, in pixel. Scaling applies after the translate. Fired when a user changes preferences. <code>[event].detail.preferences</code> is a preferences object. </td> </tr> </tbody> </table> <h4>CSS Custom Properties</h4> <table> <thead> <tr> <th>Name</th> <th>Acceptable value</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td> <code>panY</code> <code>--figspec-font-family-sans</code> </td> <td><code>number</code></td> <td><code>0</code></td> <td><code>List of font family</code></td> <td>Font family for sans serif text.</td> </tr> <tr> <td> Current value of viewport translate for Y axis, in pixel. Scaling applies after the translate. <code>--figspec-font-family-mono</code> </td> <td><code>List of font family</code></td> <td>Font family for monospace text.</td> </tr> <tr> <td> <code>scale</code> <code>--figspec-font-size</code> </td> <td><code>number</code></td> <td><code>1.0</code></td> <td><code><length></code></td> <td>Base font size. Default to <code>1rem</code>.</td> </tr> <tr> <td> Current value of viewport scaling (zoom), where 1.0 = 100%; <code>--figspec-action-overlay</code> </td> <td><code><color></code></td> <td>Background color for actions (e.g. button) on hover.</td> </tr> <tr> <td> <code>zoomSpeed</code> <code>--figspec-action-border</code> </td> <td><code>number</code></td> <td><code>500</code></td> <td><code><color></code></td> <td>Border color for actions (e.g. button).</td> </tr> <tr> <td> How fast viewport zooms when user performs <kbd>Ctrl</kbd> + wheel scroll or pinch in-out gesture. You can specify between 1 to 1,000. <code>--figspec-action-horizontal-padding</code> </td> <td><code><length></code></td> <td>Left and right padding of actions (e.g. button).</td> </tr> <tr> <td> <code>panSpeed</code> <code>--figspec-action-vertical-padding</code> </td> <td><code>number</code></td> <td><code>500</code></td> <td><code><length></code></td> <td>Top and bottom padding of actions (e.g. button).</td> </tr> <tr> <td> How fast viewport translates when user performs wheel scroll. This does not affect to the speed of drag with middle button (wheel button) pressed. You can specify between 1 to 1,000. <code>--figspec-action-radius</code> </td> <td><code><length></code></td> <td>Border radius of actions (e.g. button).</td> </tr> <tr> <td> <code>selectedNode</code> <code>--figspec-canvas-bg</code> </td> <td><code><color></code></td> <td> <code ><a href="https://github.com/pocka/figspec/blob/master/src/FigspecViewer/utils.ts#L4" >SizedNode</a > | null</code > Canvas color for Frame background and files without background color set. </td> <td><code>null</code></td> <td>Selected node object.</td> </tr> <tr> <td> <code>zoomMargin</code> <code>--figspec-fg</code> </td> <td><code>number</code></td> <td><code>50</code></td> <td><code><color></code></td> <td>Foreground color, mainly for text.</td> </tr> <tr> <td> The minimum margin for the preview canvas in px. Preview element uses this value for default zoom value (scale). <code>--figspec-bg</code> </td> <td><code><color></code></td> <td>Background color.</td> </tr> </tbody> </table> <h4>Attributes</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> <td> <code>--figspec-subtle-fg</code> </td> <td><code><color></code></td> <td> Foreground color for non-primary elements, such as description text. </td> </tr> </thead> <tbody> <tr> <td> <code>link</code> <code>--figspec-success-fg</code> </td> <td><code><color></code></td> <td>Foreground color for OK/success elements.</td> </tr> <tr> <td> A corresponding attribute for the <code>link</code> property. <code>--figspec-error-fg</code> </td> <td><code><color></code></td> <td>Foreground color for NG/error elements.</td> </tr> <tr> <td> <code>zoom-speed</code> <code>--figspec-z-index</code> </td> <td>Unit-less number</td> <td> A corresponding attribute for the <code>zoomSpeed</code> property. Base <code>z-index</code> value for the element. Internal elements creates UI stack on top of that. </td> </tr> <tr> <td> <code>pan-speed</code> <code >--figspec-code-{bg, text, keyword, string, number, list, comment, literal, function, unit}</code > </td> <td><code><color></code></td> <td>Syntax highlighting colors.</td> </tr> <tr> <td> A corresponding attribute for the <code>panSpeed</code> property. <code>--figspec-guide-thickness</code> </td> <td><code><length></code></td> <td>Stroke thickness of node guides.</td> </tr> <tr> <td> <code>zoom-margin</code> <code>--figspec-guide-color</code> </td> <td><code><color></code></td> <td>Stroke color of node guides.</td> </tr> <tr> <td> A corresponding attribute for the <code>zoomMargin</code> property. <code>--figspec-guide-selected-color</code> </td> <td><code><color></code></td> <td>Stroke color for the outline of selected node.</td> </tr> </tbody> </table> <h4>Events</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> <td> <code>--figspec-guide-tooltip-fg</code> </td> <td><code><color></code></td> <td>Text color of the distance tooltip.</td> </tr> </thead> <tbody> <tr> <td> <code>scalechange</code> <code>--figspec-guide-selected-tooltip-fg</code> </td> <td><code><color></code></td> <td>Text color of the dimension tooltip for a selected node.</td> </tr> <tr> <td> Fired when a user performs zoom-in or zoom-out on the preview. <code>--figspec-guide-tooltip-bg</code> </td> <td><code><color></code></td> <td>Background color of the distance tooltip.</td> </tr> <tr> <td> <code>positionchange</code> <code>--figspec-guide-selected-tooltip-bg</code> </td> <td>Fired when a user performs pan on the preview.</td> <td><code><color></code></td> <td> Background color of the dimension tooltip for a selected node. </td> </tr> <tr> <td> <code>nodeselect</code> <code>--figspec-guide-tooltip-font-size</code> </td> <td>Fired when a user selects or unselects a node.</td> <td><code><length></code></td> <td>Font size of tooltips.</td> </tr> </tbody> </table>
-
@@ -279,20 +378,14 @@ </thead><tbody> <tr> <td> <code>nodes</code> <code>apiResponse</code> </td> <td> <code ><a href="https://jemgold.github.io/figma-js/interfaces/filenodesresponse.html" >FileNodesResponse</a > | null</code > <code>GetFileNodesResponse | null</code> </td> <td><code>null</code></td> <td> Required. A response of the Required. Response body of the <a href="https://www.figma.com/developers/api#get-file-nodes-endpoint" >
-
@@ -316,51 +409,6 @@ >API. </td> </tr> <tr> <td> <code>documentNode</code> </td> <td> <code ><a href="https://github.com/pocka/figspec/blob/master/src/FigspecViewer/utils.ts#L4" >SizedNode</a > | null</code > </td> <td><code>null</code></td> <td>Readonly. A root drawable node.</td> </tr> </tbody> </table> <h4>Attributes</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td> <code>nodes</code> </td> <td> A corresponding attribute for the <code>nodes</code> property. Parsed as a JSON string. </td> </tr> <tr> <td> <code>rendered-image</code> </td> <td> A corresponding attribute for the <code>renderedImage</code> property. </td> </tr> </tbody> </table> <h3 id="api_figspec_file_viewer"><figspec-file-viewer /></h3>
-
@@ -377,20 +425,14 @@ </thead><tbody> <tr> <td> <code>documentNode</code> <code>apiResponse</code> </td> <td> <code ><a href="https://jemgold.github.io/figma-js/interfaces/fileresponse.html" >FileResponse</a > | null</code > <code>GetFileResponse | null</code> </td> <td><code>null</code></td> <td> Required. A response of the Required. Response body of the <a href="https://www.figma.com/developers/api#get-files-endpoint" >
-
@@ -420,53 +462,6 @@ >API. </td> </tr> <tr> <td> <code>selectedPage</code> </td> <td> <code ><a href="https://jemgold.github.io/figma-js/interfaces/canvas.html" >Canvas</a > | null</code > </td> <td> <code>null</code> </td> <td>Currently selected page object.</td> </tr> </tbody> </table> <h4>Attributes</h4> <table> <thead> <tr> <th>Name</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td> <code>document-node</code> </td> <td> A corresponding attribute for the <code>documentNode</code> property. Parsed as a JSON string. </td> </tr> <tr> <td> <code>rendered-images</code> </td> <td> A corresponding attribute for the <code>renderedImages</code> property. Parsed as a JSON string. </td> </tr> </tbody> </table> </section>
-
@@ -480,16 +475,16 @@ File viewer</a> </li> <li> <a href="./examples/custom-speed.html" target="example_frame"> Custom pan&zoom speed </a> </li> <li> <a href="./examples/parameter-missing-error.html" target="example_frame" > Parameter missing error Parameter missing (placeholder) </a> </li> <li> <a href="./examples/missing-image.html" target="example_frame"> Image missing (error) </a> </li> <li>
-
-
-
@@ -3,10 +3,29 @@import * as demoFrame from "./examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.json"; import demoImage from "./examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.svg"; const PREFERENCES_KEY = "figspec_preferences_v1"; const savedPreferences = localStorage.getItem(PREFERENCES_KEY); const demo = document.getElementById("frame_demo"); if (demo && demo instanceof FigspecFrameViewer) { // @ts-ignore: It's machine-generated data and library types. Nothing I can do here. demo.nodes = demoFrame; try { if (savedPreferences) { const value = JSON.parse(savedPreferences); demo.preferences = value; } } catch (error) { console.error("Failed to restore saved preferences"); } demo.apiResponse = demoFrame; demo.renderedImage = demoImage; demo.addEventListener("preferencesupdate", (ev) => { const { preferences } = (ev as CustomEvent).detail; localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); }); }
-
-
-
@@ -2,10 +2,10 @@ @import "@picocss/pico/css/pico.classless.min.css";figspec-frame-viewer, figspec-file-viewer { --figspec-font-size: 0.8rem; width: 100%; /* 50vh is for shallow viewports */ min-height: min(20rem, 50vh); font-size: 0.8em; box-shadow: var(--card-box-shadow); border-radius: var(--border-radius);
-
@@ -18,3 +18,8 @@box-shadow: var(--card-box-shadow); border-radius: var(--border-radius); } table { width: 100%; table-layout: fixed; }
-
-
-
@@ -170,11 +170,11 @@ figma-js: ^1.13.0glob: ^10.3.2 husky: ^8.0.1 lint-staged: ^13.0.3 lit: ^2.1.3 node-fetch: ^2.6.1 prettier: ^2.1.2 typescript: ^4.7.4 prettier: ^3.0.2 typescript: ^5.1.6 vite: ^4.4.2 vitest: ^0.34.2 languageName: unknown linkType: soft
-
@@ -199,10 +199,19 @@ checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeblanguageName: node linkType: hard "@lit/reactive-element@npm:^1.1.0": version: 1.2.2 resolution: "@lit/reactive-element@npm:1.2.2" checksum: 2fcd2cd1a50f7298100f0c4cdb57bd45e2b27cb562efc4ec779ec90791e60db6eae89546387ac6cd51f98dce94aba014a976ff81c7193923eeff56001b92af4c "@jest/schemas@npm:^29.6.0": version: 29.6.0 resolution: "@jest/schemas@npm:29.6.0" dependencies: "@sinclair/typebox": ^0.27.8 checksum: c00511c69cf89138a7d974404d3a5060af375b5a52b9c87215d91873129b382ca11c1ff25bd6d605951404bb381ddce5f8091004a61e76457da35db1f5c51365 languageName: node linkType: hard "@jridgewell/sourcemap-codec@npm:^1.4.15": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8 languageName: node linkType: hard
-
@@ -240,6 +249,13 @@ checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0flanguageName: node linkType: hard "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 languageName: node linkType: hard "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0"
-
@@ -247,10 +263,79 @@ checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8languageName: node linkType: hard "@types/trusted-types@npm:^2.0.2": version: 2.0.2 resolution: "@types/trusted-types@npm:2.0.2" checksum: 3371eef5f1c50e1c3c07a127c1207b262ba65b83dd167a1c460fc1b135a3fb0c97b9f508efebd383f239cc5dd5b7169093686a692a501fde9c3f7208657d9b0d "@types/chai-subset@npm:^1.3.3": version: 1.3.3 resolution: "@types/chai-subset@npm:1.3.3" dependencies: "@types/chai": "*" checksum: 4481da7345022995f5a105e6683744f7203d2c3d19cfe88d8e17274d045722948abf55e0adfd97709e0f043dade37a4d4e98cd4c660e2e8a14f23e6ecf79418f languageName: node linkType: hard "@types/chai@npm:*, @types/chai@npm:^4.3.5": version: 4.3.5 resolution: "@types/chai@npm:4.3.5" checksum: c8f26a88c6b5b53a3275c7f5ff8f107028e3cbb9ff26795fff5f3d9dea07106a54ce9e2dce5e40347f7c4cc35657900aaf0c83934a25a1ae12e61e0f5516e431 languageName: node linkType: hard "@types/node@npm:*": version: 20.5.1 resolution: "@types/node@npm:20.5.1" checksum: 3dbe611cd67afa987102c8558ee70f848949c5dcfee5f60abc073e55c0d7b048e391bf06bb1e0dc052cb7210ca97136ac496cbaf6e89123c989de6bd125fde82 languageName: node linkType: hard "@vitest/expect@npm:0.34.2": version: 0.34.2 resolution: "@vitest/expect@npm:0.34.2" dependencies: "@vitest/spy": 0.34.2 "@vitest/utils": 0.34.2 chai: ^4.3.7 checksum: 974ae239f2799d0fdba0ba8acba9146d09a16c64b5270b7aec768d35ea4ab77d0e4a70edbc24bf47160696d99183b8c761ba6701d6429bb87d3de8ded2b204ec languageName: node linkType: hard "@vitest/runner@npm:0.34.2": version: 0.34.2 resolution: "@vitest/runner@npm:0.34.2" dependencies: "@vitest/utils": 0.34.2 p-limit: ^4.0.0 pathe: ^1.1.1 checksum: 3b97304fcc1e48d31446940d5c19c3b3e3028110d7c9685729b20407a8a6913947c76107a924cec2d638283a27d3e36e1299bb4a6fc7d2d1c7b7b8dbedadaa2f languageName: node linkType: hard "@vitest/snapshot@npm:0.34.2": version: 0.34.2 resolution: "@vitest/snapshot@npm:0.34.2" dependencies: magic-string: ^0.30.1 pathe: ^1.1.1 pretty-format: ^29.5.0 checksum: abefb685f46ffb66d805999c868977543b976719bd8afc91596d91e0b50a452a41a1a5f6fda78d0e1f7e43f02f64d30c652727b971526c57af9b56008e7b7418 languageName: node linkType: hard "@vitest/spy@npm:0.34.2": version: 0.34.2 resolution: "@vitest/spy@npm:0.34.2" dependencies: tinyspy: ^2.1.1 checksum: 25f6a14219e6a90f2c0bd5017c7d8d872fb34832a4c30b60f47b64ff48d3970d90666ec67534b046dd9c550e67f92797ade6d3925d3e339003e7caddd458d901 languageName: node linkType: hard "@vitest/utils@npm:0.34.2": version: 0.34.2 resolution: "@vitest/utils@npm:0.34.2" dependencies: diff-sequences: ^29.4.3 loupe: ^2.3.6 pretty-format: ^29.5.0 checksum: 55081528a475413759bf752ec084ccfc013e1f549c4f9523535034c86aab6d2f8711ac44d462817d01d3ccb1608f9150809a94896a681be8602d78554b162037 languageName: node linkType: hard
-
@@ -258,6 +343,22 @@ "abbrev@npm:1":version: 1.1.1 resolution: "abbrev@npm:1.1.1" checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17 languageName: node linkType: hard "acorn-walk@npm:^8.2.0": version: 8.2.0 resolution: "acorn-walk@npm:8.2.0" checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1 languageName: node linkType: hard "acorn@npm:^8.10.0, acorn@npm:^8.9.0": version: 8.10.0 resolution: "acorn@npm:8.10.0" bin: acorn: bin/acorn checksum: 538ba38af0cc9e5ef983aee196c4b8b4d87c0c94532334fa7e065b2c8a1f85863467bb774231aae91613fcda5e68740c15d97b1967ae3394d20faddddd8af61d languageName: node linkType: hard
-
@@ -330,6 +431,13 @@ checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4languageName: node linkType: hard "ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 languageName: node linkType: hard "ansi-styles@npm:^6.0.0": version: 6.1.0 resolution: "ansi-styles@npm:6.1.0"
-
@@ -358,6 +466,13 @@ dependencies:delegates: ^1.0.0 readable-stream: ^3.6.0 checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 languageName: node linkType: hard "assertion-error@npm:^1.1.0": version: 1.1.0 resolution: "assertion-error@npm:1.1.0" checksum: fd9429d3a3d4fd61782eb3962ae76b6d08aa7383123fca0596020013b3ebd6647891a85b05ce821c47d1471ed1271f00b0545cf6a4326cf2fc91efcc3b0fbecf languageName: node linkType: hard
-
@@ -412,6 +527,13 @@ checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459languageName: node linkType: hard "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" checksum: 45a2496a9443abbe7f52a49b22fbe51b1905eff46e03fd5e6c98e3f85077be3f8949685a1849b1a9cd2bc3e5567dfebcf64f01ce01847baf918f1b37c839791a languageName: node linkType: hard "cacache@npm:^16.1.0": version: 16.1.1 resolution: "cacache@npm:16.1.1"
-
@@ -435,6 +557,28 @@ ssri: ^9.0.0tar: ^6.1.11 unique-filename: ^1.1.1 checksum: 488524617008b793f0249b0c4ea2c330c710ca997921376e15650cc2415a8054491ae2dee9f01382c2015602c0641f3f977faf2fa7361aa33d2637dcfb03907a languageName: node linkType: hard "chai@npm:^4.3.7": version: 4.3.7 resolution: "chai@npm:4.3.7" dependencies: assertion-error: ^1.1.0 check-error: ^1.0.2 deep-eql: ^4.1.2 get-func-name: ^2.0.0 loupe: ^2.3.1 pathval: ^1.1.1 type-detect: ^4.0.5 checksum: 0bba7d267848015246a66995f044ce3f0ebc35e530da3cbdf171db744e14cbe301ab913a8d07caf7952b430257ccbb1a4a983c570a7c5748dc537897e5131f7c languageName: node linkType: hard "check-error@npm:^1.0.2": version: 1.0.2 resolution: "check-error@npm:1.0.2" checksum: d9d106504404b8addd1ee3f63f8c0eaa7cd962a1a28eb9c519b1c4a1dc7098be38007fc0060f045ee00f075fbb7a2a4f42abcf61d68323677e11ab98dc16042e languageName: node linkType: hard
-
@@ -582,6 +726,15 @@ peerDependenciesMeta:supports-color: optional: true checksum: f901c2a64e5db14068145ebc82ff263aa484d2285fe11ff7c561827df2024d05dcaf3f320c85b519b7b77369e513eb0a46e206c6364ae6819a87d29b0284403b languageName: node linkType: hard "deep-eql@npm:^4.1.2": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" dependencies: type-detect: ^4.0.0 checksum: 7f6d30cb41c713973dc07eaadded848b2ab0b835e518a88b91bea72f34e08c4c71d167a722a6f302d3a6108f05afd8e6d7650689a84d5d29ec7fe6220420397f languageName: node linkType: hard
-
@@ -599,6 +752,13 @@ checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9languageName: node linkType: hard "diff-sequences@npm:^29.4.3": version: 29.4.3 resolution: "diff-sequences@npm:29.4.3" checksum: 28b265e04fdddcf7f9f814effe102cc95a9dec0564a579b5aed140edb24fc345c611ca52d76d725a3cab55d3888b915b5e8a4702e0f6058968a90fa5f41fcde7 languageName: node linkType: hard "dotenv@npm:^8.2.0": version: 8.2.0 resolution: "dotenv@npm:8.2.0"
-
@@ -832,6 +992,13 @@ checksum: 788b6bfe52f1dd8e263cda800c26ac0ca2ff6de0b6eee2fe0d9e3abf15e149b651bd27bf5226be10e6e3edb5c4e5d5985a5a1a98137e7a892f75eff76467ad2dlanguageName: node linkType: hard "get-func-name@npm:^2.0.0": version: 2.0.0 resolution: "get-func-name@npm:2.0.0" checksum: 8d82e69f3e7fab9e27c547945dfe5cc0c57fc0adf08ce135dddb01081d75684a03e7a0487466f478872b341d52ac763ae49e660d01ab83741f74932085f693c3 languageName: node linkType: hard "get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1"
-
@@ -1057,6 +1224,13 @@ checksum: e29291c0d0f280a063fa18fbd1e891ab8c2d7519fd34052c0ebde38538a15c603140d60c2c7f432375ff7ee4c5f1c10daa8b2ae19a97c3d4affe308c8360c1dflanguageName: node linkType: hard "jsonc-parser@npm:^3.2.0": version: 3.2.0 resolution: "jsonc-parser@npm:3.2.0" checksum: 946dd9a5f326b745aa326d48a7257e3f4a4b62c5e98ec8e49fa2bdd8d96cef7e6febf1399f5c7016114fd1f68a1c62c6138826d5d90bc650448e3cf0951c53c7 languageName: node linkType: hard "lilconfig@npm:2.0.5": version: 2.0.5 resolution: "lilconfig@npm:2.0.5"
-
@@ -1108,33 +1282,10 @@ checksum: 7af31851abe25969ef0581c6db808117e36af15b131401795182427769d9824f451ba9e8aff6ccd25b6a4f6c8796f816292caf08e5f1f9b1775e8e9c313dc6c5languageName: node linkType: hard "lit-element@npm:^3.1.0": version: 3.1.2 resolution: "lit-element@npm:3.1.2" dependencies: "@lit/reactive-element": ^1.1.0 lit-html: ^2.1.0 checksum: 56ae568369af7c51cfe7187136ffb0782308ed3a12aa664085bd6761ff89fb60d3ac2ef286ce15154a2eb179d89f8655f7adaa18b8c083091c03dc75dd2ebf16 languageName: node linkType: hard "lit-html@npm:^2.1.0": version: 2.1.3 resolution: "lit-html@npm:2.1.3" dependencies: "@types/trusted-types": ^2.0.2 checksum: 655ac257298ec77a474b1693ae8bceee1bbd0c9c6f536c5731b626c6342c23974b722001526aaf52f9628021512377e2810066ce531a70cd8be2ad94ef39c0e4 languageName: node linkType: hard "lit@npm:^2.1.3": version: 2.1.3 resolution: "lit@npm:2.1.3" dependencies: "@lit/reactive-element": ^1.1.0 lit-element: ^3.1.0 lit-html: ^2.1.0 checksum: 750a45513a4b63c8f9dfffaba9109cf1c8701b29db9530a5fedaef7e24b6f1e4822687cee320981e8a268339e7dd44916caa74958e86bf41ac4137397f3d3727 "local-pkg@npm:^0.4.3": version: 0.4.3 resolution: "local-pkg@npm:0.4.3" checksum: 7825aca531dd6afa3a3712a0208697aa4a5cd009065f32e3fb732aafcc42ed11f277b5ac67229222e96f4def55197171cdf3d5522d0381b489d2e5547b407d55 languageName: node linkType: hard
-
@@ -1150,6 +1301,15 @@ checksum: ae2f85bbabc1906034154fb7d4c4477c79b3e703d22d78adee8b3862fa913942772e7fa11713e3d96fb46de4e3cabefbf5d0a544344f03b58d3c4bff52aa9eb2languageName: node linkType: hard "loupe@npm:^2.3.1, loupe@npm:^2.3.6": version: 2.3.6 resolution: "loupe@npm:2.3.6" dependencies: get-func-name: ^2.0.0 checksum: cc83f1b124a1df7384601d72d8d1f5fe95fd7a8185469fec48bb2e4027e45243949e7a013e8d91051a138451ff0552310c32aa9786e60b6a30d1e801bdc2163f languageName: node linkType: hard "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0"
-
@@ -1170,6 +1330,15 @@ "lru-cache@npm:^9.1.1 || ^10.0.0":version: 10.0.0 resolution: "lru-cache@npm:10.0.0" checksum: 18f101675fe283bc09cda0ef1e3cc83781aeb8373b439f086f758d1d91b28730950db785999cd060d3c825a8571c03073e8c14512b6655af2188d623031baf50 languageName: node linkType: hard "magic-string@npm:^0.30.1": version: 0.30.3 resolution: "magic-string@npm:0.30.3" dependencies: "@jridgewell/sourcemap-codec": ^1.4.15 checksum: a5a9ddf9bd3bf49a2de1048bf358464f1bda7b3cc1311550f4a0ba8f81a4070e25445d53a5ee28850161336f1bff3cf28aa3320c6b4aeff45ce3e689f300b2f3 languageName: node linkType: hard
-
@@ -1350,6 +1519,18 @@ checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298flanguageName: node linkType: hard "mlly@npm:^1.2.0, mlly@npm:^1.4.0": version: 1.4.0 resolution: "mlly@npm:1.4.0" dependencies: acorn: ^8.9.0 pathe: ^1.1.1 pkg-types: ^1.0.3 ufo: ^1.1.2 checksum: ebf2e2b5cfb4c6e45e8d0bbe82710952247023f12626cb0997c41b1bb6e57c8b6fc113aa709228ad511382ab0b4eebaab759806be0578093b3635d3e940bd63b languageName: node linkType: hard "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0"
-
@@ -1487,6 +1668,15 @@ checksum: 0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788languageName: node linkType: hard "p-limit@npm:^4.0.0": version: 4.0.0 resolution: "p-limit@npm:4.0.0" dependencies: yocto-queue: ^1.0.0 checksum: 01d9d70695187788f984226e16c903475ec6a947ee7b21948d6f597bed788e3112cc7ec2e171c1d37125057a5f45f3da21d8653e04a3a793589e12e9e80e756b languageName: node linkType: hard "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0"
-
@@ -1524,6 +1714,20 @@ dependencies:lru-cache: ^9.1.1 || ^10.0.0 minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 checksum: e2557cff3a8fb8bc07afdd6ab163a92587884f9969b05bbbaf6fe7379348bfb09af9ed292af12ed32398b15fb443e81692047b786d1eeb6d898a51eb17ed7d90 languageName: node linkType: hard "pathe@npm:^1.1.0, pathe@npm:^1.1.1": version: 1.1.1 resolution: "pathe@npm:1.1.1" checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759 languageName: node linkType: hard "pathval@npm:^1.1.1": version: 1.1.1 resolution: "pathval@npm:1.1.1" checksum: 090e3147716647fb7fb5b4b8c8e5b55e5d0a6086d085b6cd23f3d3c01fcf0ff56fd3cc22f2f4a033bd2e46ed55d61ed8379e123b42afe7d531a2a5fc8bb556d6 languageName: node linkType: hard
-
@@ -1550,6 +1754,17 @@ checksum: 8fbc073ede9209dd15e80d616e65eb674986c93be49f42d9ddde8dbbd141bb53d628a7ca4e58ab5c370bb00383f67d75df59a9a226dede8fa801267a7030c27alanguageName: node linkType: hard "pkg-types@npm:^1.0.3": version: 1.0.3 resolution: "pkg-types@npm:1.0.3" dependencies: jsonc-parser: ^3.2.0 mlly: ^1.2.0 pathe: ^1.1.0 checksum: 4b305c834b912ddcc8a0fe77530c0b0321fe340396f84cbb87aecdbc126606f47f2178f23b8639e71a4870f9631c7217aef52ffed0ae17ea2dbbe7e43d116a6e languageName: node linkType: hard "postcss@npm:^8.4.24": version: 8.4.25 resolution: "postcss@npm:8.4.25"
-
@@ -1561,12 +1776,34 @@ checksum: 9ed3ab8af43ad5210c28f56f916fd9b8c9f94fbeaebbf645dcf579bc28bdd8056c2a7ecc934668d399b81fedb6128f0c4b299f931e50454964bc911c25a3a0a2languageName: node linkType: hard "prettier@npm:^2.1.2": version: 2.1.2 resolution: "prettier@npm:2.1.2" "postcss@npm:^8.4.27": version: 8.4.28 resolution: "postcss@npm:8.4.28" dependencies: nanoid: ^3.3.6 picocolors: ^1.0.0 source-map-js: ^1.0.2 checksum: f605c24a36f7e400bad379735fbfc893ccb8d293ad6d419bb824db77cdcb69f43d614ef35f9f7091f32ca588d130ec60dbcf53b366e6bf88a8a64bbeb3c05f6d languageName: node linkType: hard "prettier@npm:^3.0.2": version: 3.0.2 resolution: "prettier@npm:3.0.2" bin: prettier: bin-prettier.js checksum: 7bc5a2ff5e6caf585b003fbdb1645719d5f5fcd2a03b08bae75a5608a7155fd6f84bda146104b3b6b0d9dc06720ffbfab716eade2eaae771ce4817bcee745928 prettier: bin/prettier.cjs checksum: 118b59ddb6c80abe2315ab6d0f4dd1b253be5cfdb20622fa5b65bb1573dcd362e6dd3dcf2711dd3ebfe64aecf7bdc75de8a69dc2422dcd35bdde7610586b677a languageName: node linkType: hard "pretty-format@npm:^29.5.0": version: 29.6.2 resolution: "pretty-format@npm:29.6.2" dependencies: "@jest/schemas": ^29.6.0 ansi-styles: ^5.0.0 react-is: ^18.0.0 checksum: a0f972a44f959023c0df9cdfe9eed7540264d7f7ddf74667db8a5294444d5aa153fd47d20327df10ae86964e2ceec10e46ea06b1a5c9c12e02348b78c952c9fc languageName: node linkType: hard
-
@@ -1584,6 +1821,13 @@ dependencies:err-code: ^2.0.2 retry: ^0.12.0 checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429 languageName: node linkType: hard "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e languageName: node linkType: hard
-
@@ -1647,6 +1891,20 @@ checksum: 7186beeba0e6fd33b37aa33ede7157e77af0a0be4b75d345a51dab4be5962a8e7a1c5ec8868e7c4cd361b456eb1b47bdb75e5db32ab37a546bed383d06b40384languageName: node linkType: hard "rollup@npm:^3.27.1": version: 3.28.0 resolution: "rollup@npm:3.28.0" dependencies: fsevents: ~2.3.2 dependenciesMeta: fsevents: optional: true bin: rollup: dist/bin/rollup checksum: 6ded4a0d3ca531d68e82897d5eebaa9d085014a062620bc328f2859ccf78d6a148a51ed53f1275a5f89b55cc6d7b1440b7cee44e5a9e3a51442f809b4b26f727 languageName: node linkType: hard "rxjs@npm:^7.5.5": version: 7.5.5 resolution: "rxjs@npm:7.5.5"
-
@@ -1704,6 +1962,13 @@ checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222languageName: node linkType: hard "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" checksum: 8aa5a98640ca09fe00d74416eca97551b3e42991614a3d1b824b115fc1401543650914f651ab1311518177e4d297e80b953f4cd4cd7ea1eabe824e8f2091de01 languageName: node linkType: hard "signal-exit@npm:^3.0.2": version: 3.0.3 resolution: "signal-exit@npm:3.0.3"
-
@@ -1801,6 +2066,20 @@ checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eblanguageName: node linkType: hard "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" checksum: 2d4dc4e64e2db796de4a3c856d5943daccdfa3dd092e452a1ce059c81e9a9c29e0b9badba91b43ef0d5ff5c04ee62feb3bcc559a804e16faf447bac2d883aa99 languageName: node linkType: hard "std-env@npm:^3.3.3": version: 3.4.1 resolution: "std-env@npm:3.4.1" checksum: 69ddc91bc78bb9c1d8e2d21129a0e9c0ecd44205e79d2350cfc0df0786f9eecebec3dd0da83f9c6b21d03282319811e1eae6891cf4b90b10b48a7fa126e78c02 languageName: node linkType: hard "string-argv@npm:^0.3.1": version: 0.3.1 resolution: "string-argv@npm:0.3.1"
-
@@ -1884,6 +2163,15 @@ checksum: 23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050languageName: node linkType: hard "strip-literal@npm:^1.0.1": version: 1.3.0 resolution: "strip-literal@npm:1.3.0" dependencies: acorn: ^8.10.0 checksum: f5fa7e289df8ebe82e90091fd393974faf8871be087ca50114327506519323cf15f2f8fee6ebe68b5e58bfc795269cae8bdc7cb5a83e27b02b3fe953f37b0a89 languageName: node linkType: hard "tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.1.11 resolution: "tar@npm:6.1.11"
-
@@ -1905,6 +2193,27 @@ checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbdlanguageName: node linkType: hard "tinybench@npm:^2.5.0": version: 2.5.0 resolution: "tinybench@npm:2.5.0" checksum: 284bb9428f197ec8b869c543181315e65e41ccfdad3c4b6c916bb1fdae1b5c6785661b0d90cf135b48d833b03cb84dc5357b2d33ec65a1f5971fae0ab2023821 languageName: node linkType: hard "tinypool@npm:^0.7.0": version: 0.7.0 resolution: "tinypool@npm:0.7.0" checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510 languageName: node linkType: hard "tinyspy@npm:^2.1.1": version: 2.1.1 resolution: "tinyspy@npm:2.1.1" checksum: cfe669803a7f11ca912742b84c18dcc4ceecaa7661c69bc5eb608a8a802d541c48aba220df8929f6c8cd09892ad37cb5ba5958ddbbb57940e91d04681d3cee73 languageName: node linkType: hard "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1"
-
@@ -1921,6 +2230,13 @@ checksum: 8c4aa6a3c5a754bf76aefc38026134180c053b7bd2f81338cb5e5ebf96fefa0f417bff221592bf801077f5bf990562f6264fecbc42cd3309b33872cb6fc3b113languageName: node linkType: hard "type-detect@npm:^4.0.0, type-detect@npm:^4.0.5": version: 4.0.8 resolution: "type-detect@npm:4.0.8" checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15 languageName: node linkType: hard "type-fest@npm:^0.11.0": version: 0.11.0 resolution: "type-fest@npm:0.11.0"
-
@@ -1928,23 +2244,30 @@ checksum: 8e7589e1eb5ced6c8e1d3051553b59b9f525c41e58baa898229915781c7bf55db8cb2f74e56d8031f6af5af2eecc7cb8da9ca3af7e5b80b49d8ca5a81891f3f9languageName: node linkType: hard "typescript@npm:^4.7.4": version: 4.7.4 resolution: "typescript@npm:4.7.4" "typescript@npm:^5.1.6": version: 5.1.6 resolution: "typescript@npm:5.1.6" bin: tsc: bin/tsc tsserver: bin/tsserver checksum: 5750181b1cd7e6482c4195825547e70f944114fb47e58e4aa7553e62f11b3f3173766aef9c281783edfd881f7b8299cf35e3ca8caebe73d8464528c907a164df checksum: b2f2c35096035fe1f5facd1e38922ccb8558996331405eb00a5111cc948b2e733163cc22fab5db46992aba7dd520fff637f2c1df4996ff0e134e77d3249a7350 languageName: node linkType: hard "typescript@patch:typescript@^4.7.4#~builtin<compat/typescript>": version: 4.7.4 resolution: "typescript@patch:typescript@npm%3A4.7.4#~builtin<compat/typescript>::version=4.7.4&hash=7ad353" "typescript@patch:typescript@^5.1.6#~builtin<compat/typescript>": version: 5.1.6 resolution: "typescript@patch:typescript@npm%3A5.1.6#~builtin<compat/typescript>::version=5.1.6&hash=7ad353" bin: tsc: bin/tsc tsserver: bin/tsserver checksum: 9096d8f6c16cb80ef3bf96fcbbd055bf1c4a43bd14f3b7be45a9fbe7ada46ec977f604d5feed3263b4f2aa7d4c7477ce5f9cd87de0d6feedec69a983f3a4f93e checksum: 21e88b0a0c0226f9cb9fd25b9626fb05b4c0f3fddac521844a13e1f30beb8f14e90bd409a9ac43c812c5946d714d6e0dee12d5d02dfc1c562c5aacfa1f49b606 languageName: node linkType: hard "ufo@npm:^1.1.2": version: 1.2.0 resolution: "ufo@npm:1.2.0" checksum: eaac059b5fd64a6f80557093a49bb6bfd5d97aca433e641d5022db9cbd4be3e6a4011d2ffe1254cdb2fc8ab5cbe9942b0af834ee7ac7c63240ab542f5981f68e languageName: node linkType: hard
-
@@ -1973,6 +2296,62 @@ checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2languageName: node linkType: hard "vite-node@npm:0.34.2": version: 0.34.2 resolution: "vite-node@npm:0.34.2" dependencies: cac: ^6.7.14 debug: ^4.3.4 mlly: ^1.4.0 pathe: ^1.1.1 picocolors: ^1.0.0 vite: ^3.0.0 || ^4.0.0 bin: vite-node: vite-node.mjs checksum: 7861ab0b55ca01a417f2afbe9d55cf39e9cb25183a7508aeec9c4f19ae2b112d823d1fccaf66012479a805f75889c1bfdfe28b5410768a671437096bc0a8fd3e languageName: node linkType: hard "vite@npm:^3.0.0 || ^4.0.0": version: 4.4.9 resolution: "vite@npm:4.4.9" dependencies: esbuild: ^0.18.10 fsevents: ~2.3.2 postcss: ^8.4.27 rollup: ^3.27.1 peerDependencies: "@types/node": ">= 14" less: "*" lightningcss: ^1.21.0 sass: "*" stylus: "*" sugarss: "*" terser: ^5.4.0 dependenciesMeta: fsevents: optional: true peerDependenciesMeta: "@types/node": optional: true less: optional: true lightningcss: optional: true sass: optional: true stylus: optional: true sugarss: optional: true terser: optional: true bin: vite: bin/vite.js checksum: c511024ceae39c68c7dbf2ac4381ee655cd7bb62cf43867a14798bc835d3320b8fa7867a336143c30825c191c1fb4e9aa3348fce831ab617e96203080d3d2908 languageName: node linkType: hard "vite@npm:^4.4.2": version: 4.4.2 resolution: "vite@npm:4.4.2"
-
@@ -2013,6 +2392,66 @@ checksum: 37a1c3a0ccc4b80c0a696203d3608d6bf7dc98219ddc178e528b972e3305af9ab8f92dea455421024e98c59372f6a97b1b4cc409a05f757a64bff830610053c6languageName: node linkType: hard "vitest@npm:^0.34.2": version: 0.34.2 resolution: "vitest@npm:0.34.2" dependencies: "@types/chai": ^4.3.5 "@types/chai-subset": ^1.3.3 "@types/node": "*" "@vitest/expect": 0.34.2 "@vitest/runner": 0.34.2 "@vitest/snapshot": 0.34.2 "@vitest/spy": 0.34.2 "@vitest/utils": 0.34.2 acorn: ^8.9.0 acorn-walk: ^8.2.0 cac: ^6.7.14 chai: ^4.3.7 debug: ^4.3.4 local-pkg: ^0.4.3 magic-string: ^0.30.1 pathe: ^1.1.1 picocolors: ^1.0.0 std-env: ^3.3.3 strip-literal: ^1.0.1 tinybench: ^2.5.0 tinypool: ^0.7.0 vite: ^3.0.0 || ^4.0.0 vite-node: 0.34.2 why-is-node-running: ^2.2.2 peerDependencies: "@edge-runtime/vm": "*" "@vitest/browser": "*" "@vitest/ui": "*" happy-dom: "*" jsdom: "*" playwright: "*" safaridriver: "*" webdriverio: "*" peerDependenciesMeta: "@edge-runtime/vm": optional: true "@vitest/browser": optional: true "@vitest/ui": optional: true happy-dom: optional: true jsdom: optional: true playwright: optional: true safaridriver: optional: true webdriverio: optional: true bin: vitest: vitest.mjs checksum: 4dd77871583823ea389ec253a63b568e9225ae6bdac7a27a26611c52d82fdee1ca286570e0178bb879353dc0cbc545d6be997a503f7abe6d95dd29ed2fd6b61f languageName: node linkType: hard "which@npm:^2.0.1, which@npm:^2.0.2": version: 2.0.2 resolution: "which@npm:2.0.2"
-
@@ -2021,6 +2460,18 @@ isexe: ^2.0.0bin: node-which: ./bin/node-which checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 languageName: node linkType: hard "why-is-node-running@npm:^2.2.2": version: 2.2.2 resolution: "why-is-node-running@npm:2.2.2" dependencies: siginfo: ^2.0.0 stackback: 0.0.2 bin: why-is-node-running: cli.js checksum: 50820428f6a82dfc3cbce661570bcae9b658723217359b6037b67e495255409b4c8bc7931745f5c175df71210450464517cab32b2f7458ac9c40b4925065200a languageName: node linkType: hard
-
@@ -2086,3 +2537,10 @@ resolution: "yaml@npm:2.1.1"checksum: f48bb209918aa57cfaf78ef6448d1a1f8187f45c746f933268b7023dc59e5456004611879126c9bb5ea55b0a2b1c2b392dfde436931ece0c703a3d754562bb96 languageName: node linkType: hard "yocto-queue@npm:^1.0.0": version: 1.0.0 resolution: "yocto-queue@npm:1.0.0" checksum: 2cac84540f65c64ccc1683c267edce396b26b1e931aa429660aefac8fbe0188167b7aee815a3c22fa59a28a58d898d1a2b1825048f834d8d629f4c2a5d443801 languageName: node linkType: hard
-