diff --git a/.gitignore b/.gitignore index 628da991f..ade190173 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ bin/ .kotlin local.properties kotlin-js-store/ +tools/figma-plugin/node_modules/ +tools/figma-plugin/dist/ diff --git a/tools/figma-plugin/DEVELOPMENT.md b/tools/figma-plugin/DEVELOPMENT.md new file mode 100644 index 000000000..2bf466e94 --- /dev/null +++ b/tools/figma-plugin/DEVELOPMENT.md @@ -0,0 +1,13 @@ +# Figma Plugin Development + +## Build and run + +- Build converter for Wasm executable: `../../gradlew -p ../../ :sdk:figma:converter:compileProductionExecutableKotlinWasmJs` +- Install plugin package deps: `pnpm install` +- Build plugin assets: `pnpm build` +- Build converter + plugin assets: `pnpm build:all` +- Watch plugin assets: `pnpm watch` + +## Reload in Figma + +- Reload in Figma after build: `Plugins -> Development -> Hot Reload Plugin` diff --git a/tools/figma-plugin/README.md b/tools/figma-plugin/README.md new file mode 100644 index 000000000..c42408e01 --- /dev/null +++ b/tools/figma-plugin/README.md @@ -0,0 +1,59 @@ +# Valkyrie Figma Plugin + +This package contains a Figma plugin shell for exporting selected icons into Kotlin `ImageVector` source. + +## Status + +- UI + selection export flow implemented. +- Auto export is enabled by default and can be toggled off. +- Output format supports both backing property and lazy property generation. +- Plugin UI follows Figma light/dark theme tokens. +- Converter runtime is injected into `dist/ui.html` during build. +- Copy and download actions are both supported. + +## Scripts + +- `pnpm build:converter` - compile Kotlin/Wasm converter executable assets +- `pnpm build` - build plugin assets into `dist/` +- `pnpm build:all` - build converter + plugin assets +- `pnpm watch` - watch mode for development +- `pnpm typecheck` - TypeScript checks + +## Rerun in Figma + +1. Run `pnpm build:all` +2. In Figma desktop, open `Plugins -> Development -> Hot Reload Plugin` +3. Reopen `Valkyrie ImageVector Export` + +## Files + +- `manifest.json` - Figma plugin manifest +- `src/main/code.ts` - plugin main thread (selection and SVG export) +- `src/ui/ui.ts` - plugin UI entry and orchestration +- `src/ui/core/` - UI runtime primitives (dom, status, state, api, utils) +- `src/ui/controllers/` - UI request/selection lifecycle controllers +- `src/ui/features/` - conversion, rendering, settings, and bulk actions +- `src/ui/features/converterAdapter.ts` - runtime bridge to Wasm converter +- `src/shared/messages.ts` - typed message contracts between main and UI + +## Runtime hookup + +`pnpm build` reads these converter outputs: + +- `valkyrie-sdk-figma-converter.uninstantiated.mjs` +- `valkyrie-sdk-figma-converter.wasm` + +Then build-time injection inlines a Wasm bridge and exposes: + +- `window.ValkyrieFigmaWasmConverter.convertSvg(...)` +- `window.ValkyrieFigmaWasmConverter.normalizeIconName(...)` + +This avoids external script loading issues in `figma.showUI(__html__)`. + +If converter artifacts are missing, build prints warnings and the UI reports that the runtime is not loaded. + +## UX notes + +- Conversion uses request ids so stale responses do not overwrite newer runs. +- Bulk actions are disabled until at least one successful conversion exists. +- For a single converted icon, the code panel expands and increases code font size for readability. diff --git a/tools/figma-plugin/manifest.json b/tools/figma-plugin/manifest.json new file mode 100644 index 000000000..542474fd3 --- /dev/null +++ b/tools/figma-plugin/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Valkyrie ImageVector Export", + "id": "valkyrie-imagevector-export", + "api": "1.0.0", + "main": "dist/code.js", + "ui": "dist/ui.html", + "editorType": ["figma"] +} diff --git a/tools/figma-plugin/package.json b/tools/figma-plugin/package.json new file mode 100644 index 000000000..50e089e6f --- /dev/null +++ b/tools/figma-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@composegears/valkyrie-figma-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:converter": "../../gradlew -p ../../ :sdk:figma:converter:compileProductionExecutableKotlinWasmJs", + "build": "node ./scripts/build.mjs", + "build:all": "pnpm build:converter && pnpm build", + "watch": "node ./scripts/build.mjs --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@figma/plugin-typings": "^1.122.0", + "esbuild": "^0.25.10", + "typescript": "^5.9.2" + }, + "dependencies": { + "fast-text-encoding": "^1.0.6", + "fflate": "^0.8.2" + } +} diff --git a/tools/figma-plugin/pnpm-lock.yaml b/tools/figma-plugin/pnpm-lock.yaml new file mode 100644 index 000000000..1aaa4fd97 --- /dev/null +++ b/tools/figma-plugin/pnpm-lock.yaml @@ -0,0 +1,320 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + fast-text-encoding: + specifier: ^1.0.6 + version: 1.0.6 + fflate: + specifier: ^0.8.2 + version: 0.8.2 + devDependencies: + '@figma/plugin-typings': + specifier: ^1.122.0 + version: 1.123.0 + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@figma/plugin-typings@1.123.0': + resolution: {integrity: sha512-NLv2aQ8R9dP5psDplWpq+pJxRUGsJ1YEYYbBV2oTd03kS+aau7N9XWLjw52s1uVgi8jQ33N001EX3f7vSCztjQ==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + fast-text-encoding@1.0.6: + resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@figma/plugin-typings@1.123.0': {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + fast-text-encoding@1.0.6: {} + + fflate@0.8.2: {} + + typescript@5.9.3: {} diff --git a/tools/figma-plugin/scripts/build.mjs b/tools/figma-plugin/scripts/build.mjs new file mode 100644 index 000000000..860d5dd26 --- /dev/null +++ b/tools/figma-plugin/scripts/build.mjs @@ -0,0 +1,144 @@ +import { build, context } from "esbuild"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const watch = process.argv.includes("--watch"); +const root = resolve(process.cwd()); +const srcDir = resolve(root, "src"); +const mainDir = resolve(srcDir, "main"); +const uiDir = resolve(srcDir, "ui"); +const distDir = resolve(root, "dist"); +const repoRoot = resolve(root, "../.."); +const converterDistDir = resolve( + repoRoot, + "sdk/figma/converter/build/compileSync/wasmJs/main/productionExecutable/kotlin", +); + +await mkdir(distDir, { recursive: true }); + +const sharedOptions = { + bundle: true, + sourcemap: true, + target: "es2020", + logLevel: "info", +}; + +const codeConfig = { + ...sharedOptions, + // Figma's plugin sandbox uses a limited JS engine — target ES2017 to + // ensure operators like ?? and ?. are compiled down. + target: "es2017", + entryPoints: [resolve(mainDir, "code.ts")], + outfile: resolve(distDir, "code.js"), + format: "iife", + platform: "browser", +}; + +const srcUiHtmlPath = resolve(uiDir, "ui.html"); +const distUiJsPath = resolve(distDir, "ui.js"); +const distUiHtmlPath = resolve(distDir, "ui.html"); + +const escapeScriptTag = (text) => text.replaceAll(" { + if (result.errors.length > 0) { + return; + } + + try { + await writeInlinedUiHtml(); + if (watch) { + process.stdout.write("Updated dist/ui.html\n"); + } + } catch (error) { + const message = `Failed to inline ui.html: ${String(error)}\n`; + if (watch) { + process.stderr.write(message); + return; + } + throw error; + } + }); + }, + }, + ], +}; + +if (watch) { + const [codeCtx, uiCtx] = await Promise.all([context(codeConfig), context(uiConfig)]); + await Promise.all([codeCtx.watch(), uiCtx.watch()]); +} else { + await Promise.all([build(codeConfig), build(uiConfig)]); +} + +if (!watch) { + process.stdout.write("Built Figma plugin assets.\n"); +} diff --git a/tools/figma-plugin/src/fast-text-encoding.d.ts b/tools/figma-plugin/src/fast-text-encoding.d.ts new file mode 100644 index 000000000..2a4014ca4 --- /dev/null +++ b/tools/figma-plugin/src/fast-text-encoding.d.ts @@ -0,0 +1,3 @@ +declare module "fast-text-encoding" { + export const TextDecoder: typeof globalThis.TextDecoder | undefined; +} diff --git a/tools/figma-plugin/src/main/code.ts b/tools/figma-plugin/src/main/code.ts new file mode 100644 index 000000000..825cd7e53 --- /dev/null +++ b/tools/figma-plugin/src/main/code.ts @@ -0,0 +1,238 @@ +import * as fastTextEncoding from "fast-text-encoding"; + +import type { + ConversionReadyMessage, + ConversionStartedMessage, + ExportedIcon, + SelectionChangedMessage, + SettingsErrorMessage, + SettingsLoadedMessage, + UiToMainMessage, +} from "../shared/messages"; +import { createExportError, createInternalError, createSelectionError, formatPluginError } from "../shared/errorFormatter"; +import { sanitizePluginSettings } from "../shared/pluginSettings"; + +const PLUGIN_UI_SIZE = { width: 1080, height: 760, themeColors: true }; +const SETTINGS_KEY = "valkyrie-export-settings"; +const MAX_SELECTION_NAMES = 8; +const MAX_EXPORT_CONCURRENCY = 4; + +type ActiveRun = { + requestId: number; + supersedeToken: { + superseded: boolean; + }; +}; + +const textDecoderCtor: typeof TextDecoder | undefined = + typeof globalThis.TextDecoder !== "undefined" ? globalThis.TextDecoder : fastTextEncoding.TextDecoder; + +let activeRun: ActiveRun | null = null; + +figma.showUI(__html__, PLUGIN_UI_SIZE); + +// Send initial selection state +sendSelectionUpdate(); + +// Listen for selection changes +figma.on("selectionchange", () => { + sendSelectionUpdate(); +}); + +function sendSelectionUpdate(): void { + const selected = figma.currentPage.selection; + const exportable = selected.filter((node): node is SceneNode & ExportMixin => "exportAsync" in node); + const names = exportable.slice(0, MAX_SELECTION_NAMES).map((node) => node.name); + + figma.ui.postMessage({ + type: "selection-changed", + count: exportable.length, + names, + } satisfies SelectionChangedMessage); +} + +figma.ui.onmessage = async (message: UiToMainMessage) => { + if (message.type === "load-settings") { + try { + const savedSettings = await figma.clientStorage.getAsync(SETTINGS_KEY); + const settings = sanitizePluginSettings(savedSettings); + figma.ui.postMessage({ + type: "settings-loaded", + settings, + } satisfies SettingsLoadedMessage); + } catch (error) { + figma.ui.postMessage({ + type: "settings-error", + error: formatPluginError( + createInternalError(`Failed to load settings from client storage. ${String(error)}`), + ), + } satisfies SettingsErrorMessage); + + figma.ui.postMessage({ + type: "settings-loaded", + settings: null, + } satisfies SettingsLoadedMessage); + } + + return; + } + + if (message.type === "save-settings") { + try { + await figma.clientStorage.setAsync(SETTINGS_KEY, message.settings); + } catch (error) { + figma.ui.postMessage({ + type: "settings-error", + error: formatPluginError( + createInternalError(`Failed to save settings to client storage. ${String(error)}`), + ), + } satisfies SettingsErrorMessage); + } + return; + } + + if (message.type !== "run-conversion") { + return; + } + + const requestId = message.requestId; + if (activeRun && activeRun.requestId !== requestId) { + activeRun.supersedeToken.superseded = true; + } + + const supersedeToken: ActiveRun["supersedeToken"] = { + superseded: false, + }; + activeRun = { requestId, supersedeToken }; + + try { + const selected = figma.currentPage.selection; + const exportableNodes = selected.filter((node): node is SceneNode & ExportMixin => "exportAsync" in node); + + figma.ui.postMessage({ + type: "conversion-started", + requestId, + selectedCount: exportableNodes.length, + } satisfies ConversionStartedMessage); + + if (exportableNodes.length === 0) { + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons: [], + error: formatPluginError( + createSelectionError( + "Select at least one exportable icon.", + "Pick icons in the canvas and run export again.", + ), + ), + } satisfies ConversionReadyMessage); + return; + } + + const { icons, firstError, failedCount, superseded } = await exportNodesAsSvg(exportableNodes, supersedeToken); + + if (superseded) { + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons: [], + superseded: true, + } satisfies ConversionReadyMessage); + return; + } + + if (failedCount > 0 && icons.length > 0) { + figma.notify(firstError ?? `Some icons failed to export (${failedCount}).`); + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons, + attemptedCount: exportableNodes.length, + exportFailedCount: failedCount, + } satisfies ConversionReadyMessage); + return; + } + + if (firstError) { + figma.notify(firstError); + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons, + error: firstError, + } satisfies ConversionReadyMessage); + return; + } + + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons, + attemptedCount: exportableNodes.length, + exportFailedCount: 0, + } satisfies ConversionReadyMessage); + } catch (error) { + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons: [], + error: formatPluginError(createInternalError(String(error))), + } satisfies ConversionReadyMessage); + } finally { + if (activeRun && activeRun.requestId === requestId) { + activeRun = null; + } + } +}; + +function decodeUtf8(bytes: Uint8Array): string { + if (!textDecoderCtor) { + throw new Error("TextDecoder is unavailable in plugin runtime."); + } + return new textDecoderCtor("utf-8").decode(bytes); +} + +async function exportNodesAsSvg( + nodes: Array, + supersedeToken: ActiveRun["supersedeToken"], +): Promise<{ icons: ExportedIcon[]; firstError: string | null; failedCount: number; superseded: boolean }> { + const icons: Array = new Array(nodes.length).fill(null); + let firstError: string | null = null; + let failedCount = 0; + let nextIndex = 0; + + const workerCount = Math.min(MAX_EXPORT_CONCURRENCY, nodes.length); + + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < nodes.length) { + if (supersedeToken.superseded) { + break; + } + + const index = nextIndex; + nextIndex += 1; + + const node = nodes[index]; + try { + const bytes = await node.exportAsync({ format: "SVG" }); + const svg = decodeUtf8(bytes); + icons[index] = { id: node.id, name: node.name, svg }; + } catch (error) { + failedCount += 1; + if (firstError === null) { + firstError = formatPluginError(createExportError(node.name, String(error))); + } + } + } + }); + + await Promise.all(workers); + + return { + icons: icons.filter((icon): icon is ExportedIcon => icon !== null), + firstError, + failedCount, + superseded: supersedeToken.superseded, + }; +} diff --git a/tools/figma-plugin/src/shared/errorFormatter.ts b/tools/figma-plugin/src/shared/errorFormatter.ts new file mode 100644 index 000000000..a848baead --- /dev/null +++ b/tools/figma-plugin/src/shared/errorFormatter.ts @@ -0,0 +1,54 @@ +export type PluginError = { + summary: string; + nextStep: string; + diagnostics?: string; +}; + +export const DIAGNOSTICS_DELIMITER = " Diagnostics: "; + +export function formatPluginError(error: PluginError): string { + const base = `${error.summary} Next: ${error.nextStep}`; + if (!error.diagnostics) { + return base; + } + + return `${base}${DIAGNOSTICS_DELIMITER}${error.diagnostics}`; +} + +export function createSelectionError(summary: string, nextStep: string): PluginError { + return { summary, nextStep }; +} + +export function createSettingsError(summary: string, nextStep: string): PluginError { + return { summary, nextStep }; +} + +export function createExportError(nodeName: string, diagnostics: string): PluginError { + return { + summary: `Failed to export '${nodeName}'.`, + nextStep: "Check node permissions/structure, then retry.", + diagnostics, + }; +} + +export function createConverterUnavailableError(): PluginError { + return { + summary: "Converter runtime is unavailable.", + nextStep: "Run pnpm build:all in tools/figma-plugin, reload plugin, and retry.", + }; +} + +export function createTimeoutError(): PluginError { + return { + summary: "No response from Figma main thread.", + nextStep: "Check plugin console for errors, then reload and retry.", + }; +} + +export function createInternalError(diagnostics: string): PluginError { + return { + summary: "Unexpected plugin error.", + nextStep: "Retry once. If it persists, reload plugin and report the diagnostics.", + diagnostics, + }; +} diff --git a/tools/figma-plugin/src/shared/messages.ts b/tools/figma-plugin/src/shared/messages.ts new file mode 100644 index 000000000..7eea5b03a --- /dev/null +++ b/tools/figma-plugin/src/shared/messages.ts @@ -0,0 +1,65 @@ +import type { PluginSettings } from "./pluginSettings"; + +export type ExportedIcon = { + id: string; + name: string; + svg: string; +}; + +export type ConversionStartedMessage = { + type: "conversion-started"; + requestId: number; + selectedCount: number; +}; + +export type ConversionReadyMessage = { + type: "conversion-ready"; + requestId: number; + icons: ExportedIcon[]; + error?: string; + attemptedCount?: number; + exportFailedCount?: number; + superseded?: boolean; +}; + +export type SettingsErrorMessage = { + type: "settings-error"; + error: string; +}; + +export type SelectionChangedMessage = { + type: "selection-changed"; + count: number; + names: string[]; +}; + +export type SettingsLoadedMessage = { + type: "settings-loaded"; + settings: PluginSettings | null; +}; + +export type MainToUiMessage = + | ConversionStartedMessage + | ConversionReadyMessage + | SelectionChangedMessage + | SettingsLoadedMessage + | SettingsErrorMessage; + +export type RunConversionMessage = { + type: "run-conversion"; + requestId: number; +}; + +export type SaveSettingsMessage = { + type: "save-settings"; + settings: PluginSettings; +}; + +export type LoadSettingsMessage = { + type: "load-settings"; +}; + +export type UiToMainMessage = + | RunConversionMessage + | SaveSettingsMessage + | LoadSettingsMessage; diff --git a/tools/figma-plugin/src/shared/pluginSettings.ts b/tools/figma-plugin/src/shared/pluginSettings.ts new file mode 100644 index 000000000..91ff60c3a --- /dev/null +++ b/tools/figma-plugin/src/shared/pluginSettings.ts @@ -0,0 +1,80 @@ +export type OutputFormat = "backing_property" | "lazy_property"; +export type AutoMirrorOption = boolean | null; + +export type PluginSettings = { + packageName: string; + outputFormat: OutputFormat; + useComposeColors: boolean; + addTrailingComma: boolean; + useExplicitMode: boolean; + usePathDataString: boolean; + autoMirror: AutoMirrorOption; + autoExport: boolean; +}; + +export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { + packageName: "com.example.icons", + outputFormat: "backing_property", + useComposeColors: true, + addTrailingComma: false, + useExplicitMode: false, + usePathDataString: false, + autoMirror: null, + autoExport: true, +}; + +function asObject(value: unknown): Record | null { + if (value == null || typeof value !== "object") { + return null; + } + return value as Record; +} + +function asOutputFormat(value: unknown): OutputFormat | null { + return value === "backing_property" || value === "lazy_property" ? value : null; +} + +export function parseAutoMirrorOption(value: unknown): AutoMirrorOption | null { + if (value === null || value === undefined) return null; + if (typeof value === "boolean") return value; + if (value === "") return null; + if (value === "true") return true; + if (value === "false") return false; + return null; +} + +export function autoMirrorOptionToSelectValue(value: AutoMirrorOption): string { + if (value === null) return ""; + return value.toString(); +} + +export function sanitizePluginSettings(value: unknown): PluginSettings | null { + const raw = asObject(value); + if (!raw) { + return null; + } + + return { + packageName: + typeof raw.packageName === "string" ? raw.packageName.trim() : DEFAULT_PLUGIN_SETTINGS.packageName, + outputFormat: asOutputFormat(raw.outputFormat) ?? DEFAULT_PLUGIN_SETTINGS.outputFormat, + useComposeColors: + typeof raw.useComposeColors === "boolean" + ? raw.useComposeColors + : DEFAULT_PLUGIN_SETTINGS.useComposeColors, + addTrailingComma: + typeof raw.addTrailingComma === "boolean" + ? raw.addTrailingComma + : DEFAULT_PLUGIN_SETTINGS.addTrailingComma, + useExplicitMode: + typeof raw.useExplicitMode === "boolean" + ? raw.useExplicitMode + : DEFAULT_PLUGIN_SETTINGS.useExplicitMode, + usePathDataString: + typeof raw.usePathDataString === "boolean" + ? raw.usePathDataString + : DEFAULT_PLUGIN_SETTINGS.usePathDataString, + autoMirror: parseAutoMirrorOption(raw.autoMirror) ?? DEFAULT_PLUGIN_SETTINGS.autoMirror, + autoExport: typeof raw.autoExport === "boolean" ? raw.autoExport : DEFAULT_PLUGIN_SETTINGS.autoExport, + }; +} diff --git a/tools/figma-plugin/src/ui/controllers/messageHandlers.ts b/tools/figma-plugin/src/ui/controllers/messageHandlers.ts new file mode 100644 index 000000000..f1333e03c --- /dev/null +++ b/tools/figma-plugin/src/ui/controllers/messageHandlers.ts @@ -0,0 +1,74 @@ +import type { MainToUiMessage } from "../../shared/messages"; +import { applySettings } from "../features/settings"; +import { setStatus } from "../core/status"; +import { renderLoadingResults, showLoadingEmptyState } from "../features/render"; +import { runConversion } from "../features/conversion"; +import { applyRunLifecycleState } from "./runLifecycleState"; + +type SelectionController = { + handleSelectionChanged: (count: number, names: string[]) => void; + handleSettingsLoaded: () => void; +}; + +type RequestController = { + acknowledgeRequestStart: (requestId: number) => boolean; + completeRequest: (requestId: number) => boolean; +}; + +type MessageHandlerDeps = { + selectionController: SelectionController; + requestController: RequestController; +}; + +export function createMainMessageHandler(deps: MessageHandlerDeps): (message: MainToUiMessage) => void { + return (message: MainToUiMessage) => { + switch (message.type) { + case "selection-changed": { + deps.selectionController.handleSelectionChanged(message.count, message.names); + return; + } + + case "settings-loaded": { + applySettings(message.settings); + deps.selectionController.handleSettingsLoaded(); + return; + } + + case "conversion-started": { + if (!deps.requestController.acknowledgeRequestStart(message.requestId)) { + return; + } + renderLoadingResults(message.selectedCount); + showLoadingEmptyState(); + setStatus(`Exporting ${message.selectedCount} selected icon(s)...`, "working"); + return; + } + + case "conversion-ready": { + if (!deps.requestController.completeRequest(message.requestId)) { + return; + } + + if (message.superseded) { + applyRunLifecycleState("superseded"); + return; + } + + runConversion(message.icons, { + attemptedCount: message.attemptedCount, + exportFailedCount: message.exportFailedCount, + upstreamError: message.error, + }); + return; + } + + case "settings-error": { + setStatus(message.error, "error"); + return; + } + + default: + return; + } + }; +} diff --git a/tools/figma-plugin/src/ui/controllers/requestController.ts b/tools/figma-plugin/src/ui/controllers/requestController.ts new file mode 100644 index 000000000..fc3c8a32b --- /dev/null +++ b/tools/figma-plugin/src/ui/controllers/requestController.ts @@ -0,0 +1,90 @@ +import { sendMessage } from "../core/api"; +import { createTimeoutError, formatPluginError } from "../../shared/errorFormatter"; +import { setStatus } from "../core/status"; + +const REQUEST_ACK_TIMEOUT_MS = 5000; +const RUN_TIMEOUT_MS = 120000; + +type RequestState = "requested" | "started"; + +type RequestControllerDeps = { + onTimedOut?: () => void; +}; + +export function createRequestController(deps: RequestControllerDeps = {}) { + let latestRequestId = 0; + let activeRequest: { id: number; state: RequestState } | null = null; + let pendingTimeoutId: number | null = null; + + const clearPendingTimeout = (): void => { + if (pendingTimeoutId !== null) { + window.clearTimeout(pendingTimeoutId); + pendingTimeoutId = null; + } + }; + + const setPendingTimeout = (callback: () => void, ms: number): void => { + clearPendingTimeout(); + pendingTimeoutId = window.setTimeout(callback, ms); + }; + + const scheduleRequestTimeout = (requestId: number, state: RequestState, timeoutMs: number): void => { + setPendingTimeout(() => { + if (!activeRequest || activeRequest.id !== requestId || activeRequest.state !== state) { + return; + } + + activeRequest = null; + if (deps.onTimedOut) { + deps.onTimedOut(); + } else { + setStatus(formatPluginError(createTimeoutError()), "error"); + } + }, timeoutMs); + }; + + return { + cancelActiveRequest(): void { + activeRequest = null; + clearPendingTimeout(); + }, + + requestConversion(): void { + const hadActiveRequest = activeRequest !== null; + latestRequestId += 1; + const requestId = latestRequestId; + activeRequest = { id: requestId, state: "requested" }; + + setStatus( + hadActiveRequest + ? "Previous run superseded by newer run. Requesting latest export from Figma..." + : "Requesting export from Figma...", + "working", + ); + scheduleRequestTimeout(requestId, "requested", REQUEST_ACK_TIMEOUT_MS); + + sendMessage({ type: "run-conversion", requestId }); + }, + + acknowledgeRequestStart(requestId: number): boolean { + if (!activeRequest || activeRequest.id !== requestId || activeRequest.state !== "requested") { + return false; + } + + clearPendingTimeout(); + activeRequest.state = "started"; + scheduleRequestTimeout(requestId, "started", RUN_TIMEOUT_MS); + return true; + }, + + completeRequest(requestId: number): boolean { + if (!activeRequest || activeRequest.id !== requestId) { + return false; + } + + activeRequest = null; + clearPendingTimeout(); + return true; + }, + }; +} diff --git a/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts new file mode 100644 index 000000000..ac08c7e5a --- /dev/null +++ b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts @@ -0,0 +1,43 @@ +import { getConversionResults, getConversionResultsCount } from "../core/state"; +import { isLoadingResultsVisible, renderResults } from "../features/render"; +import { setStatus } from "../core/status"; +import { createTimeoutError, formatPluginError } from "../../shared/errorFormatter"; + +export type RunLifecycleState = "superseded" | "timed-out"; + +function restorePreviousResultsWhenLoading(): void { + if (getConversionResultsCount() === 0) { + return; + } + + if (isLoadingResultsVisible()) { + renderResults(Array.from(getConversionResults())); + } +} + +function clearLoadingResultsWhenNoPrevious(): void { + if (getConversionResultsCount() !== 0) { + return; + } + + if (isLoadingResultsVisible()) { + renderResults([]); + } +} + +export function applyRunLifecycleState(state: RunLifecycleState): void { + switch (state) { + case "superseded": { + setStatus("Run superseded by a newer request.", "ready"); + restorePreviousResultsWhenLoading(); + return; + } + + case "timed-out": { + restorePreviousResultsWhenLoading(); + clearLoadingResultsWhenNoPrevious(); + setStatus(formatPluginError(createTimeoutError()), "error"); + return; + } + } +} diff --git a/tools/figma-plugin/src/ui/controllers/selectionController.ts b/tools/figma-plugin/src/ui/controllers/selectionController.ts new file mode 100644 index 000000000..89d533557 --- /dev/null +++ b/tools/figma-plugin/src/ui/controllers/selectionController.ts @@ -0,0 +1,147 @@ +import { autoExportInput, runButton } from "../core/dom"; +import { renderResults, showAutoExportDisabledEmptyState, showDefaultEmptyState, showLoadingEmptyState, updateSelectionPreview } from "../features/render"; +import { clearConversionResults } from "../core/state"; + +const AUTO_RUN_DEBOUNCE_MS = 300; + +type SelectionControllerDeps = { + requestConversion: () => void; + cancelActiveRequest: () => void; + updateBulkActionState: () => void; +}; + +type SelectionUiState = "default-empty" | "auto-export-disabled" | "auto-export-ready"; + +function updateRunButtonLabel(): void { + runButton.textContent = autoExportInput.checked ? "Refresh" : "Export"; +} + +function showSelectionEmptyState(state: Exclude): void { + if (state === "auto-export-disabled") { + showAutoExportDisabledEmptyState(); + return; + } + + showDefaultEmptyState(); +} + +export function createSelectionController(deps: SelectionControllerDeps) { + let autoRunTimeoutId: number | null = null; + let latestSelectionCount = 0; + let hasRequestedInitialAutoConversion = false; + let settingsInitialized = false; + + const scheduleAutoConversion = (immediate = false): boolean => { + if (autoRunTimeoutId !== null) { + window.clearTimeout(autoRunTimeoutId); + autoRunTimeoutId = null; + } + + if (!autoExportInput.checked || latestSelectionCount === 0) { + return false; + } + + if (immediate) { + hasRequestedInitialAutoConversion = true; + deps.requestConversion(); + return true; + } + + autoRunTimeoutId = window.setTimeout(() => { + autoRunTimeoutId = null; + hasRequestedInitialAutoConversion = true; + deps.requestConversion(); + }, AUTO_RUN_DEBOUNCE_MS); + + return true; + }; + + const deriveSelectionUiState = (): SelectionUiState => { + if (latestSelectionCount === 0 || !settingsInitialized) { + return "default-empty"; + } + + if (!autoExportInput.checked) { + return "auto-export-disabled"; + } + + return "auto-export-ready"; + }; + + return { + handleSelectionChanged(count: number, names: string[]): void { + latestSelectionCount = count; + updateRunButtonLabel(); + updateSelectionPreview(count, names); + + if (count === 0) { + deps.cancelActiveRequest(); + scheduleAutoConversion(); + clearConversionResults(); + renderResults([]); + deps.updateBulkActionState(); + showSelectionEmptyState("default-empty"); + return; + } + + const uiState = deriveSelectionUiState(); + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); + return; + } + + if (uiState === "auto-export-disabled") { + scheduleAutoConversion(); + showSelectionEmptyState(uiState); + return; + } + + const shouldRunImmediately = !hasRequestedInitialAutoConversion; + if (scheduleAutoConversion(shouldRunImmediately)) { + showLoadingEmptyState(); + } + }, + + handleSettingsLoaded(): void { + settingsInitialized = true; + updateRunButtonLabel(); + + const uiState = deriveSelectionUiState(); + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); + return; + } + + if (uiState === "auto-export-disabled") { + scheduleAutoConversion(); + showSelectionEmptyState(uiState); + return; + } + + if (scheduleAutoConversion(!hasRequestedInitialAutoConversion)) { + showLoadingEmptyState(); + } + }, + + handleSettingsInputChanged(): void { + updateRunButtonLabel(); + const uiState = deriveSelectionUiState(); + + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); + return; + } + + if (uiState === "auto-export-disabled") { + scheduleAutoConversion(); + showSelectionEmptyState(uiState); + return; + } + + const isScheduled = scheduleAutoConversion(); + if (isScheduled) { + showLoadingEmptyState(); + } + }, + }; +} diff --git a/tools/figma-plugin/src/ui/core/api.ts b/tools/figma-plugin/src/ui/core/api.ts new file mode 100644 index 000000000..2838299d9 --- /dev/null +++ b/tools/figma-plugin/src/ui/core/api.ts @@ -0,0 +1,22 @@ +import type { MainToUiMessage, UiToMainMessage } from "../../shared/messages"; + +export function sendMessage(message: UiToMainMessage): void { + parent.postMessage({ pluginMessage: message }, "*"); +} + +export function onMessage(handler: (message: MainToUiMessage) => void): () => void { + const listener = (event: MessageEvent<{ pluginMessage?: MainToUiMessage }>) => { + const message = event.data?.pluginMessage; + if (message) { + handler(message); + } + }; + window.addEventListener("message", listener); + return () => { + window.removeEventListener("message", listener); + }; +} + +export function onError(handler: (event: ErrorEvent) => void): void { + window.addEventListener("error", handler); +} diff --git a/tools/figma-plugin/src/ui/core/dom.ts b/tools/figma-plugin/src/ui/core/dom.ts new file mode 100644 index 000000000..9076d6810 --- /dev/null +++ b/tools/figma-plugin/src/ui/core/dom.ts @@ -0,0 +1,32 @@ +export const runButton = document.querySelector("#run")!; +export const copyAllButton = document.querySelector("#copy-all")!; +export const downloadAllButton = document.querySelector("#download-all")!; +export const statusText = document.querySelector("#status")!; +export const statusIcon = document.querySelector("#status-icon")!; +export const statusDetails = document.querySelector("#status-details")!; +export const statusDiagnostics = document.querySelector("#status-diagnostics")!; +export const packageInput = document.querySelector("#package")!; +export const outputFormatInput = document.querySelector("#output-format")!; +export const useComposeColorsInput = document.querySelector("#compose-colors")!; +export const addTrailingCommaInput = document.querySelector("#trailing-comma")!; +export const useExplicitModeInput = document.querySelector("#explicit-mode")!; +export const usePathDataStringInput = document.querySelector("#path-data")!; +export const autoMirrorInput = document.querySelector("#auto-mirror")!; +export const autoExportInput = document.querySelector("#auto-export")!; +export const resultsContainer = document.querySelector("#results")!; +export const emptyState = document.querySelector("#empty-state")!; +export const emptyStateTitle = emptyState.querySelector("h3")!; +export const emptyStateDescription = emptyState.querySelector("p")!; +export const selectionPreview = document.querySelector("#selection-preview")!; +export const mainScroll = document.querySelector("#main-scroll")!; + +export const settingsInputs = [ + packageInput, + outputFormatInput, + useComposeColorsInput, + addTrailingCommaInput, + useExplicitModeInput, + usePathDataStringInput, + autoMirrorInput, + autoExportInput, +]; diff --git a/tools/figma-plugin/src/ui/core/state.ts b/tools/figma-plugin/src/ui/core/state.ts new file mode 100644 index 000000000..f18ed694f --- /dev/null +++ b/tools/figma-plugin/src/ui/core/state.ts @@ -0,0 +1,28 @@ +import type { ConvertResultWithSvg } from "./types"; + +const conversionResults: ConvertResultWithSvg[] = []; + +export function getConversionResults(): ReadonlyArray { + return conversionResults; +} + +export function getConversionResultsCount(): number { + return conversionResults.length; +} + +export function clearConversionResults(): void { + conversionResults.length = 0; +} + +export function replaceConversionResults(results: ConvertResultWithSvg[]): void { + conversionResults.length = 0; + conversionResults.push(...results); +} + +export function hasSuccessfulConversionResults(): boolean { + return conversionResults.some((item) => item.success); +} + +export function getSuccessfulConversionResults(): ConvertResultWithSvg[] { + return conversionResults.filter((item) => item.success); +} diff --git a/tools/figma-plugin/src/ui/core/status.ts b/tools/figma-plugin/src/ui/core/status.ts new file mode 100644 index 000000000..5270b1c31 --- /dev/null +++ b/tools/figma-plugin/src/ui/core/status.ts @@ -0,0 +1,30 @@ +import { statusText, statusIcon, statusDetails, statusDiagnostics } from "./dom"; +import type { StatusType } from "./types"; +import { DIAGNOSTICS_DELIMITER } from "../../shared/errorFormatter"; + +function splitDiagnostics(message: string): { summary: string; diagnostics: string | null } { + const markerIndex = message.indexOf(DIAGNOSTICS_DELIMITER); + if (markerIndex < 0) { + return { summary: message, diagnostics: null }; + } + + const summary = message.slice(0, markerIndex).trim(); + const diagnostics = message.slice(markerIndex + DIAGNOSTICS_DELIMITER.length).trim(); + return { summary, diagnostics: diagnostics.length > 0 ? diagnostics : null }; +} + +export function setStatus(message: string, type: StatusType = "ready"): void { + const { summary, diagnostics } = splitDiagnostics(message); + + statusText.textContent = summary; + statusIcon.className = `status-icon ${type}`; + + if (diagnostics) { + statusDiagnostics.textContent = diagnostics; + statusDetails.classList.remove("hidden"); + } else { + statusDiagnostics.textContent = ""; + statusDetails.open = false; + statusDetails.classList.add("hidden"); + } +} diff --git a/tools/figma-plugin/src/ui/core/types.ts b/tools/figma-plugin/src/ui/core/types.ts new file mode 100644 index 000000000..72430f57c --- /dev/null +++ b/tools/figma-plugin/src/ui/core/types.ts @@ -0,0 +1,14 @@ +export type ConvertResult = { + success: boolean; + iconName: string; + fileName: string; + code: string; + error?: string; +}; + +export type ConvertResultWithSvg = ConvertResult & { + svg?: string; + sourceId?: string; +}; + +export type StatusType = "ready" | "working" | "warning" | "error"; diff --git a/tools/figma-plugin/src/ui/core/utils.ts b/tools/figma-plugin/src/ui/core/utils.ts new file mode 100644 index 000000000..653817d41 --- /dev/null +++ b/tools/figma-plugin/src/ui/core/utils.ts @@ -0,0 +1,74 @@ +export function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +export function escapeAttr(text: string): string { + return text + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fallback below + } + + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + area.style.pointerEvents = "none"; + document.body.appendChild(area); + area.focus(); + area.select(); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + + document.body.removeChild(area); + return copied; +} + +const buttonFlashTimeouts = new WeakMap(); + +export function flashButton(button: HTMLButtonElement, flashText: string): void { + const existingTimeout = buttonFlashTimeouts.get(button); + if (existingTimeout !== undefined) { + window.clearTimeout(existingTimeout); + } + + const originalText = button.textContent; + button.textContent = flashText; + button.classList.add("copied"); + const timeoutId = window.setTimeout(() => { + button.textContent = originalText; + button.classList.remove("copied"); + buttonFlashTimeouts.delete(button); + }, 2000); + buttonFlashTimeouts.set(button, timeoutId); +} + +export function toBase64Utf8(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +} diff --git a/tools/figma-plugin/src/ui/features/bulkActions.ts b/tools/figma-plugin/src/ui/features/bulkActions.ts new file mode 100644 index 000000000..fd3190a71 --- /dev/null +++ b/tools/figma-plugin/src/ui/features/bulkActions.ts @@ -0,0 +1,53 @@ +import { zipSync, strToU8 } from "fflate"; +import { copyAllButton, downloadAllButton } from "../core/dom"; +import { getSuccessfulConversionResults, hasSuccessfulConversionResults } from "../core/state"; +import { setStatus } from "../core/status"; +import { copyText, flashButton } from "../core/utils"; + +export function initializeBulkActions(): void { + copyAllButton.addEventListener("click", async () => { + const successful = getSuccessfulConversionResults(); + if (successful.length === 0) { + return; + } + + const text = successful.map((item) => `// ${item.fileName}\n${item.code}`).join("\n\n"); + const copied = await copyText(text); + if (copied) { + flashButton(copyAllButton, "Copied!"); + setStatus(`Copied ${successful.length} generated file(s).`); + } else { + setStatus("Copy failed. Use Download instead.", "error"); + } + }); + + downloadAllButton.addEventListener("click", () => { + const successful = getSuccessfulConversionResults(); + if (successful.length === 0) { + return; + } + + const files: Record = {}; + for (const result of successful) { + files[result.fileName] = strToU8(result.code); + } + + const zipped = zipSync(files, { level: 6 }); + const blob = new Blob([zipped.buffer as ArrayBuffer], { type: "application/zip" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "valkyrie-icons.zip"; + link.click(); + URL.revokeObjectURL(url); + + flashButton(downloadAllButton, "Downloaded!"); + setStatus(`Downloaded ${successful.length} file(s) as ZIP.`); + }); +} + +export function updateBulkActionState(): void { + const hasSuccessful = hasSuccessfulConversionResults(); + copyAllButton.disabled = !hasSuccessful; + downloadAllButton.disabled = !hasSuccessful; +} diff --git a/tools/figma-plugin/src/ui/features/conversion.ts b/tools/figma-plugin/src/ui/features/conversion.ts new file mode 100644 index 000000000..f01bc1642 --- /dev/null +++ b/tools/figma-plugin/src/ui/features/conversion.ts @@ -0,0 +1,175 @@ +import { convert, isConverterReady, normalizeIconName } from "./converterAdapter"; +import { packageInput } from "../core/dom"; +import { createConverterUnavailableError, createSelectionError, createSettingsError, formatPluginError } from "../../shared/errorFormatter"; +import type { ExportedIcon } from "../../shared/messages"; +import { renderResults } from "./render"; +import { getConvertOptions } from "./settings"; +import { replaceConversionResults } from "../core/state"; +import { setStatus } from "../core/status"; +import type { ConvertResultWithSvg, StatusType } from "../core/types"; +import { updateBulkActionState } from "./bulkActions"; + +const CONVERSION_CHUNK_SIZE = 40; +let latestConversionJobId = 0; + +type ConversionContext = { + attemptedCount?: number; + exportFailedCount?: number; + upstreamError?: string; +}; + +function waitForNextFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + resolve(); + }); + }); +} + +export function runConversion(icons: ExportedIcon[], context: ConversionContext = {}): void { + void runConversionAsync(icons, context); +} + +async function runConversionAsync(icons: ExportedIcon[], context: ConversionContext): Promise { + latestConversionJobId += 1; + const conversionJobId = latestConversionJobId; + + const options = getConvertOptions(); + packageInput.classList.remove("input-error"); + const nextResults: ConvertResultWithSvg[] = []; + + if (!options.packageName) { + packageInput.classList.add("input-error"); + replaceConversionResults([]); + setStatus( + formatPluginError( + createSettingsError("Package name is required.", "Set a package name in Options and run export again."), + ), + "error", + ); + renderResults([]); + updateBulkActionState(); + return; + } + + if (icons.length === 0) { + replaceConversionResults([]); + if (context.upstreamError) { + setStatus(context.upstreamError, "error"); + } else { + setStatus( + formatPluginError( + createSelectionError("No exportable selected icons.", "Select one or more exportable icons in Figma and retry."), + ), + "error", + ); + } + renderResults([]); + updateBulkActionState(); + return; + } + + if (!isConverterReady()) { + replaceConversionResults([]); + renderResults([]); + updateBulkActionState(); + setStatus(formatPluginError(createConverterUnavailableError()), "error"); + return; + } + + const seenExact = new Set(); + const seenInsensitive = new Set(); + + for (let index = 0; index < icons.length; index += 1) { + if (conversionJobId !== latestConversionJobId) { + return; + } + + const icon = icons[index]; + const normalized = normalizeIconName(icon.name); + const lowered = normalized.toLowerCase(); + + if (seenExact.has(normalized)) { + nextResults.push({ + success: false, + iconName: normalized, + fileName: "", + code: "", + error: `Duplicate icon name '${normalized}'.`, + sourceId: icon.id, + }); + continue; + } + + if (seenInsensitive.has(lowered)) { + nextResults.push({ + success: false, + iconName: normalized, + fileName: "", + code: "", + error: `Case-insensitive collision for '${normalized}'.`, + sourceId: icon.id, + }); + continue; + } + + seenExact.add(normalized); + seenInsensitive.add(lowered); + + try { + const result: ConvertResultWithSvg = { + ...convert(icon.svg, icon.name, options), + svg: icon.svg, + sourceId: icon.id, + }; + nextResults.push(result); + } catch (error) { + nextResults.push({ + success: false, + iconName: normalized, + fileName: "", + code: "", + error: `Conversion failed for '${normalized}': ${String(error)}`, + svg: icon.svg, + sourceId: icon.id, + }); + } + + if ((index + 1) % CONVERSION_CHUNK_SIZE === 0) { + const progress = Math.min(index + 1, icons.length); + setStatus(`Converting ${progress}/${icons.length} icons...`, "working"); + await waitForNextFrame(); + } + } + + if (conversionJobId !== latestConversionJobId) { + return; + } + + replaceConversionResults(nextResults); + renderResults(nextResults); + updateBulkActionState(); + + const successCount = nextResults.filter((item) => item.success).length; + const failCount = nextResults.length - successCount; + const attemptedCount = context.attemptedCount ?? (nextResults.length + (context.exportFailedCount ?? 0)); + const exportFailedCount = context.exportFailedCount ?? Math.max(0, attemptedCount - nextResults.length); + + let statusMessage = ""; + let statusType: StatusType = "ready"; + + if (exportFailedCount > 0) { + statusMessage = `Converted ${successCount}/${attemptedCount} icon(s); ${exportFailedCount} export failure(s).`; + if (failCount > 0) { + statusMessage += ` ${failCount} conversion error(s).`; + } + statusType = "warning"; + } else if (failCount > 0) { + statusMessage = `Converted ${successCount}/${nextResults.length} icon(s). ${failCount} error(s).`; + statusType = "error"; + } else { + statusMessage = `Converted ${successCount} icon(s) successfully.`; + } + + setStatus(statusMessage, statusType); +} diff --git a/tools/figma-plugin/src/ui/features/converterAdapter.ts b/tools/figma-plugin/src/ui/features/converterAdapter.ts new file mode 100644 index 000000000..b6962d9e4 --- /dev/null +++ b/tools/figma-plugin/src/ui/features/converterAdapter.ts @@ -0,0 +1,115 @@ +import type { AutoMirrorOption, OutputFormat } from "../../shared/pluginSettings"; +import type { ConvertResult } from "../core/types"; + +export type ConvertOptions = { + packageName: string; + outputFormat: OutputFormat; + useComposeColors: boolean; + addTrailingComma: boolean; + useExplicitMode: boolean; + usePathDataString: boolean; + indentSize: number; + autoMirror: AutoMirrorOption; +}; + +type WasmConvertResult = { + type: string; + iconName: string; + fileName?: string; + code?: string; + error?: string; +}; + +type WasmConverter = { + convertSvg: ( + svg: string, + iconName: string, + packageName: string, + outputFormat: string, + useComposeColors: boolean, + addTrailingComma: boolean, + useExplicitMode: boolean, + usePathDataString: boolean, + indentSize: number, + autoMirror: boolean | null, + ) => string; + normalizeIconName: (iconName: string) => string; +}; + +declare global { + interface Window { + ValkyrieFigmaWasmConverter?: WasmConverter; + } +} + +export function isConverterReady(): boolean { + return typeof window.ValkyrieFigmaWasmConverter?.convertSvg === "function"; +} + +export function normalizeIconName(iconName: string): string { + if (!window.ValkyrieFigmaWasmConverter) { + return iconName; + } + return window.ValkyrieFigmaWasmConverter.normalizeIconName(iconName); +} + +export function convert(svg: string, iconName: string, options: ConvertOptions): ConvertResult { + const converter = window.ValkyrieFigmaWasmConverter; + if (!converter) { + return { + success: false, + iconName, + fileName: "", + code: "", + error: "Wasm converter is not loaded. Run `pnpm build:all` in tools/figma-plugin and reload plugin.", + }; + } + + const json = converter.convertSvg( + svg, + iconName, + options.packageName, + options.outputFormat, + options.useComposeColors, + options.addTrailingComma, + options.useExplicitMode, + options.usePathDataString, + options.indentSize, + options.autoMirror, + ); + + try { + return toPluginConvertResult(JSON.parse(json) as WasmConvertResult); + } catch { + return { + success: false, + iconName, + fileName: "", + code: "", + error: "Failed to parse converter response. Please report this issue.", + }; + } +} + +function toPluginConvertResult(result: WasmConvertResult): ConvertResult { + if (isSuccessType(result.type)) { + return { + success: true, + iconName: result.iconName, + fileName: result.fileName ?? "", + code: result.code ?? "", + }; + } + + return { + success: false, + iconName: result.iconName, + fileName: "", + code: "", + error: result.error ?? "Unknown conversion error", + }; +} + +function isSuccessType(type: string): boolean { + return type === "success"; +} diff --git a/tools/figma-plugin/src/ui/features/highlight.ts b/tools/figma-plugin/src/ui/features/highlight.ts new file mode 100644 index 000000000..f0b443d62 --- /dev/null +++ b/tools/figma-plugin/src/ui/features/highlight.ts @@ -0,0 +1,142 @@ +import { escapeHtml } from "../core/utils"; + +const KEYWORDS = new Set([ + "package", + "import", + "class", + "object", + "interface", + "fun", + "val", + "var", + "return", + "if", + "else", + "when", + "for", + "while", + "do", + "try", + "catch", + "finally", + "throw", + "in", + "is", + "as", + "this", + "super", + "true", + "false", + "null", + "private", + "public", + "internal", + "protected", + "override", + "abstract", + "open", + "sealed", + "data", + "enum", + "inline", + "crossinline", + "noinline", + "suspend", + "operator", + "infix", + "const", + "lateinit", + "tailrec", + "reified", + "by", + "where", + "typealias", +]); + +function wrap(type: string, value: string): string { + return `${escapeHtml(value)}`; +} + +export function highlightKotlin(source: string): string { + let i = 0; + const out: string[] = []; + + while (i < source.length) { + const rest = source.slice(i); + + const ws = rest.match(/^\s+/); + if (ws) { + out.push(escapeHtml(ws[0])); + i += ws[0].length; + continue; + } + + const lineComment = rest.match(/^\/\/[^\n]*/); + if (lineComment) { + out.push(wrap("comment", lineComment[0])); + i += lineComment[0].length; + continue; + } + + const blockComment = rest.match(/^\/\*[\s\S]*?\*\//); + if (blockComment) { + out.push(wrap("comment", blockComment[0])); + i += blockComment[0].length; + continue; + } + + const tripleQuoted = rest.match(/^"""[\s\S]*?"""/); + if (tripleQuoted) { + out.push(wrap("string", tripleQuoted[0])); + i += tripleQuoted[0].length; + continue; + } + + const quoted = rest.match(/^"(?:\\.|[^"\\])*"/); + if (quoted) { + out.push(wrap("string", quoted[0])); + i += quoted[0].length; + continue; + } + + const charLiteral = rest.match(/^'(?:\\.|[^'\\])'/); + if (charLiteral) { + out.push(wrap("string", charLiteral[0])); + i += charLiteral[0].length; + continue; + } + + const annotation = rest.match(/^@[A-Za-z_][A-Za-z0-9_.]*/); + if (annotation) { + out.push(wrap("annotation", annotation[0])); + i += annotation[0].length; + continue; + } + + const number = rest.match(/^\b(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/); + if (number) { + out.push(wrap("number", number[0])); + i += number[0].length; + continue; + } + + const identifier = rest.match(/^[A-Za-z_][A-Za-z0-9_]*/); + if (identifier) { + const token = identifier[0]; + if (KEYWORDS.has(token)) { + out.push(wrap("keyword", token)); + } else if (/^[A-Z]/.test(token)) { + out.push(wrap("type", token)); + } else { + out.push(escapeHtml(token)); + } + i += token.length; + continue; + } + + out.push(escapeHtml(source[i])); + i += 1; + } + + return out.join(""); +} diff --git a/tools/figma-plugin/src/ui/features/render.ts b/tools/figma-plugin/src/ui/features/render.ts new file mode 100644 index 000000000..302b15336 --- /dev/null +++ b/tools/figma-plugin/src/ui/features/render.ts @@ -0,0 +1,494 @@ +import type { ConvertResultWithSvg } from "../core/types"; +import { resultsContainer, emptyState, emptyStateTitle, emptyStateDescription, selectionPreview, mainScroll } from "../core/dom"; +import { escapeHtml, escapeAttr, copyText, flashButton, toBase64Utf8 } from "../core/utils"; +import { setStatus } from "../core/status"; +import { highlightKotlin } from "./highlight"; + +const EMPTY_TITLE_DEFAULT = "No icons exported yet"; +const EMPTY_MESSAGE_DEFAULT = "Select one or more icons in Figma to generate Kotlin ImageVector code automatically."; +const EMPTY_TITLE_LOADING = "Generating code..."; +const EMPTY_MESSAGE_LOADING = "Exporting your selected icon(s)."; +const EMPTY_TITLE_AUTO_EXPORT_OFF = "Auto export is off"; +const EMPTY_MESSAGE_AUTO_EXPORT_OFF = "Select icons and click Export."; +const LARGE_BATCH_COLLAPSE_THRESHOLD = 20; +const CODE_RENDER_BATCH_SIZE = 20; +const EXPAND_COLLAPSE_BATCH_SIZE = 80; + +let activePreviewObserver: IntersectionObserver | null = null; +let activeCodeObserver: IntersectionObserver | null = null; +let queuedCodeRenderTasks: Array<() => void> = []; +let codeRenderFrameId: number | null = null; +let expanderBatchFrameId: number | null = null; +let activeExpanderBatchToken = 0; +let loadingResultsVisible = false; + +function clearCodeRenderQueue(): void { + queuedCodeRenderTasks = []; + if (codeRenderFrameId !== null) { + window.cancelAnimationFrame(codeRenderFrameId); + codeRenderFrameId = null; + } +} + +function flushCodeRenderQueue(): void { + codeRenderFrameId = null; + let processed = 0; + while (queuedCodeRenderTasks.length > 0 && processed < CODE_RENDER_BATCH_SIZE) { + const task = queuedCodeRenderTasks.shift(); + task?.(); + processed += 1; + } + + if (queuedCodeRenderTasks.length > 0) { + codeRenderFrameId = window.requestAnimationFrame(flushCodeRenderQueue); + } +} + +function enqueueCodeRenderTask(task: () => void): void { + queuedCodeRenderTasks.push(task); + if (codeRenderFrameId === null) { + codeRenderFrameId = window.requestAnimationFrame(flushCodeRenderQueue); + } +} + +function applyExpanderBatch( + expanders: Array<{ expand: (renderCode: boolean) => void; collapse: () => void }>, + action: "expand" | "collapse", + renderCodeOnExpand = false, +): void { + activeExpanderBatchToken += 1; + const token = activeExpanderBatchToken; + if (expanderBatchFrameId !== null) { + window.cancelAnimationFrame(expanderBatchFrameId); + expanderBatchFrameId = null; + } + + let index = 0; + + const runBatch = (): void => { + if (token !== activeExpanderBatchToken) { + expanderBatchFrameId = null; + return; + } + + const end = Math.min(index + EXPAND_COLLAPSE_BATCH_SIZE, expanders.length); + while (index < end) { + if (action === "expand") { + expanders[index].expand(renderCodeOnExpand); + } else { + expanders[index].collapse(); + } + index += 1; + } + + if (index < expanders.length) { + expanderBatchFrameId = window.requestAnimationFrame(runBatch); + return; + } + + expanderBatchFrameId = null; + }; + + runBatch(); +} + +export function renderLoadingResults(selectedCount: number): void { + loadingResultsVisible = true; + resultsContainer.classList.remove("single-result"); + resultsContainer.innerHTML = ""; + emptyState.classList.add("hidden"); + + const card = document.createElement("section"); + card.className = "result-card loading-card"; + + const header = document.createElement("div"); + header.className = "card-header"; + + const info = document.createElement("div"); + info.className = "card-info"; + + const title = document.createElement("h3"); + title.textContent = selectedCount > 1 ? `Exporting ${selectedCount} icons...` : "Exporting icon..."; + info.appendChild(title); + + const filename = document.createElement("span"); + filename.className = "filename"; + filename.textContent = "Preparing Kotlin output"; + info.appendChild(filename); + + header.appendChild(info); + card.appendChild(header); + + const body = document.createElement("div"); + body.className = "card-code"; + body.innerHTML = + `
` + + `
` + + `
` + + `
` + + `
` + + `
`; + card.appendChild(body); + + resultsContainer.appendChild(card); +} + +export function updateSelectionPreview(count: number, names: string[]): void { + if (count === 0) { + selectionPreview.innerHTML = `
No icons selected
`; + return; + } + + const label = count === 1 ? "icon" : "icons"; + const nameList = names.join(", ") + (count > names.length ? `, +${count - names.length} more` : ""); + + selectionPreview.innerHTML = + `
${count} ${label} selected
` + + `
${escapeHtml(nameList)}
`; +} + +export function renderResults(results: ConvertResultWithSvg[]): void { + loadingResultsVisible = false; + activeExpanderBatchToken += 1; + if (expanderBatchFrameId !== null) { + window.cancelAnimationFrame(expanderBatchFrameId); + expanderBatchFrameId = null; + } + const previousMainScrollTop = mainScroll.scrollTop; + const previousMainScrollLeft = mainScroll.scrollLeft; + clearCodeRenderQueue(); + if (activePreviewObserver) { + activePreviewObserver.disconnect(); + activePreviewObserver = null; + } + if (activeCodeObserver) { + activeCodeObserver.disconnect(); + activeCodeObserver = null; + } + resultsContainer.classList.toggle("single-result", results.length === 1); + const previousScrollState = new Map(); + const existingCards = resultsContainer.querySelectorAll(".result-card"); + existingCards.forEach((cardNode) => { + const existingCard = cardNode as HTMLElement; + const key = existingCard.dataset.resultKey; + if (!key) { + return; + } + + const codeWrapper = existingCard.querySelector(".card-code") as HTMLElement | null; + if (!codeWrapper) { + return; + } + + previousScrollState.set(key, { + top: codeWrapper.scrollTop, + left: codeWrapper.scrollLeft, + }); + }); + + resultsContainer.innerHTML = ""; + + if (results.length === 0) { + emptyState.classList.remove("hidden"); + mainScroll.scrollTop = previousMainScrollTop; + mainScroll.scrollLeft = previousMainScrollLeft; + return; + } + + emptyState.classList.add("hidden"); + + const successfulCount = results.filter((result) => result.success).length; + const showExpandControls = successfulCount > 1; + const collapseByDefault = successfulCount >= LARGE_BATCH_COLLAPSE_THRESHOLD; + const expanders: Array<{ expand: (renderCode: boolean) => void; collapse: () => void }> = []; + const codeRenderers = new WeakMap void>(); + const previewObserver = collapseByDefault && typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver((entries, observer) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + const image = entry.target as HTMLImageElement; + const svg = image.dataset.svg; + if (svg) { + image.src = "data:image/svg+xml;base64," + toBase64Utf8(svg); + delete image.dataset.svg; + } + observer.unobserve(image); + } + }, { + root: mainScroll, + rootMargin: "200px", + threshold: 0, + }) + : null; + activePreviewObserver = previewObserver; + + const codeObserver = collapseByDefault && typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + const codeWrapper = entry.target as HTMLElement; + const renderCode = codeRenderers.get(codeWrapper); + if (renderCode && codeWrapper.dataset.expanded === "true") { + enqueueCodeRenderTask(renderCode); + } + } + }, { + root: mainScroll, + rootMargin: "1200px", + threshold: 0, + }) + : null; + activeCodeObserver = codeObserver; + + if (showExpandControls) { + const toolbar = document.createElement("div"); + toolbar.className = "results-toolbar"; + + const label = document.createElement("span"); + label.className = "results-toolbar-label"; + label.textContent = collapseByDefault ? `Large batch (${successfulCount} files)` : `${successfulCount} files`; + toolbar.appendChild(label); + + const controls = document.createElement("div"); + controls.className = "results-toolbar-actions"; + + const expandAllButton = document.createElement("button"); + expandAllButton.className = "card-btn"; + expandAllButton.textContent = "Expand all"; + expandAllButton.addEventListener("click", () => { + applyExpanderBatch(expanders, "expand", !codeObserver); + }); + controls.appendChild(expandAllButton); + + const collapseAllButton = document.createElement("button"); + collapseAllButton.className = "card-btn"; + collapseAllButton.textContent = "Collapse all"; + collapseAllButton.addEventListener("click", () => { + applyExpanderBatch(expanders, "collapse"); + }); + controls.appendChild(collapseAllButton); + + toolbar.appendChild(controls); + resultsContainer.appendChild(toolbar); + } + + for (const result of results) { + const resultKey = `${result.success ? "ok" : "error"}:${result.sourceId ?? "unknown"}:${result.iconName}:${result.fileName}`; + const card = document.createElement("section"); + card.className = "result-card"; + card.dataset.resultKey = resultKey; + + const header = document.createElement("div"); + header.className = "card-header"; + + if (result.svg) { + const preview = document.createElement("div"); + preview.className = "card-preview"; + const img = document.createElement("img"); + img.alt = result.iconName; + if (previewObserver) { + img.dataset.svg = result.svg; + previewObserver.observe(img); + } else { + img.src = "data:image/svg+xml;base64," + toBase64Utf8(result.svg); + } + preview.appendChild(img); + header.appendChild(preview); + } + + const info = document.createElement("div"); + info.className = "card-info"; + const title = document.createElement("h3"); + title.textContent = result.iconName; + info.appendChild(title); + + if (result.success && result.fileName) { + const filename = document.createElement("span"); + filename.className = "filename"; + filename.textContent = result.fileName; + info.appendChild(filename); + } + + header.appendChild(info); + + let codeWrapperForCard: HTMLDivElement | null = null; + + if (result.success) { + const actions = document.createElement("div"); + actions.className = "card-actions"; + + let codeWrapper: HTMLDivElement | null = null; + let codeRendered = false; + const ensureCodeRendered = (): void => { + if (!codeWrapper || codeRendered) { + return; + } + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.innerHTML = highlightKotlin(result.code); + pre.appendChild(code); + codeWrapper.appendChild(pre); + codeRendered = true; + }; + + const requestCodeRender = (): void => { + if (!codeWrapper || codeRendered) { + return; + } + + if (collapseByDefault) { + enqueueCodeRenderTask(() => { + if (!codeWrapper || codeRendered || codeWrapper.dataset.expanded !== "true") { + return; + } + ensureCodeRendered(); + }); + return; + } + + ensureCodeRendered(); + }; + + const setExpanded = (expanded: boolean, button: HTMLButtonElement | null, shouldRenderCode: boolean): void => { + if (!codeWrapper) { + return; + } + + codeWrapper.dataset.expanded = expanded ? "true" : "false"; + + if (expanded) { + if (shouldRenderCode) { + requestCodeRender(); + } + codeWrapper.classList.remove("hidden"); + } else { + codeWrapper.classList.add("hidden"); + } + + if (button) { + button.textContent = expanded ? "Collapse" : "Expand"; + } + }; + + const copyButton = document.createElement("button"); + copyButton.className = "card-btn"; + copyButton.textContent = "Copy"; + copyButton.addEventListener("click", async () => { + const copied = await copyText(result.code); + if (copied) { + flashButton(copyButton, "Copied!"); + setStatus(`Copied ${result.fileName}.`); + } else { + setStatus("Copy failed. Use Download instead.", "error"); + } + }); + actions.appendChild(copyButton); + + const downloadButton = document.createElement("button"); + downloadButton.className = "card-btn"; + downloadButton.textContent = "Download"; + downloadButton.addEventListener("click", () => { + const blob = new Blob([result.code], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = result.fileName; + link.click(); + URL.revokeObjectURL(url); + flashButton(downloadButton, "Done!"); + }); + actions.appendChild(downloadButton); + + let toggleButton: HTMLButtonElement | null = null; + if (showExpandControls) { + toggleButton = document.createElement("button"); + toggleButton.className = "card-btn"; + toggleButton.textContent = "Expand"; + toggleButton.addEventListener("click", () => { + if (!codeWrapper) { + return; + } + + const willExpand = codeWrapper.classList.contains("hidden"); + setExpanded(willExpand, toggleButton, willExpand); + }); + actions.appendChild(toggleButton); + } + + header.appendChild(actions); + + codeWrapper = document.createElement("div"); + codeWrapper.className = "card-code"; + codeWrapperForCard = codeWrapper; + codeRenderers.set(codeWrapper, ensureCodeRendered); + if (codeObserver) { + codeObserver.observe(codeWrapper); + } + + if (showExpandControls) { + const startExpanded = !collapseByDefault; + setExpanded(startExpanded, toggleButton, startExpanded); + expanders.push({ + expand: (renderCode: boolean) => setExpanded(true, toggleButton, renderCode), + collapse: () => setExpanded(false, toggleButton, false), + }); + } else { + codeWrapper.dataset.expanded = "true"; + requestCodeRender(); + } + } + + card.appendChild(header); + + if (codeWrapperForCard) { + card.appendChild(codeWrapperForCard); + } + + if (!result.success) { + const error = document.createElement("div"); + error.className = "card-error"; + error.textContent = result.error ?? "Unknown conversion error"; + card.appendChild(error); + } + + resultsContainer.appendChild(card); + + const previous = previousScrollState.get(resultKey); + if (previous) { + const codeWrapper = card.querySelector(".card-code") as HTMLElement | null; + if (codeWrapper) { + codeWrapper.scrollTop = previous.top; + codeWrapper.scrollLeft = previous.left; + } + } + } + + mainScroll.scrollTop = previousMainScrollTop; + mainScroll.scrollLeft = previousMainScrollLeft; +} + +export function showLoadingEmptyState(): void { + emptyStateTitle.textContent = EMPTY_TITLE_LOADING; + emptyStateDescription.textContent = EMPTY_MESSAGE_LOADING; +} + +export function showDefaultEmptyState(): void { + emptyStateTitle.textContent = EMPTY_TITLE_DEFAULT; + emptyStateDescription.textContent = EMPTY_MESSAGE_DEFAULT; +} + +export function showAutoExportDisabledEmptyState(): void { + emptyStateTitle.textContent = EMPTY_TITLE_AUTO_EXPORT_OFF; + emptyStateDescription.textContent = EMPTY_MESSAGE_AUTO_EXPORT_OFF; +} + +export function isLoadingResultsVisible(): boolean { + return loadingResultsVisible; +} diff --git a/tools/figma-plugin/src/ui/features/settings.ts b/tools/figma-plugin/src/ui/features/settings.ts new file mode 100644 index 000000000..16e2d977c --- /dev/null +++ b/tools/figma-plugin/src/ui/features/settings.ts @@ -0,0 +1,73 @@ +import { packageInput, outputFormatInput, useComposeColorsInput, addTrailingCommaInput, useExplicitModeInput, usePathDataStringInput, autoMirrorInput, autoExportInput, settingsInputs } from "../core/dom"; +import type { ConvertOptions } from "./converterAdapter"; +import { sendMessage } from "../core/api"; +import type { PluginSettings } from "../../shared/pluginSettings"; +import { autoMirrorOptionToSelectValue, parseAutoMirrorOption, sanitizePluginSettings } from "../../shared/pluginSettings"; + +let saveSettingsTimeoutId: number | null = null; + +function readCurrentSettings(): PluginSettings { + return { + packageName: packageInput.value.trim(), + outputFormat: outputFormatInput.value as PluginSettings["outputFormat"], + useComposeColors: useComposeColorsInput.checked, + addTrailingComma: addTrailingCommaInput.checked, + useExplicitMode: useExplicitModeInput.checked, + usePathDataString: usePathDataStringInput.checked, + autoMirror: parseAutoMirrorOption(autoMirrorInput.value), + autoExport: autoExportInput.checked, + }; +} + +export function addSettingsInputListeners(listener: () => void): void { + for (const input of settingsInputs) { + input.addEventListener("input", listener); + input.addEventListener("change", listener); + } +} + +export function applySettings(settings: PluginSettings | null): void { + const parsed = sanitizePluginSettings(settings); + if (!parsed) { + return; + } + + packageInput.value = parsed.packageName; + outputFormatInput.value = parsed.outputFormat; + useComposeColorsInput.checked = parsed.useComposeColors; + addTrailingCommaInput.checked = parsed.addTrailingComma; + useExplicitModeInput.checked = parsed.useExplicitMode; + usePathDataStringInput.checked = parsed.usePathDataString; + autoMirrorInput.value = autoMirrorOptionToSelectValue(parsed.autoMirror); + autoExportInput.checked = parsed.autoExport; +} + +export function scheduleSaveSettings(): void { + if (saveSettingsTimeoutId !== null) { + window.clearTimeout(saveSettingsTimeoutId); + } + saveSettingsTimeoutId = window.setTimeout(() => { + sendMessage({ type: "save-settings", settings: readCurrentSettings() }); + saveSettingsTimeoutId = null; + }, 500); +} + +export function initSettingsListeners(): void { + addSettingsInputListeners(scheduleSaveSettings); + sendMessage({ type: "load-settings" }); +} + +export function getConvertOptions(): ConvertOptions { + const settings = readCurrentSettings(); + + return { + packageName: settings.packageName, + outputFormat: settings.outputFormat, + useComposeColors: settings.useComposeColors, + addTrailingComma: settings.addTrailingComma, + useExplicitMode: settings.useExplicitMode, + usePathDataString: settings.usePathDataString, + indentSize: 4, + autoMirror: settings.autoMirror, + }; +} diff --git a/tools/figma-plugin/src/ui/ui.html b/tools/figma-plugin/src/ui/ui.html new file mode 100644 index 000000000..ef19b2e9a --- /dev/null +++ b/tools/figma-plugin/src/ui/ui.html @@ -0,0 +1,791 @@ + + + + + + Valkyrie Figma Export + + + +
+ + + + +
+
+
+ + +
+
+ + + + + +
+

Preparing export...

+

Checking current selection and settings.

+
+
+
+
+ + + + + diff --git a/tools/figma-plugin/src/ui/ui.ts b/tools/figma-plugin/src/ui/ui.ts new file mode 100644 index 000000000..4b5b2ae55 --- /dev/null +++ b/tools/figma-plugin/src/ui/ui.ts @@ -0,0 +1,49 @@ +import { runButton } from "./core/dom"; +import { addSettingsInputListeners, initSettingsListeners } from "./features/settings"; +import { onMessage, onError } from "./core/api"; +import { setStatus } from "./core/status"; +import { initializeBulkActions, updateBulkActionState } from "./features/bulkActions"; +import { createSelectionController } from "./controllers/selectionController"; +import { createRequestController } from "./controllers/requestController"; +import { createMainMessageHandler } from "./controllers/messageHandlers"; +import { applyRunLifecycleState } from "./controllers/runLifecycleState"; + +const requestController = createRequestController({ + onTimedOut: () => { + applyRunLifecycleState("timed-out"); + }, +}); + +const selectionController = createSelectionController({ + requestConversion: () => { + requestController.requestConversion(); + }, + cancelActiveRequest: () => { + requestController.cancelActiveRequest(); + }, + updateBulkActionState, +}); + +runButton.addEventListener("click", () => { + requestController.requestConversion(); +}); + +addSettingsInputListeners(() => { + selectionController.handleSettingsInputChanged(); +}); + +onMessage( + createMainMessageHandler({ + selectionController, + requestController, + }), +); + +initSettingsListeners(); +initializeBulkActions(); +setStatus("Ready"); +updateBulkActionState(); + +onError((event) => { + setStatus(`UI error: ${event.message}`, "error"); +}); diff --git a/tools/figma-plugin/tsconfig.json b/tools/figma-plugin/tsconfig.json new file mode 100644 index 000000000..559b3e441 --- /dev/null +++ b/tools/figma-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["@figma/plugin-typings"], + "noEmit": true + }, + "include": ["src", "scripts"] +}