feat: add figma plugin

This commit is contained in:
quillzhou@gmail.com 2023-12-23 15:43:54 +08:00
parent f90966808b
commit 83a1d49af4
9 changed files with 306 additions and 8 deletions

View File

@ -22,9 +22,13 @@
"check:size": "cd ./check-size && npm run start",
"test": "vitest run",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
"format": "prettier --plugin-search-dir . --write .",
"dev:figma": "concurrently -n plugin,svelte 'npm run build:plugin -- --watch --define:SITE_URL=\\\"http://localhost:5173?figma=1\\\"' 'npm run dev'",
"build:plugin": "esbuild src/figma/code.ts --bundle --target=es6 --loader:.svg=text --outfile=src/figma/dist/code.js",
"build:figma": "concurrently -n plugin,svelte 'npm run build:plugin -- --define:SITE_URL=\\\"$npm_package_config_siteURL\\\"' 'npm run build'"
},
"dependencies": {
"@figma/plugin-typings": "^1.82.0",
"@upstash/ratelimit": "1.0.0",
"@upstash/redis": "1.25.2",
"bits-ui": "0.11.8",
@ -45,6 +49,8 @@
"@typescript-eslint/eslint-plugin": "6.14.0",
"@typescript-eslint/parser": "6.14.0",
"autoprefixer": "10.4.16",
"concurrently": "^8.2.2",
"esbuild": "^0.19.10",
"eslint": "8.56.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.35.1",
@ -61,5 +67,8 @@
"typescript": "5.3.3",
"vite": "5.0.10",
"vitest": "1.0.4"
},
"config": {
"siteURL": "https://svgl.vercel.app?figma=1"
}
}

View File

@ -10,15 +10,27 @@
import { flyAndScale } from '@/utils/flyAndScale';
// Icons:
import { CopyIcon, DownloadIcon, LinkIcon, PackageIcon, PaintBucket } from 'lucide-svelte';
import { CopyIcon, DownloadIcon, LinkIcon, PackageIcon, PaintBucket, ChevronsRight } from 'lucide-svelte';
// Main Card:
import CardSpotlight from './cardSpotlight.svelte';
import { DropdownMenu } from 'bits-ui';
// Figma
import { onMount } from "svelte";
import { copyToClipboard as figmaCopyToClipboard } from '@/figma/copy-to-clipboard';
import { insertSVG as figmaInsertSVG } from '@/figma/insert-svg';
// Props:
export let svgInfo: iSVG;
let isInFigma = false
onMount(() => {
const searchParams = new URLSearchParams(window.location.search);
isInFigma = searchParams.get('figma') === '1';
});
// Download SVG:
const downloadSvg = (url?: string) => {
download(url || '');
@ -56,6 +68,11 @@
const data = {
[MIMETYPE]: getSvgContent(url, true)
};
if(isInFigma) {
const content = (await getSvgContent(url, false)) as string;
figmaCopyToClipboard(content);
} else {
try {
const clipboardItem = new ClipboardItem(data);
await navigator.clipboard.write([clipboardItem]);
@ -63,11 +80,18 @@
const content = (await getSvgContent(url, false)) as string;
await navigator.clipboard.writeText(content);
}
}
toast.success('Copied to clipboard!', {
description: `${svgInfo.title} - ${svgInfo.category}`
});
};
const insertSVG = async (url?: string) => {
const content = (await getSvgContent(url, false)) as string;
figmaInsertSVG(content);
}
// Icon Stroke & Size:
let iconStroke = 1.8;
let iconSize = 16;
@ -102,6 +126,38 @@
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
{#if isInFigma}
<button
title="Insert to figma"
on:click={() => {
const svgHasTheme = typeof svgInfo.route !== 'string';
if (!svgHasTheme) {
insertSVG(
typeof svgInfo.route === 'string'
? svgInfo.route
: "Something went wrong. Couldn't copy the SVG."
);
return;
}
const dark = document.documentElement.classList.contains('dark');
insertSVG(
typeof svgInfo.route !== 'string'
? dark
? svgInfo.route.dark
: svgInfo.route.light
: svgInfo.route
);
}}
class="flex items-center space-x-2 rounded-md p-2 duration-100 hover:bg-neutral-200 dark:hover:bg-neutral-700/40"
>
<ChevronsRight size={iconSize} strokeWidth={iconStroke} />
</button>
{/if}
<button
title="Copy to clipboard"
on:click={() => {

37
src/figma/code.ts Normal file
View File

@ -0,0 +1,37 @@
declare const SITE_URL: string
figma.showUI(`<script>window.location.href = '${SITE_URL}'</script>`, {
width: 400,
height: 700,
})
figma.ui.onmessage = async (message, props) => {
if (!SITE_URL.includes(props.origin)) {
return
}
switch (message.type) {
case 'EVAL': {
const fn = eval.call(null, message.code)
try {
const result = await fn(figma, message.params)
figma.ui.postMessage({
type: 'EVAL_RESULT',
result,
id: message.id,
})
} catch (e) {
figma.ui.postMessage({
type: 'EVAL_REJECT',
error: typeof e === 'string' ? e : e && typeof e === 'object' && 'message' in e ? e.message : null,
id: message.id,
})
}
break
}
}
}

View File

@ -0,0 +1,26 @@
export function copyToClipboard(value: string) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (window.copy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.copy(value);
} else {
const area = document.createElement('textarea');
document.body.appendChild(area);
area.value = value;
// area.focus();
area.select();
const result = document.execCommand('copy');
document.body.removeChild(area);
if (!result) {
throw new Error();
}
}
} catch (e) {
console.error(`Unable to copy the value: ${value}`);
return false;
}
return true;
}

54
src/figma/dist/code.js vendored Normal file
View File

@ -0,0 +1,54 @@
"use strict";
(() => {
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
// src/figma/code.ts
figma.showUI(`<script>window.location.href = '${"http://localhost:5173?figma=1"}'<\/script>`, {
width: 400,
height: 700
});
figma.ui.onmessage = (message, props) => __async(void 0, null, function* () {
if (!"http://localhost:5173?figma=1".includes(props.origin)) {
return;
}
switch (message.type) {
case "EVAL": {
const fn = eval.call(null, message.code);
try {
const result = yield fn(figma, message.params);
figma.ui.postMessage({
type: "EVAL_RESULT",
result,
id: message.id
});
} catch (e) {
figma.ui.postMessage({
type: "EVAL_REJECT",
error: typeof e === "string" ? e : e && typeof e === "object" && "message" in e ? e.message : null,
id: message.id
});
}
break;
}
}
});
})();

80
src/figma/figma-api.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* This is a magic file that allows us to run code in the Figma plugin context
* from the iframe. It does this by getting the code as a string, and sending it
* to the plugin via postMessage. The plugin then evals the code and sends the
* result back to the iframe. There are a few caveats:
* 1. The code cannot reference any variables outside of the function. This is
* because the code is stringified and sent to the plugin, and the plugin
* evals it. The plugin has no access to the variables in the iframe.
* 2. The return value of the function must be JSON serializable. This is
* because the result is sent back to the iframe via postMessage, which only
* supports JSON.
*
* You can get around these limitations by passing in the variables you need
* as parameters to the function.
*
* @example
* ```ts
* const result = await figmaAPI.run((figma, {nodeId}) => {
* return figma.getNodeById(nodeId)?.name;
* }, {nodeId: "0:2"});
*
* console.log(result); // "Page 1"
* ```
*/
class FigmaAPI {
private id = 0
/**
* Run a function in the Figma plugin context. The function cannot reference
* any variables outside of itself, and the return value must be JSON
* serializable. If you need to pass in variables, you can do so by passing
* them as the second parameter.
*/
run<T, U>(fn: (figma: PluginAPI, params: U) => Promise<T> | T, params?: U): Promise<T> {
return new Promise((resolve, reject) => {
const id = this.id++
const cb = (event: MessageEvent) => {
if (event.origin !== 'https://www.figma.com' && event.origin !== 'https://staging.figma.com') {
return
}
if (event.data.pluginMessage?.type === 'EVAL_RESULT') {
if (event.data.pluginMessage.id === id) {
window.removeEventListener('message', cb)
resolve(event.data.pluginMessage.result)
}
}
if (event.data.pluginMessage?.type === 'EVAL_REJECT') {
if (event.data.pluginMessage.id === id) {
window.removeEventListener('message', cb)
const message = event.data.pluginMessage.error
reject(new Error(typeof message === 'string' ? message : 'An error occurred in FigmaAPI.run()'))
}
}
}
window.addEventListener('message', cb)
const msg = {
pluginMessage: {
type: 'EVAL',
code: fn.toString(),
id,
params,
},
pluginId: '*',
}
;['https://www.figma.com', 'https://staging.figma.com'].forEach((origin) => {
try {
parent.postMessage(msg, origin)
} catch (e) {
console.error(e)
}
})
})
}
}
export const figmaAPI = new FigmaAPI()

22
src/figma/insert-svg.ts Normal file
View File

@ -0,0 +1,22 @@
import { figmaAPI } from './figma-api'
export async function insertSVG(svgString: string) {
if (!svgString) return
figmaAPI.run(
async (figma, { svgString }: { svgString: string }) => {
const node = figma.createNodeFromSvg(svgString)
const selectedNode = figma.currentPage.selection[0]
if (selectedNode) {
node.x = selectedNode.x + selectedNode.width + 20
node.y = selectedNode.y
}
figma.currentPage.appendChild(node)
figma.currentPage.selection = [node]
figma.viewport.scrollAndZoomIntoView([node])
},
{ svgString },
)
}

13
src/figma/manifest.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "SVGL",
"id": "svgl",
"api": "1.0.0",
"main": "dist/code.js",
"enableProposedApi": false,
"editorType": ["figma", "figjam"],
"permissions": ["currentuser"],
"networkAccess": {
"allowedDomains": ["*"],
"reasoning": "Internet access for local development."
}
}

View File

@ -8,6 +8,7 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
"strict": true,
"types": ["@figma/plugin-typings"]
}
}