From 231cca1c26052583a98a978c733307f4d72ee7ae Mon Sep 17 00:00:00 2001 From: t-regbs Date: Tue, 3 Mar 2026 23:27:50 +0000 Subject: [PATCH 1/7] feat(figma-plugin): add initial plugin workflow and themed UI --- .gitignore | 2 + DEVELOPMENT.md | 9 + components/converter/figma/api/figma.api | 5 + components/converter/figma/api/figma.klib.api | 10 + components/converter/figma/build.gradle.kts | 23 + .../converter/figma/FigmaWasmConverter.kt | 131 +++ .../imagevector/render/PathNodeRenderer.kt | 45 + .../imagevector/util/ImageVectorRenderer.kt | 4 +- .../kmp/imagevector/util/NodeParams.kt | 4 +- settings.gradle.kts | 1 + tools/figma-plugin/README.md | 56 ++ tools/figma-plugin/manifest.json | 28 + tools/figma-plugin/package.json | 21 + tools/figma-plugin/pnpm-lock.yaml | 312 +++++++ tools/figma-plugin/scripts/build.mjs | 139 +++ tools/figma-plugin/src/api.ts | 22 + tools/figma-plugin/src/bulkActions.ts | 53 ++ tools/figma-plugin/src/code.ts | 296 +++++++ tools/figma-plugin/src/conversion.ts | 157 ++++ tools/figma-plugin/src/converterAdapter.ts | 81 ++ tools/figma-plugin/src/dom.ts | 33 + tools/figma-plugin/src/errorFormatter.ts | 52 ++ tools/figma-plugin/src/highlight.ts | 142 ++++ tools/figma-plugin/src/messageHandlers.ts | 99 +++ tools/figma-plugin/src/messages.ts | 83 ++ tools/figma-plugin/src/pluginSettings.ts | 69 ++ tools/figma-plugin/src/render.ts | 476 +++++++++++ tools/figma-plugin/src/requestController.ts | 88 ++ tools/figma-plugin/src/selectionController.ts | 120 +++ tools/figma-plugin/src/settings.ts | 71 ++ tools/figma-plugin/src/state.ts | 28 + tools/figma-plugin/src/status.ts | 31 + tools/figma-plugin/src/types.ts | 5 + tools/figma-plugin/src/ui.html | 798 ++++++++++++++++++ tools/figma-plugin/src/ui.ts | 64 ++ tools/figma-plugin/src/utils.ts | 74 ++ tools/figma-plugin/tsconfig.json | 13 + 37 files changed, 3641 insertions(+), 4 deletions(-) create mode 100644 components/converter/figma/api/figma.api create mode 100644 components/converter/figma/api/figma.klib.api create mode 100644 components/converter/figma/build.gradle.kts create mode 100644 components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt create mode 100644 components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt create mode 100644 tools/figma-plugin/README.md create mode 100644 tools/figma-plugin/manifest.json create mode 100644 tools/figma-plugin/package.json create mode 100644 tools/figma-plugin/pnpm-lock.yaml create mode 100644 tools/figma-plugin/scripts/build.mjs create mode 100644 tools/figma-plugin/src/api.ts create mode 100644 tools/figma-plugin/src/bulkActions.ts create mode 100644 tools/figma-plugin/src/code.ts create mode 100644 tools/figma-plugin/src/conversion.ts create mode 100644 tools/figma-plugin/src/converterAdapter.ts create mode 100644 tools/figma-plugin/src/dom.ts create mode 100644 tools/figma-plugin/src/errorFormatter.ts create mode 100644 tools/figma-plugin/src/highlight.ts create mode 100644 tools/figma-plugin/src/messageHandlers.ts create mode 100644 tools/figma-plugin/src/messages.ts create mode 100644 tools/figma-plugin/src/pluginSettings.ts create mode 100644 tools/figma-plugin/src/render.ts create mode 100644 tools/figma-plugin/src/requestController.ts create mode 100644 tools/figma-plugin/src/selectionController.ts create mode 100644 tools/figma-plugin/src/settings.ts create mode 100644 tools/figma-plugin/src/state.ts create mode 100644 tools/figma-plugin/src/status.ts create mode 100644 tools/figma-plugin/src/types.ts create mode 100644 tools/figma-plugin/src/ui.html create mode 100644 tools/figma-plugin/src/ui.ts create mode 100644 tools/figma-plugin/src/utils.ts create mode 100644 tools/figma-plugin/tsconfig.json 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/DEVELOPMENT.md b/DEVELOPMENT.md index 418023913..38c845397 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -37,3 +37,12 @@ Update changelog: `./gradlew tools:idea-plugin:patchChangelog` ## WEB - Run WASM: `./gradlew tools:compose-app:wasmJsBrowserDevelopmentRun` + +## FIGMA Plugin (Simple mode) + +- Build converter for Wasm executable: `./gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs` +- Install plugin package deps (pnpm): `pnpm install` (run in `tools/figma-plugin`) +- Build plugin assets: `pnpm build` (run in `tools/figma-plugin`) +- Build converter + plugin assets: `pnpm build:all` (run in `tools/figma-plugin`) +- Watch plugin assets: `pnpm watch` (run in `tools/figma-plugin`) +- Reload in Figma after build: `Plugins -> Development -> Reload plugins` diff --git a/components/converter/figma/api/figma.api b/components/converter/figma/api/figma.api new file mode 100644 index 000000000..1ea5ff12f --- /dev/null +++ b/components/converter/figma/api/figma.api @@ -0,0 +1,5 @@ +public final class io/github/composegears/valkyrie/converter/figma/FigmaWasmConverterKt { + public static final fun convertSvg (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/String;)Ljava/lang/String; + public static synthetic fun convertSvg$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/String;ILjava/lang/Object;)Ljava/lang/String; + public static final fun normalizeIconName (Ljava/lang/String;)Ljava/lang/String; +} diff --git a/components/converter/figma/api/figma.klib.api b/components/converter/figma/api/figma.klib.api new file mode 100644 index 000000000..49e5517f6 --- /dev/null +++ b/components/converter/figma/api/figma.klib.api @@ -0,0 +1,10 @@ +// Klib ABI Dump +// Targets: [wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun io.github.composegears.valkyrie.converter.figma/convertSvg(kotlin/String, kotlin/String, kotlin/String, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...): kotlin/String // io.github.composegears.valkyrie.converter.figma/convertSvg|convertSvg(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] +final fun io.github.composegears.valkyrie.converter.figma/normalizeIconName(kotlin/String): kotlin/String // io.github.composegears.valkyrie.converter.figma/normalizeIconName|normalizeIconName(kotlin.String){}[0] diff --git a/components/converter/figma/build.gradle.kts b/components/converter/figma/build.gradle.kts new file mode 100644 index 000000000..a467d4d42 --- /dev/null +++ b/components/converter/figma/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.valkyrie.kmp) + alias(libs.plugins.valkyrie.abi) + alias(libs.plugins.valkyrie.kover) +} + +kotlin { + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + binaries.executable() + } + + sourceSets { + commonMain.dependencies { + implementation(projects.components.parser.unified) + implementation(projects.components.generator.kmp.imagevector) + implementation(projects.sdk.ir.core) + } + commonTest.dependencies { + implementation(libs.bundles.kmp.test) + } + } +} diff --git a/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt b/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt new file mode 100644 index 000000000..880f5f13d --- /dev/null +++ b/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt @@ -0,0 +1,131 @@ +@file:OptIn(ExperimentalJsExport::class) + +package io.github.composegears.valkyrie.converter.figma + +import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorGenerator +import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorGeneratorConfig +import io.github.composegears.valkyrie.generator.kmp.imagevector.OutputFormat +import io.github.composegears.valkyrie.parser.unified.ParserType +import io.github.composegears.valkyrie.parser.unified.SvgXmlParser +import io.github.composegears.valkyrie.parser.unified.util.IconNameFormatter +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +@JsExport +fun convertSvg( + svg: String, + iconName: String, + packageName: String, + outputFormat: String = OutputFormat.BackingProperty.key, + useComposeColors: Boolean = true, + addTrailingComma: Boolean = false, + useExplicitMode: Boolean = false, + usePathDataString: Boolean = false, + indentSize: Int = 4, + autoMirror: String = "", +): String { + return runCatching { + val normalizedIconName = IconNameFormatter.format(iconName) + + val parseOutput = SvgXmlParser.toIrImageVector( + parser = ParserType.Kmp, + value = svg, + iconName = normalizedIconName, + ).let { + when (autoMirror.lowercase()) { + "true" -> it.copy(irImageVector = it.irImageVector.copy(autoMirror = true)) + "false" -> it.copy(irImageVector = it.irImageVector.copy(autoMirror = false)) + else -> it + } + } + + val output = ImageVectorGenerator.convert( + vector = parseOutput.irImageVector, + iconName = parseOutput.iconName, + config = ImageVectorGeneratorConfig( + packageName = packageName, + iconPackPackage = packageName, + packName = "", + nestedPackName = "", + outputFormat = when (outputFormat) { + OutputFormat.LazyProperty.key -> OutputFormat.LazyProperty + else -> OutputFormat.BackingProperty + }, + useComposeColors = useComposeColors, + generatePreview = false, + useFlatPackage = false, + addTrailingComma = addTrailingComma, + useExplicitMode = useExplicitMode, + usePathDataString = usePathDataString, + indentSize = indentSize, + ), + ) + + ConverterResult( + ok = true, + iconName = output.name, + fileName = "${output.name}.kt", + content = output.content, + ) + }.getOrElse { error -> + ConverterResult( + ok = false, + iconName = iconName, + fileName = "", + content = "", + error = error.message ?: "Unknown conversion error", + ) + }.toJson() +} + +@JsExport +fun normalizeIconName(iconName: String): String = IconNameFormatter.format(iconName) + +private data class ConverterResult( + val ok: Boolean, + val iconName: String, + val fileName: String, + val content: String, + val error: String? = null, +) { + fun toJson(): String { + return buildString { + append('{') + append("\"ok\":$ok") + append(',') + append("\"iconName\":\"") + append(iconName.escapeJson()) + append("\"") + append(',') + append("\"fileName\":\"") + append(fileName.escapeJson()) + append("\"") + append(',') + append("\"content\":\"") + append(content.escapeJson()) + append("\"") + if (error != null) { + append(',') + append("\"error\":\"") + append(error.escapeJson()) + append("\"") + } + append('}') + } + } +} + +private fun String.escapeJson(): String { + return buildString(length) { + for (ch in this@escapeJson) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + } +} diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt new file mode 100644 index 000000000..db5ac6da6 --- /dev/null +++ b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt @@ -0,0 +1,45 @@ +package io.github.composegears.valkyrie.generator.kmp.imagevector.render + +import io.github.composegears.valkyrie.generator.core.formatFloat +import io.github.composegears.valkyrie.sdk.ir.core.IrPathNode +import io.github.composegears.valkyrie.sdk.ir.core.toPathString + +internal fun IrPathNode.asStatement(): String = when (this) { + is IrPathNode.Close -> "close()" + is IrPathNode.RelativeMoveTo -> "moveToRelative(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.MoveTo -> "moveTo(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.RelativeLineTo -> "lineToRelative(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.LineTo -> "lineTo(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.RelativeHorizontalTo -> "horizontalLineToRelative(${x.formatFloat()})" + is IrPathNode.HorizontalTo -> "horizontalLineTo(${x.formatFloat()})" + is IrPathNode.RelativeVerticalTo -> "verticalLineToRelative(${y.formatFloat()})" + is IrPathNode.VerticalTo -> "verticalLineTo(${y.formatFloat()})" + is IrPathNode.RelativeCurveTo -> { + "curveToRelative(${dx1.formatFloat()}, ${dy1.formatFloat()}, ${dx2.formatFloat()}, ${dy2.formatFloat()}, ${dx3.formatFloat()}, ${dy3.formatFloat()})" + } + is IrPathNode.CurveTo -> { + "curveTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()}, ${x3.formatFloat()}, ${y3.formatFloat()})" + } + is IrPathNode.RelativeReflectiveCurveTo -> { + "reflectiveCurveToRelative(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" + } + is IrPathNode.ReflectiveCurveTo -> { + "reflectiveCurveTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" + } + is IrPathNode.RelativeQuadTo -> { + "quadToRelative(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" + } + is IrPathNode.QuadTo -> { + "quadTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" + } + is IrPathNode.RelativeReflectiveQuadTo -> "reflectiveQuadToRelative(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.ReflectiveQuadTo -> "reflectiveQuadTo(${x.formatFloat()}, ${y.formatFloat()})" + is IrPathNode.RelativeArcTo -> { + "arcToRelative(${horizontalEllipseRadius.formatFloat()}, ${verticalEllipseRadius.formatFloat()}, ${theta.formatFloat()}, isMoreThanHalf = $isMoreThanHalf, isPositiveArc = $isPositiveArc, ${arcStartDx.formatFloat()}, ${arcStartDy.formatFloat()})" + } + is IrPathNode.ArcTo -> { + "arcTo(${horizontalEllipseRadius.formatFloat()}, ${verticalEllipseRadius.formatFloat()}, ${theta.formatFloat()}, isMoreThanHalf = $isMoreThanHalf, isPositiveArc = $isPositiveArc, ${arcStartX.formatFloat()}, ${arcStartY.formatFloat()})" + } +} + +internal fun List.asPathDataString(): String = toPathString() diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt index 47b157e42..da577731d 100644 --- a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt +++ b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt @@ -1,10 +1,10 @@ package io.github.composegears.valkyrie.generator.kmp.imagevector.util -import io.github.composegears.valkyrie.generator.core.asPathDataString -import io.github.composegears.valkyrie.generator.core.asStatement import io.github.composegears.valkyrie.generator.core.formatFloat import io.github.composegears.valkyrie.generator.core.trimTrailingZero import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorRenderConfig +import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asPathDataString +import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asStatement import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolveIconBuilderName import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolvePackageName import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolveReceiverName diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt index ff4a7d167..f8f34770d 100644 --- a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt +++ b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt @@ -1,9 +1,9 @@ package io.github.composegears.valkyrie.generator.kmp.imagevector.util -import io.github.composegears.valkyrie.generator.core.asPathDataString -import io.github.composegears.valkyrie.generator.core.asStatement import io.github.composegears.valkyrie.generator.core.formatFloat import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorRenderConfig +import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asPathDataString +import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asStatement import io.github.composegears.valkyrie.sdk.ir.core.IrColor import io.github.composegears.valkyrie.sdk.ir.core.IrFill import io.github.composegears.valkyrie.sdk.ir.core.IrPathFillType diff --git a/settings.gradle.kts b/settings.gradle.kts index c0d18bbbb..cec6201d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -75,6 +75,7 @@ include("components:generator:kmp:imagevector") include("components:generator:iconpack") include("components:generator:jvm:poet-extensions") include("components:generator:jvm:imagevector") +include("components:converter:figma") include("components:parser:common") include("components:parser:jvm:svg") include("components:parser:jvm:xml") diff --git a/tools/figma-plugin/README.md b/tools/figma-plugin/README.md new file mode 100644 index 000000000..e8c8d7638 --- /dev/null +++ b/tools/figma-plugin/README.md @@ -0,0 +1,56 @@ +# Valkyrie Figma Plugin (Simple Mode) + +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 -> Reload plugins` +3. Reopen `Valkyrie ImageVector Export` + +## Files + +- `manifest.json` - Figma plugin manifest +- `src/code.ts` - plugin main thread (selection and SVG export) +- `src/ui.ts` - plugin UI logic (conversion and result rendering) +- `src/converterAdapter.ts` - runtime bridge to Wasm converter + +## Runtime hookup + +`pnpm build` reads these converter outputs: + +- `valkyrie-components-converter-figma.mjs` +- `valkyrie-components-converter-figma.uninstantiated.mjs` +- `valkyrie-components-converter-figma.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..8fd386b53 --- /dev/null +++ b/tools/figma-plugin/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Valkyrie ImageVector Export", + "id": "valkyrie-imagevector-export", + "api": "1.0.0", + "main": "dist/code.js", + "ui": "dist/ui.html", + "menu": [ + { + "name": "Open exporter", + "command": "open-exporter" + }, + { + "name": "Re-export selection", + "command": "re-export" + } + ], + "relaunchButtons": [ + { + "name": "Open exporter", + "command": "open-exporter" + }, + { + "name": "Re-export", + "command": "re-export" + } + ], + "editorType": ["figma"] +} diff --git a/tools/figma-plugin/package.json b/tools/figma-plugin/package.json new file mode 100644 index 000000000..49bba6c8e --- /dev/null +++ b/tools/figma-plugin/package.json @@ -0,0 +1,21 @@ +{ + "name": "@composegears/valkyrie-figma-plugin", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build:converter": "../../gradlew :components:converter:figma: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": { + "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..5627cc925 --- /dev/null +++ b/tools/figma-plugin/pnpm-lock.yaml @@ -0,0 +1,312 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + 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 + + 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 + + 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..d5dcd60e7 --- /dev/null +++ b/tools/figma-plugin/scripts/build.mjs @@ -0,0 +1,139 @@ +import { build, context } from "esbuild"; +import { cp, 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 distDir = resolve(root, "dist"); +const repoRoot = resolve(root, "../.."); +const converterDistDir = resolve( + repoRoot, + "components/converter/figma/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(srcDir, "code.ts")], + outfile: resolve(distDir, "code.js"), + format: "iife", + platform: "browser", +}; + +const srcUiHtmlPath = resolve(srcDir, "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) { + process.stderr.write(`Failed to inline ui.html: ${String(error)}\n`); + } + }); + }, + }, + ], +}; + +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)]); +} + +const converterFiles = [ + "valkyrie-components-converter-figma.mjs", + "valkyrie-components-converter-figma.uninstantiated.mjs", + "valkyrie-components-converter-figma.wasm", +]; + +for (const file of converterFiles) { + try { + await cp(resolve(converterDistDir, file), resolve(distDir, file)); + } catch { + process.stderr.write( + `Missing converter artifact: ${file}. Run ../../gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs first.\n`, + ); + } +} + +if (!watch) { + process.stdout.write("Built Figma plugin assets.\n"); +} diff --git a/tools/figma-plugin/src/api.ts b/tools/figma-plugin/src/api.ts new file mode 100644 index 000000000..b152183a9 --- /dev/null +++ b/tools/figma-plugin/src/api.ts @@ -0,0 +1,22 @@ +import type { MainToUiMessage, UiToMainMessage } from "./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/bulkActions.ts b/tools/figma-plugin/src/bulkActions.ts new file mode 100644 index 000000000..e15b12e2e --- /dev/null +++ b/tools/figma-plugin/src/bulkActions.ts @@ -0,0 +1,53 @@ +import { zipSync, strToU8 } from "fflate"; +import { copyAllButton, downloadAllButton } from "./dom"; +import { getSuccessfulConversionResults, hasSuccessfulConversionResults } from "./state"; +import { setStatus } from "./status"; +import { copyText, flashButton } from "./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.content}`).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.content); + } + + 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/code.ts b/tools/figma-plugin/src/code.ts new file mode 100644 index 000000000..94f793758 --- /dev/null +++ b/tools/figma-plugin/src/code.ts @@ -0,0 +1,296 @@ +import type { + ConversionReadyMessage, + ConversionStartedMessage, + ExportedIcon, + SelectionChangedMessage, + SettingsErrorMessage, + SettingsLoadedMessage, + UiToMainMessage, +} from "./messages"; +import { createExportError, createInternalError, createSelectionError, formatPluginError } from "./errorFormatter"; +import { sanitizePluginSettings } from "./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; +const OPEN_EXPORTER_COMMAND = "open-exporter"; +const REEXPORT_COMMAND = "re-export"; +type LaunchCommand = typeof OPEN_EXPORTER_COMMAND | typeof REEXPORT_COMMAND; +const RELAUNCH_DATA: Record = { + [OPEN_EXPORTER_COMMAND]: "Open exporter", + [REEXPORT_COMMAND]: "Re-export with current settings", +}; + +type CancelReason = "user" | "superseded"; + +type ActiveRun = { + requestId: number; + token: { + cancelled: boolean; + reason: CancelReason | null; + }; +}; + +let activeRun: ActiveRun | null = null; +let pendingLaunchCommand: LaunchCommand = normalizeLaunchCommand(figma.command); + +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 === "close-plugin") { + figma.closePlugin(); + return; + } + + if (message.type === "load-settings") { + const launchCommand = pendingLaunchCommand; + pendingLaunchCommand = OPEN_EXPORTER_COMMAND; + + try { + const savedSettings = await figma.clientStorage.getAsync(SETTINGS_KEY); + const settings = sanitizePluginSettings(savedSettings); + figma.ui.postMessage({ + type: "settings-loaded", + settings, + launchCommand, + } 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, + launchCommand, + } 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 === "request-selection") { + sendSelectionUpdate(); + return; + } + + if (message.type === "cancel-conversion") { + if (activeRun && activeRun.requestId === message.requestId) { + activeRun.token.cancelled = true; + activeRun.token.reason = "user"; + } + return; + } + + if (message.type !== "run-conversion") { + return; + } + + const requestId = message.requestId; + if (activeRun && activeRun.requestId !== requestId) { + activeRun.token.cancelled = true; + activeRun.token.reason = "superseded"; + } + + const cancelToken: ActiveRun["token"] = { + cancelled: false, + reason: null, + }; + activeRun = { requestId, token: cancelToken }; + + 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 node.", + "Pick icon nodes in the canvas and run export again.", + ), + ), + } satisfies ConversionReadyMessage); + return; + } + + const { icons, firstError, failedCount, canceledReason } = await exportNodesAsSvg(exportableNodes, cancelToken); + + if (canceledReason) { + figma.ui.postMessage({ + type: "conversion-ready", + requestId, + icons: [], + canceled: true, + canceledReason, + } satisfies ConversionReadyMessage); + return; + } + + applyRelaunchData(exportableNodes, icons); + + 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 (typeof TextDecoder !== "undefined") { + return new TextDecoder("utf-8").decode(bytes); + } + + let result = ""; + for (let i = 0; i < bytes.length; i += 1) { + result += String.fromCharCode(bytes[i]); + } + return result; +} + +async function exportNodesAsSvg( + nodes: Array, + cancelToken: ActiveRun["token"], +): Promise<{ icons: ExportedIcon[]; firstError: string | null; failedCount: number; canceledReason: CancelReason | null }> { + 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 (cancelToken.cancelled) { + 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, + canceledReason: cancelToken.cancelled ? cancelToken.reason ?? "user" : null, + }; +} + +function applyRelaunchData(nodes: Array, successfulIcons: ExportedIcon[]): void { + const successfulIds = new Set(successfulIcons.map((icon) => icon.id)); + for (const node of nodes) { + if (!successfulIds.has(node.id)) { + continue; + } + + node.setRelaunchData(RELAUNCH_DATA); + } +} + +function normalizeLaunchCommand(command: string | undefined): LaunchCommand { + if (command === REEXPORT_COMMAND) { + return REEXPORT_COMMAND; + } + + return OPEN_EXPORTER_COMMAND; +} diff --git a/tools/figma-plugin/src/conversion.ts b/tools/figma-plugin/src/conversion.ts new file mode 100644 index 000000000..2dec5e44f --- /dev/null +++ b/tools/figma-plugin/src/conversion.ts @@ -0,0 +1,157 @@ +import { convert, isConverterReady, normalizeIconName } from "./converterAdapter"; +import { packageInput } from "./dom"; +import { createConverterUnavailableError, createSelectionError, createSettingsError, formatPluginError } from "./errorFormatter"; +import type { ExportedIcon } from "./messages"; +import { renderResults } from "./render"; +import { getConvertOptions } from "./settings"; +import { replaceConversionResults } from "./state"; +import { setStatus } from "./status"; +import type { ConvertResultWithSvg, StatusType } from "./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 icon nodes 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({ + ok: false, + iconName: normalized, + fileName: "", + content: "", + error: `Duplicate icon name '${normalized}'.`, + }); + continue; + } + + if (seenInsensitive.has(lowered)) { + nextResults.push({ + ok: false, + iconName: normalized, + fileName: "", + content: "", + error: `Case-insensitive collision for '${normalized}'.`, + }); + continue; + } + + seenExact.add(normalized); + seenInsensitive.add(lowered); + + const result: ConvertResultWithSvg = { ...convert(icon.svg, icon.name, options), svg: icon.svg }; + nextResults.push(result); + + 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.ok).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/converterAdapter.ts b/tools/figma-plugin/src/converterAdapter.ts new file mode 100644 index 000000000..6ef8f689a --- /dev/null +++ b/tools/figma-plugin/src/converterAdapter.ts @@ -0,0 +1,81 @@ +import type { AutoMirrorOption, OutputFormat } from "./pluginSettings"; + +export type ConvertOptions = { + packageName: string; + outputFormat: OutputFormat; + useComposeColors: boolean; + addTrailingComma: boolean; + useExplicitMode: boolean; + usePathDataString: boolean; + indentSize: number; + autoMirror: AutoMirrorOption; +}; + +export type ConvertResult = { + ok: boolean; + iconName: string; + fileName: string; + content: 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: string, + ) => 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 { + ok: false, + iconName, + fileName: "", + content: "", + 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, + ); + + return JSON.parse(json) as ConvertResult; +} diff --git a/tools/figma-plugin/src/dom.ts b/tools/figma-plugin/src/dom.ts new file mode 100644 index 000000000..a2be48344 --- /dev/null +++ b/tools/figma-plugin/src/dom.ts @@ -0,0 +1,33 @@ +export const runButton = document.querySelector("#run")!; +export const cancelButton = document.querySelector("#cancel")!; +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/errorFormatter.ts b/tools/figma-plugin/src/errorFormatter.ts new file mode 100644 index 000000000..09e325e72 --- /dev/null +++ b/tools/figma-plugin/src/errorFormatter.ts @@ -0,0 +1,52 @@ +export type PluginError = { + summary: string; + nextStep: string; + diagnostics?: string; +}; + +export function formatPluginError(error: PluginError): string { + const base = `${error.summary} Next: ${error.nextStep}`; + if (!error.diagnostics) { + return base; + } + + return `${base} Diagnostics: ${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/highlight.ts b/tools/figma-plugin/src/highlight.ts new file mode 100644 index 000000000..05c1ccedc --- /dev/null +++ b/tools/figma-plugin/src/highlight.ts @@ -0,0 +1,142 @@ +import { escapeHtml } from "./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/messageHandlers.ts b/tools/figma-plugin/src/messageHandlers.ts new file mode 100644 index 000000000..da27fe85b --- /dev/null +++ b/tools/figma-plugin/src/messageHandlers.ts @@ -0,0 +1,99 @@ +import type { MainToUiMessage } from "./messages"; +import { getConversionResultsCount, getConversionResults } from "./state"; +import { applySettings } from "./settings"; +import { setStatus } from "./status"; +import { + isLoadingResultsVisible, + renderLoadingResults, + renderResults, + showCanceledEmptyState, + showLoadingEmptyState, +} from "./render"; +import { runConversion } from "./conversion"; + +type SelectionController = { + handleSelectionChanged: (count: number, names: string[]) => void; + handleSettingsLoaded: (options?: { suppressAutoRun?: boolean }) => void; +}; + +type RequestController = { + requestConversion: () => void; + isLatestRequest: (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); + const shouldReexport = message.launchCommand === "re-export"; + deps.selectionController.handleSettingsLoaded({ suppressAutoRun: shouldReexport }); + if (shouldReexport) { + deps.requestController.requestConversion(); + } + return; + } + + case "conversion-started": { + if (!deps.requestController.isLatestRequest(message.requestId)) { + return; + } + renderLoadingResults(message.selectedCount); + showLoadingEmptyState(); + setStatus(`Exporting ${message.selectedCount} selected node(s)...`, "working"); + return; + } + + case "conversion-ready": { + if (!deps.requestController.completeRequest(message.requestId)) { + return; + } + + if (message.canceled) { + setStatus( + message.canceledReason === "superseded" + ? "Run superseded by a newer request." + : "Run canceled.", + "ready", + ); + + if (getConversionResultsCount() > 0) { + if (isLoadingResultsVisible()) { + renderResults(Array.from(getConversionResults())); + } + } else if (message.canceledReason === "user") { + renderResults([]); + showCanceledEmptyState(); + } + 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/messages.ts b/tools/figma-plugin/src/messages.ts new file mode 100644 index 000000000..9609f21e5 --- /dev/null +++ b/tools/figma-plugin/src/messages.ts @@ -0,0 +1,83 @@ +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; + canceled?: boolean; + canceledReason?: "user" | "superseded"; +}; + +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; + launchCommand?: "open-exporter" | "re-export"; +}; + +export type MainToUiMessage = + | ConversionStartedMessage + | ConversionReadyMessage + | SelectionChangedMessage + | SettingsLoadedMessage + | SettingsErrorMessage; + +export type RunConversionMessage = { + type: "run-conversion"; + requestId: number; +}; + +export type RequestSelectionMessage = { + type: "request-selection"; +}; + +export type CloseMessage = { + type: "close-plugin"; +}; + +export type SaveSettingsMessage = { + type: "save-settings"; + settings: PluginSettings; +}; + +export type LoadSettingsMessage = { + type: "load-settings"; +}; + +export type CancelConversionMessage = { + type: "cancel-conversion"; + requestId: number; +}; + +export type UiToMainMessage = + | RunConversionMessage + | CancelConversionMessage + | RequestSelectionMessage + | CloseMessage + | SaveSettingsMessage + | LoadSettingsMessage; diff --git a/tools/figma-plugin/src/pluginSettings.ts b/tools/figma-plugin/src/pluginSettings.ts new file mode 100644 index 000000000..80491ae1e --- /dev/null +++ b/tools/figma-plugin/src/pluginSettings.ts @@ -0,0 +1,69 @@ +export type OutputFormat = "backing_property" | "lazy_property"; +export type AutoMirrorOption = "" | "true" | "false"; + +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: "", + 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; +} + +function asAutoMirrorOption(value: unknown): AutoMirrorOption | null { + return value === "" || value === "true" || value === "false" ? value : null; +} + +export function sanitizePluginSettings(value: unknown): PluginSettings | null { + const raw = asObject(value); + if (!raw) { + return null; + } + + return { + packageName: typeof raw.packageName === "string" ? raw.packageName : 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: asAutoMirrorOption(raw.autoMirror) ?? DEFAULT_PLUGIN_SETTINGS.autoMirror, + autoExport: typeof raw.autoExport === "boolean" ? raw.autoExport : DEFAULT_PLUGIN_SETTINGS.autoExport, + }; +} diff --git a/tools/figma-plugin/src/render.ts b/tools/figma-plugin/src/render.ts new file mode 100644 index 000000000..8f92b207e --- /dev/null +++ b/tools/figma-plugin/src/render.ts @@ -0,0 +1,476 @@ +import type { ConvertResultWithSvg } from "./types"; +import { resultsContainer, emptyState, emptyStateTitle, emptyStateDescription, selectionPreview, mainScroll } from "./dom"; +import { escapeHtml, escapeAttr, copyText, flashButton, toBase64Utf8 } from "./utils"; +import { setStatus } from "./status"; +import { highlightKotlin } from "./highlight"; + +const EMPTY_TITLE_DEFAULT = "No icons exported yet"; +const EMPTY_MESSAGE_DEFAULT = "Select one or more icon nodes in Figma to generate Kotlin ImageVector code automatically."; +const EMPTY_TITLE_LOADING = "Generating code..."; +const EMPTY_MESSAGE_LOADING = "Exporting your selected node(s)."; +const EMPTY_TITLE_AUTO_EXPORT_OFF = "Auto export is off"; +const EMPTY_MESSAGE_AUTO_EXPORT_OFF = "Select icon nodes and click Refresh to export."; +const EMPTY_TITLE_CANCELED = "Export canceled"; +const EMPTY_MESSAGE_CANCELED = "You canceled the current run. Click Refresh to start again."; +const LARGE_BATCH_COLLAPSE_THRESHOLD = 20; +const CODE_RENDER_BATCH_SIZE = 10; +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 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", +): void { + let index = 0; + + const runBatch = (): void => { + const end = Math.min(index + EXPAND_COLLAPSE_BATCH_SIZE, expanders.length); + while (index < end) { + if (action === "expand") { + expanders[index].expand(false); + } else { + expanders[index].collapse(); + } + index += 1; + } + + if (index < expanders.length) { + window.requestAnimationFrame(runBatch); + } + }; + + 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 nodes selected
`; + return; + } + + const label = count === 1 ? "node" : "nodes"; + 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; + 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.ok).length; + 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: "200px", + threshold: 0, + }) + : null; + activeCodeObserver = codeObserver; + + if (collapseByDefault) { + const toolbar = document.createElement("div"); + toolbar.className = "results-toolbar"; + + const label = document.createElement("span"); + label.className = "results-toolbar-label"; + label.textContent = `Large batch (${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"); + }); + 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.ok ? "ok" : "error"}:${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.ok && 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.ok) { + 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.content); + 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.content); + 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.content], { 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 (collapseByDefault) { + 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 (collapseByDefault) { + setExpanded(false, toggleButton, false); + 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.ok) { + 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 showCanceledEmptyState(): void { + emptyStateTitle.textContent = EMPTY_TITLE_CANCELED; + emptyStateDescription.textContent = EMPTY_MESSAGE_CANCELED; +} + +export function isLoadingResultsVisible(): boolean { + return loadingResultsVisible; +} diff --git a/tools/figma-plugin/src/requestController.ts b/tools/figma-plugin/src/requestController.ts new file mode 100644 index 000000000..c3be2ae41 --- /dev/null +++ b/tools/figma-plugin/src/requestController.ts @@ -0,0 +1,88 @@ +import { sendMessage } from "./api"; +import { createTimeoutError, formatPluginError } from "./errorFormatter"; +import { setStatus } from "./status"; + +const REQUEST_TIMEOUT_MS = 5000; + +type RequestControllerDeps = { + onRunningChanged?: (running: boolean) => void; +}; + +export function createRequestController(deps: RequestControllerDeps = {}) { + let latestRequestId = 0; + let activeRequestId: number | null = null; + let pendingTimeoutId: number | null = null; + + const setRunning = (running: boolean): void => { + deps.onRunningChanged?.(running); + }; + + const clearPendingTimeout = (): void => { + if (pendingTimeoutId !== null) { + window.clearTimeout(pendingTimeoutId); + pendingTimeoutId = null; + } + }; + + const setPendingTimeout = (callback: () => void, ms: number): void => { + clearPendingTimeout(); + pendingTimeoutId = window.setTimeout(callback, ms); + }; + + return { + requestConversion(): void { + const hadActiveRequest = activeRequestId !== null; + latestRequestId += 1; + const requestId = latestRequestId; + activeRequestId = requestId; + setRunning(true); + + setStatus( + hadActiveRequest + ? "Previous run superseded by newer run. Requesting latest export from Figma..." + : "Requesting export from Figma...", + "working", + ); + clearPendingTimeout(); + setPendingTimeout(() => { + if (requestId !== latestRequestId) { + return; + } + activeRequestId = null; + setRunning(false); + setStatus(formatPluginError(createTimeoutError()), "error"); + }, REQUEST_TIMEOUT_MS); + + sendMessage({ type: "run-conversion", requestId }); + }, + + cancelActiveRequest(): boolean { + if (activeRequestId === null) { + return false; + } + + const requestId = activeRequestId; + activeRequestId = null; + setRunning(false); + clearPendingTimeout(); + sendMessage({ type: "cancel-conversion", requestId }); + setStatus("Run canceled. Start a new export when ready.", "ready"); + return true; + }, + + isLatestRequest(requestId: number): boolean { + return requestId === latestRequestId; + }, + + completeRequest(requestId: number): boolean { + if (requestId !== latestRequestId) { + return false; + } + + activeRequestId = null; + setRunning(false); + clearPendingTimeout(); + return true; + }, + }; +} diff --git a/tools/figma-plugin/src/selectionController.ts b/tools/figma-plugin/src/selectionController.ts new file mode 100644 index 000000000..4ffa5ac30 --- /dev/null +++ b/tools/figma-plugin/src/selectionController.ts @@ -0,0 +1,120 @@ +import { autoExportInput } from "./dom"; +import { renderResults, showAutoExportDisabledEmptyState, showDefaultEmptyState, showLoadingEmptyState, updateSelectionPreview } from "./render"; +import { clearConversionResults } from "./state"; + +const AUTO_RUN_DEBOUNCE_MS = 300; + +type SelectionControllerDeps = { + requestConversion: () => void; + updateBulkActionState: () => void; +}; + +type SettingsLoadedOptions = { + suppressAutoRun?: boolean; +}; + +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; + }; + + return { + handleSelectionChanged(count: number, names: string[]): void { + latestSelectionCount = count; + updateSelectionPreview(count, names); + + if (count === 0) { + scheduleAutoConversion(); + clearConversionResults(); + renderResults([]); + deps.updateBulkActionState(); + showDefaultEmptyState(); + return; + } + + if (!settingsInitialized) { + showDefaultEmptyState(); + return; + } + + if (!autoExportInput.checked) { + scheduleAutoConversion(); + showAutoExportDisabledEmptyState(); + return; + } + + const shouldRunImmediately = !hasRequestedInitialAutoConversion; + if (scheduleAutoConversion(shouldRunImmediately)) { + showLoadingEmptyState(); + } + }, + + handleSettingsLoaded(options: SettingsLoadedOptions = {}): void { + settingsInitialized = true; + + if (latestSelectionCount === 0) { + showDefaultEmptyState(); + return; + } + + if (!autoExportInput.checked) { + scheduleAutoConversion(); + showAutoExportDisabledEmptyState(); + return; + } + + if (options.suppressAutoRun) { + showLoadingEmptyState(); + return; + } + + if (scheduleAutoConversion(!hasRequestedInitialAutoConversion)) { + showLoadingEmptyState(); + } + }, + + handleSettingsInputChanged(): void { + const isScheduled = scheduleAutoConversion(); + + if (latestSelectionCount === 0) { + showDefaultEmptyState(); + return; + } + + if (!autoExportInput.checked) { + showAutoExportDisabledEmptyState(); + return; + } + + if (isScheduled) { + showLoadingEmptyState(); + } + }, + }; +} diff --git a/tools/figma-plugin/src/settings.ts b/tools/figma-plugin/src/settings.ts new file mode 100644 index 000000000..2d775b34a --- /dev/null +++ b/tools/figma-plugin/src/settings.ts @@ -0,0 +1,71 @@ +import { packageInput, outputFormatInput, useComposeColorsInput, addTrailingCommaInput, useExplicitModeInput, usePathDataStringInput, autoMirrorInput, autoExportInput, settingsInputs } from "./dom"; +import type { ConvertOptions } from "./converterAdapter"; +import { sendMessage } from "./api"; +import type { PluginSettings } from "./pluginSettings"; +import { sanitizePluginSettings } from "./pluginSettings"; + +let saveSettingsTimeoutId: number | null = null; + +export function addSettingsInputListeners(listener: () => void): void { + for (const input of settingsInputs) { + input.addEventListener("input", listener); + input.addEventListener("change", listener); + } +} + +export function getSettingsValues(): PluginSettings { + return { + packageName: packageInput.value, + outputFormat: outputFormatInput.value as PluginSettings["outputFormat"], + useComposeColors: useComposeColorsInput.checked, + addTrailingComma: addTrailingCommaInput.checked, + useExplicitMode: useExplicitModeInput.checked, + usePathDataString: usePathDataStringInput.checked, + autoMirror: autoMirrorInput.value as PluginSettings["autoMirror"], + autoExport: autoExportInput.checked, + }; +} + +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 = parsed.autoMirror; + autoExportInput.checked = parsed.autoExport; +} + +export function scheduleSaveSettings(): void { + if (saveSettingsTimeoutId !== null) { + window.clearTimeout(saveSettingsTimeoutId); + } + saveSettingsTimeoutId = window.setTimeout(() => { + sendMessage({ type: "save-settings", settings: getSettingsValues() }); + saveSettingsTimeoutId = null; + }, 500); +} + +export function initSettingsListeners(): void { + addSettingsInputListeners(scheduleSaveSettings); + sendMessage({ type: "load-settings" }); +} + +export function getConvertOptions(): ConvertOptions { + return { + packageName: packageInput.value.trim(), + outputFormat: outputFormatInput.value as ConvertOptions["outputFormat"], + useComposeColors: useComposeColorsInput.checked, + addTrailingComma: addTrailingCommaInput.checked, + useExplicitMode: useExplicitModeInput.checked, + usePathDataString: usePathDataStringInput.checked, + indentSize: 4, + autoMirror: autoMirrorInput.value as ConvertOptions["autoMirror"], + }; +} diff --git a/tools/figma-plugin/src/state.ts b/tools/figma-plugin/src/state.ts new file mode 100644 index 000000000..b1dc34188 --- /dev/null +++ b/tools/figma-plugin/src/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.ok); +} + +export function getSuccessfulConversionResults(): ConvertResultWithSvg[] { + return conversionResults.filter((item) => item.ok); +} diff --git a/tools/figma-plugin/src/status.ts b/tools/figma-plugin/src/status.ts new file mode 100644 index 000000000..d384a042d --- /dev/null +++ b/tools/figma-plugin/src/status.ts @@ -0,0 +1,31 @@ +import { statusText, statusIcon, statusDetails, statusDiagnostics } from "./dom"; +import type { StatusType } from "./types"; + +const DIAGNOSTICS_MARKER = " Diagnostics: "; + +function splitDiagnostics(message: string): { summary: string; diagnostics: string | null } { + const markerIndex = message.indexOf(DIAGNOSTICS_MARKER); + if (markerIndex < 0) { + return { summary: message, diagnostics: null }; + } + + const summary = message.slice(0, markerIndex).trim(); + const diagnostics = message.slice(markerIndex + DIAGNOSTICS_MARKER.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/types.ts b/tools/figma-plugin/src/types.ts new file mode 100644 index 000000000..ae0d76798 --- /dev/null +++ b/tools/figma-plugin/src/types.ts @@ -0,0 +1,5 @@ +import type { ConvertResult } from "./converterAdapter"; + +export type ConvertResultWithSvg = ConvertResult & { svg?: string }; + +export type StatusType = "ready" | "working" | "warning" | "error"; diff --git a/tools/figma-plugin/src/ui.html b/tools/figma-plugin/src/ui.html new file mode 100644 index 000000000..ca83721ed --- /dev/null +++ b/tools/figma-plugin/src/ui.html @@ -0,0 +1,798 @@ + + + + + + Valkyrie Figma Export + + + +
+ + + + +
+
+
+ + +
+
+ + + + + +
+

Preparing export...

+

Checking current selection and settings.

+
+
+
+
+ + + + + diff --git a/tools/figma-plugin/src/ui.ts b/tools/figma-plugin/src/ui.ts new file mode 100644 index 000000000..a19dd9d67 --- /dev/null +++ b/tools/figma-plugin/src/ui.ts @@ -0,0 +1,64 @@ +import { runButton, cancelButton } from "./dom"; +import { getConversionResultsCount, getConversionResults } from "./state"; +import { addSettingsInputListeners, initSettingsListeners } from "./settings"; +import { sendMessage, onMessage, onError } from "./api"; +import { setStatus } from "./status"; +import { initializeBulkActions, updateBulkActionState } from "./bulkActions"; +import { createSelectionController } from "./selectionController"; +import { createRequestController } from "./requestController"; +import { createMainMessageHandler } from "./messageHandlers"; +import { renderResults, showCanceledEmptyState } from "./render"; + +const requestController = createRequestController({ + onRunningChanged: (running) => { + cancelButton.disabled = !running; + }, +}); + +const selectionController = createSelectionController({ + requestConversion: () => { + requestController.requestConversion(); + }, + updateBulkActionState, +}); + +initSettingsListeners(); +initializeBulkActions(); +setStatus("Ready"); +updateBulkActionState(); + +runButton.addEventListener("click", () => { + requestController.requestConversion(); +}); + +cancelButton.addEventListener("click", () => { + const canceled = requestController.cancelActiveRequest(); + if (!canceled) { + return; + } + + if (getConversionResultsCount() > 0) { + renderResults(Array.from(getConversionResults())); + return; + } + + renderResults([]); + showCanceledEmptyState(); +}); + +addSettingsInputListeners(() => { + selectionController.handleSettingsInputChanged(); +}); + +onMessage( + createMainMessageHandler({ + selectionController, + requestController, + }), +); + +onError((event) => { + setStatus(`UI error: ${event.message}`, "error"); +}); + +sendMessage({ type: "request-selection" }); diff --git a/tools/figma-plugin/src/utils.ts b/tools/figma-plugin/src/utils.ts new file mode 100644 index 000000000..653817d41 --- /dev/null +++ b/tools/figma-plugin/src/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/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"] +} From b0a97398313d9cfec847dcf9e27c9a7b7d9d9c52 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Tue, 3 Mar 2026 23:28:04 +0000 Subject: [PATCH 2/7] refactor(converter): stabilize figma converter result contracts --- DEVELOPMENT.md | 2 +- components/converter/figma/api/figma.api | 5 - components/converter/figma/api/figma.klib.api | 10 -- components/converter/figma/build.gradle.kts | 23 --- .../converter/figma/FigmaWasmConverter.kt | 131 ------------------ sdk/figma/converter/api/converter.api | 70 ---------- sdk/figma/converter/api/converter.klib.api | 71 ---------- sdk/figma/converter/build.gradle.kts | 4 +- .../converter/figma/FigmaConverter.kt | 4 +- .../converter/figma/FigmaConverterTest.kt | 1 - settings.gradle.kts | 1 - tools/figma-plugin/README.md | 6 +- tools/figma-plugin/package.json | 2 +- tools/figma-plugin/scripts/build.mjs | 22 ++- tools/figma-plugin/src/bulkActions.ts | 4 +- tools/figma-plugin/src/conversion.ts | 10 +- tools/figma-plugin/src/converterAdapter.ts | 43 +++++- tools/figma-plugin/src/pluginSettings.ts | 11 +- tools/figma-plugin/src/render.ts | 16 +-- tools/figma-plugin/src/settings.ts | 18 ++- tools/figma-plugin/src/state.ts | 4 +- 21 files changed, 100 insertions(+), 358 deletions(-) delete mode 100644 components/converter/figma/api/figma.api delete mode 100644 components/converter/figma/api/figma.klib.api delete mode 100644 components/converter/figma/build.gradle.kts delete mode 100644 components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 38c845397..67ca318fa 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -40,7 +40,7 @@ Update changelog: `./gradlew tools:idea-plugin:patchChangelog` ## FIGMA Plugin (Simple mode) -- Build converter for Wasm executable: `./gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs` +- Build converter for Wasm executable: `./gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs` - Install plugin package deps (pnpm): `pnpm install` (run in `tools/figma-plugin`) - Build plugin assets: `pnpm build` (run in `tools/figma-plugin`) - Build converter + plugin assets: `pnpm build:all` (run in `tools/figma-plugin`) diff --git a/components/converter/figma/api/figma.api b/components/converter/figma/api/figma.api deleted file mode 100644 index 1ea5ff12f..000000000 --- a/components/converter/figma/api/figma.api +++ /dev/null @@ -1,5 +0,0 @@ -public final class io/github/composegears/valkyrie/converter/figma/FigmaWasmConverterKt { - public static final fun convertSvg (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/String;)Ljava/lang/String; - public static synthetic fun convertSvg$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/String;ILjava/lang/Object;)Ljava/lang/String; - public static final fun normalizeIconName (Ljava/lang/String;)Ljava/lang/String; -} diff --git a/components/converter/figma/api/figma.klib.api b/components/converter/figma/api/figma.klib.api deleted file mode 100644 index 49e5517f6..000000000 --- a/components/converter/figma/api/figma.klib.api +++ /dev/null @@ -1,10 +0,0 @@ -// Klib ABI Dump -// Targets: [wasmJs] -// Rendering settings: -// - Signature version: 2 -// - Show manifest properties: true -// - Show declarations: true - -// Library unique name: -final fun io.github.composegears.valkyrie.converter.figma/convertSvg(kotlin/String, kotlin/String, kotlin/String, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...): kotlin/String // io.github.composegears.valkyrie.converter.figma/convertSvg|convertSvg(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] -final fun io.github.composegears.valkyrie.converter.figma/normalizeIconName(kotlin/String): kotlin/String // io.github.composegears.valkyrie.converter.figma/normalizeIconName|normalizeIconName(kotlin.String){}[0] diff --git a/components/converter/figma/build.gradle.kts b/components/converter/figma/build.gradle.kts deleted file mode 100644 index a467d4d42..000000000 --- a/components/converter/figma/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - alias(libs.plugins.valkyrie.kmp) - alias(libs.plugins.valkyrie.abi) - alias(libs.plugins.valkyrie.kover) -} - -kotlin { - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) - wasmJs { - binaries.executable() - } - - sourceSets { - commonMain.dependencies { - implementation(projects.components.parser.unified) - implementation(projects.components.generator.kmp.imagevector) - implementation(projects.sdk.ir.core) - } - commonTest.dependencies { - implementation(libs.bundles.kmp.test) - } - } -} diff --git a/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt b/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt deleted file mode 100644 index 880f5f13d..000000000 --- a/components/converter/figma/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaWasmConverter.kt +++ /dev/null @@ -1,131 +0,0 @@ -@file:OptIn(ExperimentalJsExport::class) - -package io.github.composegears.valkyrie.converter.figma - -import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorGenerator -import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorGeneratorConfig -import io.github.composegears.valkyrie.generator.kmp.imagevector.OutputFormat -import io.github.composegears.valkyrie.parser.unified.ParserType -import io.github.composegears.valkyrie.parser.unified.SvgXmlParser -import io.github.composegears.valkyrie.parser.unified.util.IconNameFormatter -import kotlin.js.ExperimentalJsExport -import kotlin.js.JsExport - -@JsExport -fun convertSvg( - svg: String, - iconName: String, - packageName: String, - outputFormat: String = OutputFormat.BackingProperty.key, - useComposeColors: Boolean = true, - addTrailingComma: Boolean = false, - useExplicitMode: Boolean = false, - usePathDataString: Boolean = false, - indentSize: Int = 4, - autoMirror: String = "", -): String { - return runCatching { - val normalizedIconName = IconNameFormatter.format(iconName) - - val parseOutput = SvgXmlParser.toIrImageVector( - parser = ParserType.Kmp, - value = svg, - iconName = normalizedIconName, - ).let { - when (autoMirror.lowercase()) { - "true" -> it.copy(irImageVector = it.irImageVector.copy(autoMirror = true)) - "false" -> it.copy(irImageVector = it.irImageVector.copy(autoMirror = false)) - else -> it - } - } - - val output = ImageVectorGenerator.convert( - vector = parseOutput.irImageVector, - iconName = parseOutput.iconName, - config = ImageVectorGeneratorConfig( - packageName = packageName, - iconPackPackage = packageName, - packName = "", - nestedPackName = "", - outputFormat = when (outputFormat) { - OutputFormat.LazyProperty.key -> OutputFormat.LazyProperty - else -> OutputFormat.BackingProperty - }, - useComposeColors = useComposeColors, - generatePreview = false, - useFlatPackage = false, - addTrailingComma = addTrailingComma, - useExplicitMode = useExplicitMode, - usePathDataString = usePathDataString, - indentSize = indentSize, - ), - ) - - ConverterResult( - ok = true, - iconName = output.name, - fileName = "${output.name}.kt", - content = output.content, - ) - }.getOrElse { error -> - ConverterResult( - ok = false, - iconName = iconName, - fileName = "", - content = "", - error = error.message ?: "Unknown conversion error", - ) - }.toJson() -} - -@JsExport -fun normalizeIconName(iconName: String): String = IconNameFormatter.format(iconName) - -private data class ConverterResult( - val ok: Boolean, - val iconName: String, - val fileName: String, - val content: String, - val error: String? = null, -) { - fun toJson(): String { - return buildString { - append('{') - append("\"ok\":$ok") - append(',') - append("\"iconName\":\"") - append(iconName.escapeJson()) - append("\"") - append(',') - append("\"fileName\":\"") - append(fileName.escapeJson()) - append("\"") - append(',') - append("\"content\":\"") - append(content.escapeJson()) - append("\"") - if (error != null) { - append(',') - append("\"error\":\"") - append(error.escapeJson()) - append("\"") - } - append('}') - } - } -} - -private fun String.escapeJson(): String { - return buildString(length) { - for (ch in this@escapeJson) { - when (ch) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> append(ch) - } - } - } -} diff --git a/sdk/figma/converter/api/converter.api b/sdk/figma/converter/api/converter.api index b24cc36c9..bcb23c4be 100644 --- a/sdk/figma/converter/api/converter.api +++ b/sdk/figma/converter/api/converter.api @@ -1,75 +1,5 @@ -public abstract interface class io/github/composegears/valkyrie/converter/figma/ConverterResult { - public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Companion; - public abstract fun getIconName ()Ljava/lang/String; -} - -public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error : io/github/composegears/valkyrie/converter/figma/ConverterResult { - public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; - public static synthetic fun copy$default (Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/String; - public fun getIconName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success : io/github/composegears/valkyrie/converter/figma/ConverterResult { - public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; - public static synthetic fun copy$default (Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; - public fun equals (Ljava/lang/Object;)Z - public final fun getCode ()Ljava/lang/String; - public final fun getFileName ()Ljava/lang/String; - public fun getIconName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/github/composegears/valkyrie/converter/figma/FigmaConverterKt { public static final fun convertSvg (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/Boolean;)Ljava/lang/String; public static synthetic fun convertSvg$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/String; public static final fun normalizeIconName (Ljava/lang/String;)Ljava/lang/String; } - diff --git a/sdk/figma/converter/api/converter.klib.api b/sdk/figma/converter/api/converter.klib.api index 3d0adc894..592c12367 100644 --- a/sdk/figma/converter/api/converter.klib.api +++ b/sdk/figma/converter/api/converter.klib.api @@ -6,76 +6,5 @@ // - Show declarations: true // Library unique name: -sealed interface io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult|null[0] - abstract val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.iconName|{}iconName[0] - abstract fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.iconName.|(){}[0] - - final class Error : io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error|null[0] - constructor (kotlin/String, kotlin/String) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.|(kotlin.String;kotlin.String){}[0] - - final val error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.error|{}error[0] - final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.error.|(){}[0] - final val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.iconName|{}iconName[0] - final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.iconName.|(){}[0] - - final fun component1(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.component1|component1(){}[0] - final fun component2(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.component2|component2(){}[0] - final fun copy(kotlin/String = ..., kotlin/String = ...): io.github.composegears.valkyrie.converter.figma/ConverterResult.Error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.copy|copy(kotlin.String;kotlin.String){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.toString|toString(){}[0] - - final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer|null[0] - final val descriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.descriptor|{}descriptor[0] - final fun (): kotlinx.serialization.descriptors/SerialDescriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.descriptor.|(){}[0] - - final fun childSerializers(): kotlin/Array> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.childSerializers|childSerializers(){}[0] - final fun deserialize(kotlinx.serialization.encoding/Decoder): io.github.composegears.valkyrie.converter.figma/ConverterResult.Error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] - final fun serialize(kotlinx.serialization.encoding/Encoder, io.github.composegears.valkyrie.converter.figma/ConverterResult.Error) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;io.github.composegears.valkyrie.converter.figma.ConverterResult.Error){}[0] - } - - final object Companion { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.Companion|null[0] - final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.Companion.serializer|serializer(){}[0] - } - } - - final class Success : io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success|null[0] - constructor (kotlin/String, kotlin/String, kotlin/String) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.|(kotlin.String;kotlin.String;kotlin.String){}[0] - - final val code // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.code|{}code[0] - final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.code.|(){}[0] - final val fileName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.fileName|{}fileName[0] - final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.fileName.|(){}[0] - final val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.iconName|{}iconName[0] - final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.iconName.|(){}[0] - - final fun component1(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component1|component1(){}[0] - final fun component2(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component2|component2(){}[0] - final fun component3(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component3|component3(){}[0] - final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ...): io.github.composegears.valkyrie.converter.figma/ConverterResult.Success // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.copy|copy(kotlin.String;kotlin.String;kotlin.String){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.toString|toString(){}[0] - - final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer|null[0] - final val descriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.descriptor|{}descriptor[0] - final fun (): kotlinx.serialization.descriptors/SerialDescriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.descriptor.|(){}[0] - - final fun childSerializers(): kotlin/Array> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.childSerializers|childSerializers(){}[0] - final fun deserialize(kotlinx.serialization.encoding/Decoder): io.github.composegears.valkyrie.converter.figma/ConverterResult.Success // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] - final fun serialize(kotlinx.serialization.encoding/Encoder, io.github.composegears.valkyrie.converter.figma/ConverterResult.Success) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;io.github.composegears.valkyrie.converter.figma.ConverterResult.Success){}[0] - } - - final object Companion { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.Companion|null[0] - final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.Companion.serializer|serializer(){}[0] - } - } - - final object Companion : kotlinx.serialization.internal/SerializerFactory { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion|null[0] - final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion.serializer|serializer(){}[0] - final fun serializer(kotlin/Array>...): kotlinx.serialization/KSerializer<*> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion.serializer|serializer(kotlin.Array>...){}[0] - } -} - final fun io.github.composegears.valkyrie.converter.figma/convertSvg(kotlin/String, kotlin/String, kotlin/String, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/Boolean? = ...): kotlin/String // io.github.composegears.valkyrie.converter.figma/convertSvg|convertSvg(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Int;kotlin.Boolean?){}[0] final fun io.github.composegears.valkyrie.converter.figma/normalizeIconName(kotlin/String): kotlin/String // io.github.composegears.valkyrie.converter.figma/normalizeIconName|normalizeIconName(kotlin.String){}[0] diff --git a/sdk/figma/converter/build.gradle.kts b/sdk/figma/converter/build.gradle.kts index efa3ae90d..e58c68e62 100644 --- a/sdk/figma/converter/build.gradle.kts +++ b/sdk/figma/converter/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl - plugins { alias(libs.plugins.valkyrie.kmp) alias(libs.plugins.valkyrie.abi) @@ -8,7 +6,7 @@ plugins { } kotlin { - @OptIn(ExperimentalWasmDsl::class) + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) wasmJs { binaries.executable() } diff --git a/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt b/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt index 7981c2da8..5d6be42c3 100644 --- a/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt +++ b/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt @@ -83,8 +83,8 @@ private fun resolveOutputFormat(outputFormat: String): OutputFormat = when (outp OutputFormat.BackingProperty.key -> OutputFormat.BackingProperty OutputFormat.LazyProperty.key -> OutputFormat.LazyProperty else -> throw IllegalArgumentException( - "Unsupported outputFormat '$outputFormat'. Supported values: " + - "'${OutputFormat.BackingProperty.key}', '${OutputFormat.LazyProperty.key}'.", + "Unsupported outputFormat '$outputFormat'. Supported values: " + + "'${OutputFormat.BackingProperty.key}', '${OutputFormat.LazyProperty.key}'.", ) } diff --git a/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt b/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt index 602f4430f..9ae3c8fb8 100644 --- a/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt +++ b/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt @@ -79,6 +79,5 @@ class FigmaConverterTest { assertThat(error.error).contains("Unsupported outputFormat") assertThat(error.iconName).isEqualTo("ic_test_icon") } - private fun String.jsonType(): String = Json.parseToJsonElement(this).jsonObject.getValue("type").jsonPrimitive.content } diff --git a/settings.gradle.kts b/settings.gradle.kts index cec6201d9..c0d18bbbb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -75,7 +75,6 @@ include("components:generator:kmp:imagevector") include("components:generator:iconpack") include("components:generator:jvm:poet-extensions") include("components:generator:jvm:imagevector") -include("components:converter:figma") include("components:parser:common") include("components:parser:jvm:svg") include("components:parser:jvm:xml") diff --git a/tools/figma-plugin/README.md b/tools/figma-plugin/README.md index e8c8d7638..e86b40e62 100644 --- a/tools/figma-plugin/README.md +++ b/tools/figma-plugin/README.md @@ -36,9 +36,9 @@ This package contains a Figma plugin shell for exporting selected icons into Kot `pnpm build` reads these converter outputs: -- `valkyrie-components-converter-figma.mjs` -- `valkyrie-components-converter-figma.uninstantiated.mjs` -- `valkyrie-components-converter-figma.wasm` +- `valkyrie-sdk-figma-converter.mjs` +- `valkyrie-sdk-figma-converter.uninstantiated.mjs` +- `valkyrie-sdk-figma-converter.wasm` Then build-time injection inlines a Wasm bridge and exposes: diff --git a/tools/figma-plugin/package.json b/tools/figma-plugin/package.json index 49bba6c8e..8a333f2a2 100644 --- a/tools/figma-plugin/package.json +++ b/tools/figma-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build:converter": "../../gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs", + "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", diff --git a/tools/figma-plugin/scripts/build.mjs b/tools/figma-plugin/scripts/build.mjs index d5dcd60e7..544c0169f 100644 --- a/tools/figma-plugin/scripts/build.mjs +++ b/tools/figma-plugin/scripts/build.mjs @@ -1,5 +1,5 @@ import { build, context } from "esbuild"; -import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; const watch = process.argv.includes("--watch"); @@ -9,7 +9,7 @@ const distDir = resolve(root, "dist"); const repoRoot = resolve(root, "../.."); const converterDistDir = resolve( repoRoot, - "components/converter/figma/build/compileSync/wasmJs/main/productionExecutable/kotlin", + "sdk/figma/converter/build/compileSync/wasmJs/main/productionExecutable/kotlin", ); await mkdir(distDir, { recursive: true }); @@ -42,8 +42,8 @@ async function buildWasmBridgeScript() { let wasmBridgeScript = "window.ValkyrieFigmaWasmConverter = undefined;"; try { - const uninstantiatedPath = resolve(converterDistDir, "valkyrie-components-converter-figma.uninstantiated.mjs"); - const wasmPath = resolve(converterDistDir, "valkyrie-components-converter-figma.wasm"); + const uninstantiatedPath = resolve(converterDistDir, "valkyrie-sdk-figma-converter.uninstantiated.mjs"); + const wasmPath = resolve(converterDistDir, "valkyrie-sdk-figma-converter.wasm"); const uninstantiated = await readFile(uninstantiatedPath, "utf8"); const wasmBytes = await readFile(wasmPath); const wasmBase64 = wasmBytes.toString("base64"); @@ -51,7 +51,7 @@ async function buildWasmBridgeScript() { wasmBridgeScript = uninstantiated .replace("export async function instantiate", "async function instantiate") .replace( - "fetch(new URL('./valkyrie-components-converter-figma.wasm',import.meta.url).href)", + "fetch(new URL('./valkyrie-sdk-figma-converter.wasm',import.meta.url).href)", `fetch('data:application/wasm;base64,${wasmBase64}')`, ) .concat( @@ -60,7 +60,7 @@ async function buildWasmBridgeScript() { ); } catch { process.stderr.write( - "Converter artifacts missing. Run ../../gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs first.\n", + "Converter artifacts missing. Run ../../gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs first.\n", ); } @@ -119,17 +119,25 @@ if (watch) { } const converterFiles = [ + "valkyrie-sdk-figma-converter.mjs", + "valkyrie-sdk-figma-converter.uninstantiated.mjs", + "valkyrie-sdk-figma-converter.wasm", +]; + +const staleConverterFiles = [ "valkyrie-components-converter-figma.mjs", "valkyrie-components-converter-figma.uninstantiated.mjs", "valkyrie-components-converter-figma.wasm", ]; +await Promise.all(staleConverterFiles.map((file) => rm(resolve(distDir, file), { force: true }))); + for (const file of converterFiles) { try { await cp(resolve(converterDistDir, file), resolve(distDir, file)); } catch { process.stderr.write( - `Missing converter artifact: ${file}. Run ../../gradlew :components:converter:figma:compileProductionExecutableKotlinWasmJs first.\n`, + `Missing converter artifact: ${file}. Run ../../gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs first.\n`, ); } } diff --git a/tools/figma-plugin/src/bulkActions.ts b/tools/figma-plugin/src/bulkActions.ts index e15b12e2e..e971b10ff 100644 --- a/tools/figma-plugin/src/bulkActions.ts +++ b/tools/figma-plugin/src/bulkActions.ts @@ -11,7 +11,7 @@ export function initializeBulkActions(): void { return; } - const text = successful.map((item) => `// ${item.fileName}\n${item.content}`).join("\n\n"); + const text = successful.map((item) => `// ${item.fileName}\n${item.code}`).join("\n\n"); const copied = await copyText(text); if (copied) { flashButton(copyAllButton, "Copied!"); @@ -29,7 +29,7 @@ export function initializeBulkActions(): void { const files: Record = {}; for (const result of successful) { - files[result.fileName] = strToU8(result.content); + files[result.fileName] = strToU8(result.code); } const zipped = zipSync(files, { level: 6 }); diff --git a/tools/figma-plugin/src/conversion.ts b/tools/figma-plugin/src/conversion.ts index 2dec5e44f..91671a89f 100644 --- a/tools/figma-plugin/src/conversion.ts +++ b/tools/figma-plugin/src/conversion.ts @@ -91,10 +91,10 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont if (seenExact.has(normalized)) { nextResults.push({ - ok: false, + success: false, iconName: normalized, fileName: "", - content: "", + code: "", error: `Duplicate icon name '${normalized}'.`, }); continue; @@ -102,10 +102,10 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont if (seenInsensitive.has(lowered)) { nextResults.push({ - ok: false, + success: false, iconName: normalized, fileName: "", - content: "", + code: "", error: `Case-insensitive collision for '${normalized}'.`, }); continue; @@ -132,7 +132,7 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont renderResults(nextResults); updateBulkActionState(); - const successCount = nextResults.filter((item) => item.ok).length; + 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); diff --git a/tools/figma-plugin/src/converterAdapter.ts b/tools/figma-plugin/src/converterAdapter.ts index 6ef8f689a..7518082ad 100644 --- a/tools/figma-plugin/src/converterAdapter.ts +++ b/tools/figma-plugin/src/converterAdapter.ts @@ -12,10 +12,18 @@ export type ConvertOptions = { }; export type ConvertResult = { - ok: boolean; + success: boolean; iconName: string; fileName: string; - content: string; + code: string; + error?: string; +}; + +type WasmConvertResult = { + type: string; + iconName: string; + fileName?: string; + code?: string; error?: string; }; @@ -30,7 +38,7 @@ type WasmConverter = { useExplicitMode: boolean, usePathDataString: boolean, indentSize: number, - autoMirror: string, + autoMirror: boolean | null, ) => string; normalizeIconName: (iconName: string) => string; }; @@ -56,10 +64,10 @@ export function convert(svg: string, iconName: string, options: ConvertOptions): const converter = window.ValkyrieFigmaWasmConverter; if (!converter) { return { - ok: false, + success: false, iconName, fileName: "", - content: "", + code: "", error: "Wasm converter is not loaded. Run `pnpm build:all` in tools/figma-plugin and reload plugin.", }; } @@ -77,5 +85,28 @@ export function convert(svg: string, iconName: string, options: ConvertOptions): options.autoMirror, ); - return JSON.parse(json) as ConvertResult; + return toPluginConvertResult(JSON.parse(json) as WasmConvertResult); +} + +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" || type.endsWith(".Success"); } diff --git a/tools/figma-plugin/src/pluginSettings.ts b/tools/figma-plugin/src/pluginSettings.ts index 80491ae1e..aec2e049f 100644 --- a/tools/figma-plugin/src/pluginSettings.ts +++ b/tools/figma-plugin/src/pluginSettings.ts @@ -1,5 +1,5 @@ export type OutputFormat = "backing_property" | "lazy_property"; -export type AutoMirrorOption = "" | "true" | "false"; +export type AutoMirrorOption = boolean | null; export type PluginSettings = { packageName: string; @@ -19,7 +19,7 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { addTrailingComma: false, useExplicitMode: false, usePathDataString: false, - autoMirror: "", + autoMirror: null, autoExport: true, }; @@ -35,7 +35,12 @@ function asOutputFormat(value: unknown): OutputFormat | null { } function asAutoMirrorOption(value: unknown): AutoMirrorOption | null { - return value === "" || value === "true" || value === "false" ? value : 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 sanitizePluginSettings(value: unknown): PluginSettings | null { diff --git a/tools/figma-plugin/src/render.ts b/tools/figma-plugin/src/render.ts index 8f92b207e..e80355fcd 100644 --- a/tools/figma-plugin/src/render.ts +++ b/tools/figma-plugin/src/render.ts @@ -176,7 +176,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { emptyState.classList.add("hidden"); - const successfulCount = results.filter((result) => result.ok).length; + const successfulCount = results.filter((result) => result.success).length; const collapseByDefault = successfulCount >= LARGE_BATCH_COLLAPSE_THRESHOLD; const expanders: Array<{ expand: (renderCode: boolean) => void; collapse: () => void }> = []; const codeRenderers = new WeakMap void>(); @@ -257,7 +257,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { } for (const result of results) { - const resultKey = `${result.ok ? "ok" : "error"}:${result.iconName}:${result.fileName}`; + const resultKey = `${result.success ? "ok" : "error"}:${result.iconName}:${result.fileName}`; const card = document.createElement("section"); card.className = "result-card"; card.dataset.resultKey = resultKey; @@ -286,7 +286,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { title.textContent = result.iconName; info.appendChild(title); - if (result.ok && result.fileName) { + if (result.success && result.fileName) { const filename = document.createElement("span"); filename.className = "filename"; filename.textContent = result.fileName; @@ -297,7 +297,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { let codeWrapperForCard: HTMLDivElement | null = null; - if (result.ok) { + if (result.success) { const actions = document.createElement("div"); actions.className = "card-actions"; @@ -310,7 +310,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { const pre = document.createElement("pre"); const code = document.createElement("code"); - code.innerHTML = highlightKotlin(result.content); + code.innerHTML = highlightKotlin(result.code); pre.appendChild(code); codeWrapper.appendChild(pre); codeRendered = true; @@ -359,7 +359,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { copyButton.className = "card-btn"; copyButton.textContent = "Copy"; copyButton.addEventListener("click", async () => { - const copied = await copyText(result.content); + const copied = await copyText(result.code); if (copied) { flashButton(copyButton, "Copied!"); setStatus(`Copied ${result.fileName}.`); @@ -373,7 +373,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { downloadButton.className = "card-btn"; downloadButton.textContent = "Download"; downloadButton.addEventListener("click", () => { - const blob = new Blob([result.content], { type: "text/plain;charset=utf-8" }); + 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; @@ -428,7 +428,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { card.appendChild(codeWrapperForCard); } - if (!result.ok) { + if (!result.success) { const error = document.createElement("div"); error.className = "card-error"; error.textContent = result.error ?? "Unknown conversion error"; diff --git a/tools/figma-plugin/src/settings.ts b/tools/figma-plugin/src/settings.ts index 2d775b34a..4afd88955 100644 --- a/tools/figma-plugin/src/settings.ts +++ b/tools/figma-plugin/src/settings.ts @@ -21,11 +21,23 @@ export function getSettingsValues(): PluginSettings { addTrailingComma: addTrailingCommaInput.checked, useExplicitMode: useExplicitModeInput.checked, usePathDataString: usePathDataStringInput.checked, - autoMirror: autoMirrorInput.value as PluginSettings["autoMirror"], + autoMirror: parseAutoMirrorInput(autoMirrorInput.value), autoExport: autoExportInput.checked, }; } +function parseAutoMirrorInput(value: string): boolean | null { + if (value === "") return null; + if (value === "true") return true; + if (value === "false") return false; + return null; +} + +function autoMirrorToSelectValue(value: boolean | null): string { + if (value === null) return ""; + return value.toString(); +} + export function applySettings(settings: PluginSettings | null): void { const parsed = sanitizePluginSettings(settings); if (!parsed) { @@ -38,7 +50,7 @@ export function applySettings(settings: PluginSettings | null): void { addTrailingCommaInput.checked = parsed.addTrailingComma; useExplicitModeInput.checked = parsed.useExplicitMode; usePathDataStringInput.checked = parsed.usePathDataString; - autoMirrorInput.value = parsed.autoMirror; + autoMirrorInput.value = autoMirrorToSelectValue(parsed.autoMirror); autoExportInput.checked = parsed.autoExport; } @@ -66,6 +78,6 @@ export function getConvertOptions(): ConvertOptions { useExplicitMode: useExplicitModeInput.checked, usePathDataString: usePathDataStringInput.checked, indentSize: 4, - autoMirror: autoMirrorInput.value as ConvertOptions["autoMirror"], + autoMirror: parseAutoMirrorInput(autoMirrorInput.value), }; } diff --git a/tools/figma-plugin/src/state.ts b/tools/figma-plugin/src/state.ts index b1dc34188..f18ed694f 100644 --- a/tools/figma-plugin/src/state.ts +++ b/tools/figma-plugin/src/state.ts @@ -20,9 +20,9 @@ export function replaceConversionResults(results: ConvertResultWithSvg[]): void } export function hasSuccessfulConversionResults(): boolean { - return conversionResults.some((item) => item.ok); + return conversionResults.some((item) => item.success); } export function getSuccessfulConversionResults(): ConvertResultWithSvg[] { - return conversionResults.filter((item) => item.ok); + return conversionResults.filter((item) => item.success); } From 2600b4cd05eb29574380416e43abfa422dadabdd Mon Sep 17 00:00:00 2001 From: t-regbs Date: Tue, 3 Mar 2026 23:28:14 +0000 Subject: [PATCH 3/7] refactor(figma-plugin): streamline run lifecycle and remove cancel flow --- tools/figma-plugin/manifest.json | 20 ----- tools/figma-plugin/scripts/build.mjs | 26 +------ tools/figma-plugin/src/code.ts | 74 ++++--------------- tools/figma-plugin/src/dom.ts | 1 - tools/figma-plugin/src/messageHandlers.ts | 41 ++-------- tools/figma-plugin/src/messages.ts | 20 +---- tools/figma-plugin/src/render.ts | 52 ++++++++----- tools/figma-plugin/src/requestController.ts | 45 +++++------ tools/figma-plugin/src/runTerminalState.ts | 31 ++++++++ tools/figma-plugin/src/selectionController.ts | 64 ++++++++++------ tools/figma-plugin/src/ui.html | 1 - tools/figma-plugin/src/ui.ts | 28 ++----- 12 files changed, 156 insertions(+), 247 deletions(-) create mode 100644 tools/figma-plugin/src/runTerminalState.ts diff --git a/tools/figma-plugin/manifest.json b/tools/figma-plugin/manifest.json index 8fd386b53..542474fd3 100644 --- a/tools/figma-plugin/manifest.json +++ b/tools/figma-plugin/manifest.json @@ -4,25 +4,5 @@ "api": "1.0.0", "main": "dist/code.js", "ui": "dist/ui.html", - "menu": [ - { - "name": "Open exporter", - "command": "open-exporter" - }, - { - "name": "Re-export selection", - "command": "re-export" - } - ], - "relaunchButtons": [ - { - "name": "Open exporter", - "command": "open-exporter" - }, - { - "name": "Re-export", - "command": "re-export" - } - ], "editorType": ["figma"] } diff --git a/tools/figma-plugin/scripts/build.mjs b/tools/figma-plugin/scripts/build.mjs index 544c0169f..5ddfd9936 100644 --- a/tools/figma-plugin/scripts/build.mjs +++ b/tools/figma-plugin/scripts/build.mjs @@ -1,5 +1,5 @@ import { build, context } from "esbuild"; -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; const watch = process.argv.includes("--watch"); @@ -118,30 +118,6 @@ if (watch) { await Promise.all([build(codeConfig), build(uiConfig)]); } -const converterFiles = [ - "valkyrie-sdk-figma-converter.mjs", - "valkyrie-sdk-figma-converter.uninstantiated.mjs", - "valkyrie-sdk-figma-converter.wasm", -]; - -const staleConverterFiles = [ - "valkyrie-components-converter-figma.mjs", - "valkyrie-components-converter-figma.uninstantiated.mjs", - "valkyrie-components-converter-figma.wasm", -]; - -await Promise.all(staleConverterFiles.map((file) => rm(resolve(distDir, file), { force: true }))); - -for (const file of converterFiles) { - try { - await cp(resolve(converterDistDir, file), resolve(distDir, file)); - } catch { - process.stderr.write( - `Missing converter artifact: ${file}. Run ../../gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs first.\n`, - ); - } -} - if (!watch) { process.stdout.write("Built Figma plugin assets.\n"); } diff --git a/tools/figma-plugin/src/code.ts b/tools/figma-plugin/src/code.ts index 94f793758..ae2019658 100644 --- a/tools/figma-plugin/src/code.ts +++ b/tools/figma-plugin/src/code.ts @@ -14,26 +14,18 @@ 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; -const OPEN_EXPORTER_COMMAND = "open-exporter"; -const REEXPORT_COMMAND = "re-export"; -type LaunchCommand = typeof OPEN_EXPORTER_COMMAND | typeof REEXPORT_COMMAND; -const RELAUNCH_DATA: Record = { - [OPEN_EXPORTER_COMMAND]: "Open exporter", - [REEXPORT_COMMAND]: "Re-export with current settings", -}; - -type CancelReason = "user" | "superseded"; +const RELAUNCH_DATA = { + "open-exporter": "Open exporter", +} as const; type ActiveRun = { requestId: number; - token: { - cancelled: boolean; - reason: CancelReason | null; + supersedeToken: { + superseded: boolean; }; }; let activeRun: ActiveRun | null = null; -let pendingLaunchCommand: LaunchCommand = normalizeLaunchCommand(figma.command); figma.showUI(__html__, PLUGIN_UI_SIZE); @@ -58,22 +50,13 @@ function sendSelectionUpdate(): void { } figma.ui.onmessage = async (message: UiToMainMessage) => { - if (message.type === "close-plugin") { - figma.closePlugin(); - return; - } - if (message.type === "load-settings") { - const launchCommand = pendingLaunchCommand; - pendingLaunchCommand = OPEN_EXPORTER_COMMAND; - try { const savedSettings = await figma.clientStorage.getAsync(SETTINGS_KEY); const settings = sanitizePluginSettings(savedSettings); figma.ui.postMessage({ type: "settings-loaded", settings, - launchCommand, } satisfies SettingsLoadedMessage); } catch (error) { figma.ui.postMessage({ @@ -86,7 +69,6 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { figma.ui.postMessage({ type: "settings-loaded", settings: null, - launchCommand, } satisfies SettingsLoadedMessage); } @@ -107,34 +89,19 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { return; } - if (message.type === "request-selection") { - sendSelectionUpdate(); - return; - } - - if (message.type === "cancel-conversion") { - if (activeRun && activeRun.requestId === message.requestId) { - activeRun.token.cancelled = true; - activeRun.token.reason = "user"; - } - return; - } - if (message.type !== "run-conversion") { return; } const requestId = message.requestId; if (activeRun && activeRun.requestId !== requestId) { - activeRun.token.cancelled = true; - activeRun.token.reason = "superseded"; + activeRun.supersedeToken.superseded = true; } - const cancelToken: ActiveRun["token"] = { - cancelled: false, - reason: null, + const supersedeToken: ActiveRun["supersedeToken"] = { + superseded: false, }; - activeRun = { requestId, token: cancelToken }; + activeRun = { requestId, supersedeToken }; try { const selected = figma.currentPage.selection; @@ -161,15 +128,14 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { return; } - const { icons, firstError, failedCount, canceledReason } = await exportNodesAsSvg(exportableNodes, cancelToken); + const { icons, firstError, failedCount, superseded } = await exportNodesAsSvg(exportableNodes, supersedeToken); - if (canceledReason) { + if (superseded) { figma.ui.postMessage({ type: "conversion-ready", requestId, icons: [], - canceled: true, - canceledReason, + superseded: true, } satisfies ConversionReadyMessage); return; } @@ -234,8 +200,8 @@ function decodeUtf8(bytes: Uint8Array): string { async function exportNodesAsSvg( nodes: Array, - cancelToken: ActiveRun["token"], -): Promise<{ icons: ExportedIcon[]; firstError: string | null; failedCount: number; canceledReason: CancelReason | null }> { + 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; @@ -245,7 +211,7 @@ async function exportNodesAsSvg( const workers = Array.from({ length: workerCount }, async () => { while (nextIndex < nodes.length) { - if (cancelToken.cancelled) { + if (supersedeToken.superseded) { break; } @@ -272,7 +238,7 @@ async function exportNodesAsSvg( icons: icons.filter((icon): icon is ExportedIcon => icon !== null), firstError, failedCount, - canceledReason: cancelToken.cancelled ? cancelToken.reason ?? "user" : null, + superseded: supersedeToken.superseded, }; } @@ -286,11 +252,3 @@ function applyRelaunchData(nodes: Array, successfulIcon node.setRelaunchData(RELAUNCH_DATA); } } - -function normalizeLaunchCommand(command: string | undefined): LaunchCommand { - if (command === REEXPORT_COMMAND) { - return REEXPORT_COMMAND; - } - - return OPEN_EXPORTER_COMMAND; -} diff --git a/tools/figma-plugin/src/dom.ts b/tools/figma-plugin/src/dom.ts index a2be48344..9076d6810 100644 --- a/tools/figma-plugin/src/dom.ts +++ b/tools/figma-plugin/src/dom.ts @@ -1,5 +1,4 @@ export const runButton = document.querySelector("#run")!; -export const cancelButton = document.querySelector("#cancel")!; export const copyAllButton = document.querySelector("#copy-all")!; export const downloadAllButton = document.querySelector("#download-all")!; export const statusText = document.querySelector("#status")!; diff --git a/tools/figma-plugin/src/messageHandlers.ts b/tools/figma-plugin/src/messageHandlers.ts index da27fe85b..662ee5a76 100644 --- a/tools/figma-plugin/src/messageHandlers.ts +++ b/tools/figma-plugin/src/messageHandlers.ts @@ -1,24 +1,17 @@ import type { MainToUiMessage } from "./messages"; -import { getConversionResultsCount, getConversionResults } from "./state"; import { applySettings } from "./settings"; import { setStatus } from "./status"; -import { - isLoadingResultsVisible, - renderLoadingResults, - renderResults, - showCanceledEmptyState, - showLoadingEmptyState, -} from "./render"; +import { renderLoadingResults, showLoadingEmptyState } from "./render"; import { runConversion } from "./conversion"; +import { applyTerminalRunState } from "./runTerminalState"; type SelectionController = { handleSelectionChanged: (count: number, names: string[]) => void; - handleSettingsLoaded: (options?: { suppressAutoRun?: boolean }) => void; + handleSettingsLoaded: () => void; }; type RequestController = { - requestConversion: () => void; - isLatestRequest: (requestId: number) => boolean; + acknowledgeRequestStart: (requestId: number) => boolean; completeRequest: (requestId: number) => boolean; }; @@ -37,16 +30,12 @@ export function createMainMessageHandler(deps: MessageHandlerDeps): (message: Ma case "settings-loaded": { applySettings(message.settings); - const shouldReexport = message.launchCommand === "re-export"; - deps.selectionController.handleSettingsLoaded({ suppressAutoRun: shouldReexport }); - if (shouldReexport) { - deps.requestController.requestConversion(); - } + deps.selectionController.handleSettingsLoaded(); return; } case "conversion-started": { - if (!deps.requestController.isLatestRequest(message.requestId)) { + if (!deps.requestController.acknowledgeRequestStart(message.requestId)) { return; } renderLoadingResults(message.selectedCount); @@ -60,22 +49,8 @@ export function createMainMessageHandler(deps: MessageHandlerDeps): (message: Ma return; } - if (message.canceled) { - setStatus( - message.canceledReason === "superseded" - ? "Run superseded by a newer request." - : "Run canceled.", - "ready", - ); - - if (getConversionResultsCount() > 0) { - if (isLoadingResultsVisible()) { - renderResults(Array.from(getConversionResults())); - } - } else if (message.canceledReason === "user") { - renderResults([]); - showCanceledEmptyState(); - } + if (message.superseded) { + applyTerminalRunState("superseded"); return; } diff --git a/tools/figma-plugin/src/messages.ts b/tools/figma-plugin/src/messages.ts index 9609f21e5..7eea5b03a 100644 --- a/tools/figma-plugin/src/messages.ts +++ b/tools/figma-plugin/src/messages.ts @@ -19,8 +19,7 @@ export type ConversionReadyMessage = { error?: string; attemptedCount?: number; exportFailedCount?: number; - canceled?: boolean; - canceledReason?: "user" | "superseded"; + superseded?: boolean; }; export type SettingsErrorMessage = { @@ -37,7 +36,6 @@ export type SelectionChangedMessage = { export type SettingsLoadedMessage = { type: "settings-loaded"; settings: PluginSettings | null; - launchCommand?: "open-exporter" | "re-export"; }; export type MainToUiMessage = @@ -52,14 +50,6 @@ export type RunConversionMessage = { requestId: number; }; -export type RequestSelectionMessage = { - type: "request-selection"; -}; - -export type CloseMessage = { - type: "close-plugin"; -}; - export type SaveSettingsMessage = { type: "save-settings"; settings: PluginSettings; @@ -69,15 +59,7 @@ export type LoadSettingsMessage = { type: "load-settings"; }; -export type CancelConversionMessage = { - type: "cancel-conversion"; - requestId: number; -}; - export type UiToMainMessage = | RunConversionMessage - | CancelConversionMessage - | RequestSelectionMessage - | CloseMessage | SaveSettingsMessage | LoadSettingsMessage; diff --git a/tools/figma-plugin/src/render.ts b/tools/figma-plugin/src/render.ts index e80355fcd..67e94d28a 100644 --- a/tools/figma-plugin/src/render.ts +++ b/tools/figma-plugin/src/render.ts @@ -10,16 +10,16 @@ const EMPTY_TITLE_LOADING = "Generating code..."; const EMPTY_MESSAGE_LOADING = "Exporting your selected node(s)."; const EMPTY_TITLE_AUTO_EXPORT_OFF = "Auto export is off"; const EMPTY_MESSAGE_AUTO_EXPORT_OFF = "Select icon nodes and click Refresh to export."; -const EMPTY_TITLE_CANCELED = "Export canceled"; -const EMPTY_MESSAGE_CANCELED = "You canceled the current run. Click Refresh to start again."; const LARGE_BATCH_COLLAPSE_THRESHOLD = 20; -const CODE_RENDER_BATCH_SIZE = 10; +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 { @@ -54,14 +54,27 @@ function enqueueCodeRenderTask(task: () => void): void { 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(false); + expanders[index].expand(renderCodeOnExpand); } else { expanders[index].collapse(); } @@ -69,8 +82,11 @@ function applyExpanderBatch( } if (index < expanders.length) { - window.requestAnimationFrame(runBatch); + expanderBatchFrameId = window.requestAnimationFrame(runBatch); + return; } + + expanderBatchFrameId = null; }; runBatch(); @@ -133,6 +149,11 @@ export function updateSelectionPreview(count: number, names: string[]): void { 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(); @@ -177,6 +198,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { 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>(); @@ -218,19 +240,19 @@ export function renderResults(results: ConvertResultWithSvg[]): void { } }, { root: mainScroll, - rootMargin: "200px", + rootMargin: "1200px", threshold: 0, }) : null; activeCodeObserver = codeObserver; - if (collapseByDefault) { + if (showExpandControls) { const toolbar = document.createElement("div"); toolbar.className = "results-toolbar"; const label = document.createElement("span"); label.className = "results-toolbar-label"; - label.textContent = `Large batch (${successfulCount} files)`; + label.textContent = collapseByDefault ? `Large batch (${successfulCount} files)` : `${successfulCount} files`; toolbar.appendChild(label); const controls = document.createElement("div"); @@ -240,7 +262,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { expandAllButton.className = "card-btn"; expandAllButton.textContent = "Expand all"; expandAllButton.addEventListener("click", () => { - applyExpanderBatch(expanders, "expand"); + applyExpanderBatch(expanders, "expand", !codeObserver); }); controls.appendChild(expandAllButton); @@ -385,7 +407,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { actions.appendChild(downloadButton); let toggleButton: HTMLButtonElement | null = null; - if (collapseByDefault) { + if (showExpandControls) { toggleButton = document.createElement("button"); toggleButton.className = "card-btn"; toggleButton.textContent = "Expand"; @@ -410,8 +432,9 @@ export function renderResults(results: ConvertResultWithSvg[]): void { codeObserver.observe(codeWrapper); } - if (collapseByDefault) { - setExpanded(false, toggleButton, false); + if (showExpandControls) { + const startExpanded = !collapseByDefault; + setExpanded(startExpanded, toggleButton, startExpanded); expanders.push({ expand: (renderCode: boolean) => setExpanded(true, toggleButton, renderCode), collapse: () => setExpanded(false, toggleButton, false), @@ -466,11 +489,6 @@ export function showAutoExportDisabledEmptyState(): void { emptyStateDescription.textContent = EMPTY_MESSAGE_AUTO_EXPORT_OFF; } -export function showCanceledEmptyState(): void { - emptyStateTitle.textContent = EMPTY_TITLE_CANCELED; - emptyStateDescription.textContent = EMPTY_MESSAGE_CANCELED; -} - export function isLoadingResultsVisible(): boolean { return loadingResultsVisible; } diff --git a/tools/figma-plugin/src/requestController.ts b/tools/figma-plugin/src/requestController.ts index c3be2ae41..a9a440f49 100644 --- a/tools/figma-plugin/src/requestController.ts +++ b/tools/figma-plugin/src/requestController.ts @@ -4,19 +4,17 @@ import { setStatus } from "./status"; const REQUEST_TIMEOUT_MS = 5000; +type RequestState = "requested" | "started"; + type RequestControllerDeps = { - onRunningChanged?: (running: boolean) => void; + onTimedOut?: () => void; }; export function createRequestController(deps: RequestControllerDeps = {}) { let latestRequestId = 0; - let activeRequestId: number | null = null; + let activeRequest: { id: number; state: RequestState } | null = null; let pendingTimeoutId: number | null = null; - const setRunning = (running: boolean): void => { - deps.onRunningChanged?.(running); - }; - const clearPendingTimeout = (): void => { if (pendingTimeoutId !== null) { window.clearTimeout(pendingTimeoutId); @@ -31,11 +29,10 @@ export function createRequestController(deps: RequestControllerDeps = {}) { return { requestConversion(): void { - const hadActiveRequest = activeRequestId !== null; + const hadActiveRequest = activeRequest !== null; latestRequestId += 1; const requestId = latestRequestId; - activeRequestId = requestId; - setRunning(true); + activeRequest = { id: requestId, state: "requested" }; setStatus( hadActiveRequest @@ -45,42 +42,36 @@ export function createRequestController(deps: RequestControllerDeps = {}) { ); clearPendingTimeout(); setPendingTimeout(() => { - if (requestId !== latestRequestId) { + if (!activeRequest || activeRequest.id !== requestId || activeRequest.state !== "requested") { return; } - activeRequestId = null; - setRunning(false); - setStatus(formatPluginError(createTimeoutError()), "error"); + activeRequest = null; + if (deps.onTimedOut) { + deps.onTimedOut(); + } else { + setStatus(formatPluginError(createTimeoutError()), "error"); + } }, REQUEST_TIMEOUT_MS); sendMessage({ type: "run-conversion", requestId }); }, - cancelActiveRequest(): boolean { - if (activeRequestId === null) { + acknowledgeRequestStart(requestId: number): boolean { + if (!activeRequest || activeRequest.id !== requestId || activeRequest.state !== "requested") { return false; } - const requestId = activeRequestId; - activeRequestId = null; - setRunning(false); clearPendingTimeout(); - sendMessage({ type: "cancel-conversion", requestId }); - setStatus("Run canceled. Start a new export when ready.", "ready"); + activeRequest.state = "started"; return true; }, - isLatestRequest(requestId: number): boolean { - return requestId === latestRequestId; - }, - completeRequest(requestId: number): boolean { - if (requestId !== latestRequestId) { + if (!activeRequest || activeRequest.id !== requestId) { return false; } - activeRequestId = null; - setRunning(false); + activeRequest = null; clearPendingTimeout(); return true; }, diff --git a/tools/figma-plugin/src/runTerminalState.ts b/tools/figma-plugin/src/runTerminalState.ts new file mode 100644 index 000000000..73c0adc17 --- /dev/null +++ b/tools/figma-plugin/src/runTerminalState.ts @@ -0,0 +1,31 @@ +import { getConversionResults, getConversionResultsCount } from "./state"; +import { isLoadingResultsVisible, renderResults } from "./render"; +import { setStatus } from "./status"; +import { createTimeoutError, formatPluginError } from "./errorFormatter"; + +export type TerminalRunState = "superseded" | "timed-out"; + +function restorePreviousResultsWhenLoading(): void { + if (getConversionResultsCount() === 0) { + return; + } + + if (isLoadingResultsVisible()) { + renderResults(Array.from(getConversionResults())); + } +} + +export function applyTerminalRunState(state: TerminalRunState): void { + switch (state) { + case "superseded": { + setStatus("Run superseded by a newer request.", "ready"); + restorePreviousResultsWhenLoading(); + return; + } + + case "timed-out": { + setStatus(formatPluginError(createTimeoutError()), "error"); + return; + } + } +} diff --git a/tools/figma-plugin/src/selectionController.ts b/tools/figma-plugin/src/selectionController.ts index 4ffa5ac30..83ddbe0c3 100644 --- a/tools/figma-plugin/src/selectionController.ts +++ b/tools/figma-plugin/src/selectionController.ts @@ -9,9 +9,16 @@ type SelectionControllerDeps = { updateBulkActionState: () => void; }; -type SettingsLoadedOptions = { - suppressAutoRun?: boolean; -}; +type SelectionUiState = "default-empty" | "auto-export-disabled" | "auto-export-ready"; + +function showSelectionEmptyState(state: Exclude): void { + if (state === "auto-export-disabled") { + showAutoExportDisabledEmptyState(); + return; + } + + showDefaultEmptyState(); +} export function createSelectionController(deps: SelectionControllerDeps) { let autoRunTimeoutId: number | null = null; @@ -44,6 +51,18 @@ export function createSelectionController(deps: SelectionControllerDeps) { 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; @@ -54,18 +73,19 @@ export function createSelectionController(deps: SelectionControllerDeps) { clearConversionResults(); renderResults([]); deps.updateBulkActionState(); - showDefaultEmptyState(); + showSelectionEmptyState("default-empty"); return; } - if (!settingsInitialized) { - showDefaultEmptyState(); + const uiState = deriveSelectionUiState(); + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); return; } - if (!autoExportInput.checked) { + if (uiState === "auto-export-disabled") { scheduleAutoConversion(); - showAutoExportDisabledEmptyState(); + showSelectionEmptyState(uiState); return; } @@ -75,22 +95,18 @@ export function createSelectionController(deps: SelectionControllerDeps) { } }, - handleSettingsLoaded(options: SettingsLoadedOptions = {}): void { + handleSettingsLoaded(): void { settingsInitialized = true; - if (latestSelectionCount === 0) { - showDefaultEmptyState(); + const uiState = deriveSelectionUiState(); + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); return; } - if (!autoExportInput.checked) { + if (uiState === "auto-export-disabled") { scheduleAutoConversion(); - showAutoExportDisabledEmptyState(); - return; - } - - if (options.suppressAutoRun) { - showLoadingEmptyState(); + showSelectionEmptyState(uiState); return; } @@ -100,18 +116,20 @@ export function createSelectionController(deps: SelectionControllerDeps) { }, handleSettingsInputChanged(): void { - const isScheduled = scheduleAutoConversion(); + const uiState = deriveSelectionUiState(); - if (latestSelectionCount === 0) { - showDefaultEmptyState(); + if (uiState === "default-empty") { + showSelectionEmptyState(uiState); return; } - if (!autoExportInput.checked) { - showAutoExportDisabledEmptyState(); + if (uiState === "auto-export-disabled") { + scheduleAutoConversion(); + showSelectionEmptyState(uiState); return; } + const isScheduled = scheduleAutoConversion(); if (isScheduled) { showLoadingEmptyState(); } diff --git a/tools/figma-plugin/src/ui.html b/tools/figma-plugin/src/ui.html index ca83721ed..6b45ab81f 100644 --- a/tools/figma-plugin/src/ui.html +++ b/tools/figma-plugin/src/ui.html @@ -751,7 +751,6 @@

Valkyrie Export

-
diff --git a/tools/figma-plugin/src/ui.ts b/tools/figma-plugin/src/ui.ts index a19dd9d67..876fff448 100644 --- a/tools/figma-plugin/src/ui.ts +++ b/tools/figma-plugin/src/ui.ts @@ -1,17 +1,16 @@ -import { runButton, cancelButton } from "./dom"; -import { getConversionResultsCount, getConversionResults } from "./state"; +import { runButton } from "./dom"; import { addSettingsInputListeners, initSettingsListeners } from "./settings"; -import { sendMessage, onMessage, onError } from "./api"; +import { onMessage, onError } from "./api"; import { setStatus } from "./status"; import { initializeBulkActions, updateBulkActionState } from "./bulkActions"; import { createSelectionController } from "./selectionController"; import { createRequestController } from "./requestController"; import { createMainMessageHandler } from "./messageHandlers"; -import { renderResults, showCanceledEmptyState } from "./render"; +import { applyTerminalRunState } from "./runTerminalState"; const requestController = createRequestController({ - onRunningChanged: (running) => { - cancelButton.disabled = !running; + onTimedOut: () => { + applyTerminalRunState("timed-out"); }, }); @@ -31,21 +30,6 @@ runButton.addEventListener("click", () => { requestController.requestConversion(); }); -cancelButton.addEventListener("click", () => { - const canceled = requestController.cancelActiveRequest(); - if (!canceled) { - return; - } - - if (getConversionResultsCount() > 0) { - renderResults(Array.from(getConversionResults())); - return; - } - - renderResults([]); - showCanceledEmptyState(); -}); - addSettingsInputListeners(() => { selectionController.handleSettingsInputChanged(); }); @@ -60,5 +44,3 @@ onMessage( onError((event) => { setStatus(`UI error: ${event.message}`, "error"); }); - -sendMessage({ type: "request-selection" }); From 064dec70705c74f017ce1b8131bf21575e5beebc Mon Sep 17 00:00:00 2001 From: t-regbs Date: Tue, 3 Mar 2026 23:28:28 +0000 Subject: [PATCH 4/7] refactor(figma-plugin): organize src boundaries and finalize cleanup --- .../imagevector/render/PathNodeRenderer.kt | 45 ------------ .../imagevector/util/ImageVectorRenderer.kt | 4 +- .../kmp/imagevector/util/NodeParams.kt | 4 +- sdk/figma/converter/api/converter.api | 70 ++++++++++++++++++ sdk/figma/converter/api/converter.klib.api | 71 +++++++++++++++++++ sdk/figma/converter/build.gradle.kts | 4 +- .../converter/figma/FigmaConverter.kt | 4 +- .../converter/figma/FigmaConverterTest.kt | 1 + tools/figma-plugin/README.md | 12 ++-- tools/figma-plugin/package.json | 2 +- tools/figma-plugin/scripts/build.mjs | 8 ++- tools/figma-plugin/src/{ => main}/code.ts | 26 ++----- .../src/{ => shared}/errorFormatter.ts | 0 .../figma-plugin/src/{ => shared}/messages.ts | 0 .../src/{ => shared}/pluginSettings.ts | 9 ++- .../{ => ui/controllers}/messageHandlers.ts | 16 ++--- .../{ => ui/controllers}/requestController.ts | 6 +- .../controllers/runLifecycleState.ts} | 12 ++-- .../controllers}/selectionController.ts | 13 +++- tools/figma-plugin/src/{ => ui/core}/api.ts | 2 +- tools/figma-plugin/src/{ => ui/core}/dom.ts | 0 tools/figma-plugin/src/{ => ui/core}/state.ts | 0 .../figma-plugin/src/{ => ui/core}/status.ts | 0 tools/figma-plugin/src/{ => ui/core}/types.ts | 2 +- tools/figma-plugin/src/{ => ui/core}/utils.ts | 0 .../src/{ => ui/features}/bulkActions.ts | 8 +-- .../src/{ => ui/features}/conversion.ts | 14 ++-- .../src/{ => ui/features}/converterAdapter.ts | 2 +- .../src/{ => ui/features}/highlight.ts | 2 +- .../src/{ => ui/features}/render.ts | 18 ++--- .../src/{ => ui/features}/settings.ts | 26 ++----- tools/figma-plugin/src/{ => ui}/ui.html | 8 +-- tools/figma-plugin/src/{ => ui}/ui.ts | 20 +++--- 33 files changed, 246 insertions(+), 163 deletions(-) delete mode 100644 components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt rename tools/figma-plugin/src/{ => main}/code.ts (90%) rename tools/figma-plugin/src/{ => shared}/errorFormatter.ts (100%) rename tools/figma-plugin/src/{ => shared}/messages.ts (100%) rename tools/figma-plugin/src/{ => shared}/pluginSettings.ts (87%) rename tools/figma-plugin/src/{ => ui/controllers}/messageHandlers.ts (77%) rename tools/figma-plugin/src/{ => ui/controllers}/requestController.ts (92%) rename tools/figma-plugin/src/{runTerminalState.ts => ui/controllers/runLifecycleState.ts} (61%) rename tools/figma-plugin/src/{ => ui/controllers}/selectionController.ts (91%) rename tools/figma-plugin/src/{ => ui/core}/api.ts (88%) rename tools/figma-plugin/src/{ => ui/core}/dom.ts (100%) rename tools/figma-plugin/src/{ => ui/core}/state.ts (100%) rename tools/figma-plugin/src/{ => ui/core}/status.ts (100%) rename tools/figma-plugin/src/{ => ui/core}/types.ts (67%) rename tools/figma-plugin/src/{ => ui/core}/utils.ts (100%) rename tools/figma-plugin/src/{ => ui/features}/bulkActions.ts (89%) rename tools/figma-plugin/src/{ => ui/features}/conversion.ts (91%) rename tools/figma-plugin/src/{ => ui/features}/converterAdapter.ts (96%) rename tools/figma-plugin/src/{ => ui/features}/highlight.ts (98%) rename tools/figma-plugin/src/{ => ui/features}/render.ts (96%) rename tools/figma-plugin/src/{ => ui/features}/settings.ts (77%) rename tools/figma-plugin/src/{ => ui}/ui.html (99%) rename tools/figma-plugin/src/{ => ui}/ui.ts (61%) diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt deleted file mode 100644 index db5ac6da6..000000000 --- a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/render/PathNodeRenderer.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.composegears.valkyrie.generator.kmp.imagevector.render - -import io.github.composegears.valkyrie.generator.core.formatFloat -import io.github.composegears.valkyrie.sdk.ir.core.IrPathNode -import io.github.composegears.valkyrie.sdk.ir.core.toPathString - -internal fun IrPathNode.asStatement(): String = when (this) { - is IrPathNode.Close -> "close()" - is IrPathNode.RelativeMoveTo -> "moveToRelative(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.MoveTo -> "moveTo(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.RelativeLineTo -> "lineToRelative(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.LineTo -> "lineTo(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.RelativeHorizontalTo -> "horizontalLineToRelative(${x.formatFloat()})" - is IrPathNode.HorizontalTo -> "horizontalLineTo(${x.formatFloat()})" - is IrPathNode.RelativeVerticalTo -> "verticalLineToRelative(${y.formatFloat()})" - is IrPathNode.VerticalTo -> "verticalLineTo(${y.formatFloat()})" - is IrPathNode.RelativeCurveTo -> { - "curveToRelative(${dx1.formatFloat()}, ${dy1.formatFloat()}, ${dx2.formatFloat()}, ${dy2.formatFloat()}, ${dx3.formatFloat()}, ${dy3.formatFloat()})" - } - is IrPathNode.CurveTo -> { - "curveTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()}, ${x3.formatFloat()}, ${y3.formatFloat()})" - } - is IrPathNode.RelativeReflectiveCurveTo -> { - "reflectiveCurveToRelative(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" - } - is IrPathNode.ReflectiveCurveTo -> { - "reflectiveCurveTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" - } - is IrPathNode.RelativeQuadTo -> { - "quadToRelative(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" - } - is IrPathNode.QuadTo -> { - "quadTo(${x1.formatFloat()}, ${y1.formatFloat()}, ${x2.formatFloat()}, ${y2.formatFloat()})" - } - is IrPathNode.RelativeReflectiveQuadTo -> "reflectiveQuadToRelative(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.ReflectiveQuadTo -> "reflectiveQuadTo(${x.formatFloat()}, ${y.formatFloat()})" - is IrPathNode.RelativeArcTo -> { - "arcToRelative(${horizontalEllipseRadius.formatFloat()}, ${verticalEllipseRadius.formatFloat()}, ${theta.formatFloat()}, isMoreThanHalf = $isMoreThanHalf, isPositiveArc = $isPositiveArc, ${arcStartDx.formatFloat()}, ${arcStartDy.formatFloat()})" - } - is IrPathNode.ArcTo -> { - "arcTo(${horizontalEllipseRadius.formatFloat()}, ${verticalEllipseRadius.formatFloat()}, ${theta.formatFloat()}, isMoreThanHalf = $isMoreThanHalf, isPositiveArc = $isPositiveArc, ${arcStartX.formatFloat()}, ${arcStartY.formatFloat()})" - } -} - -internal fun List.asPathDataString(): String = toPathString() diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt index da577731d..47b157e42 100644 --- a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt +++ b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/ImageVectorRenderer.kt @@ -1,10 +1,10 @@ package io.github.composegears.valkyrie.generator.kmp.imagevector.util +import io.github.composegears.valkyrie.generator.core.asPathDataString +import io.github.composegears.valkyrie.generator.core.asStatement import io.github.composegears.valkyrie.generator.core.formatFloat import io.github.composegears.valkyrie.generator.core.trimTrailingZero import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorRenderConfig -import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asPathDataString -import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asStatement import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolveIconBuilderName import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolvePackageName import io.github.composegears.valkyrie.generator.kmp.imagevector.render.resolveReceiverName diff --git a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt index f8f34770d..ff4a7d167 100644 --- a/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt +++ b/components/generator/kmp/imagevector/src/commonMain/kotlin/io/github/composegears/valkyrie/generator/kmp/imagevector/util/NodeParams.kt @@ -1,9 +1,9 @@ package io.github.composegears.valkyrie.generator.kmp.imagevector.util +import io.github.composegears.valkyrie.generator.core.asPathDataString +import io.github.composegears.valkyrie.generator.core.asStatement import io.github.composegears.valkyrie.generator.core.formatFloat import io.github.composegears.valkyrie.generator.kmp.imagevector.ImageVectorRenderConfig -import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asPathDataString -import io.github.composegears.valkyrie.generator.kmp.imagevector.render.asStatement import io.github.composegears.valkyrie.sdk.ir.core.IrColor import io.github.composegears.valkyrie.sdk.ir.core.IrFill import io.github.composegears.valkyrie.sdk.ir.core.IrPathFillType diff --git a/sdk/figma/converter/api/converter.api b/sdk/figma/converter/api/converter.api index bcb23c4be..b24cc36c9 100644 --- a/sdk/figma/converter/api/converter.api +++ b/sdk/figma/converter/api/converter.api @@ -1,5 +1,75 @@ +public abstract interface class io/github/composegears/valkyrie/converter/figma/ConverterResult { + public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Companion; + public abstract fun getIconName ()Ljava/lang/String; +} + +public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error : io/github/composegears/valkyrie/converter/figma/ConverterResult { + public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; + public static synthetic fun copy$default (Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Ljava/lang/String; + public fun getIconName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Error;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Error$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success : io/github/composegears/valkyrie/converter/figma/ConverterResult { + public static final field Companion Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; + public static synthetic fun copy$default (Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getCode ()Ljava/lang/String; + public final fun getFileName ()Ljava/lang/String; + public fun getIconName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/github/composegears/valkyrie/converter/figma/ConverterResult$Success;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/github/composegears/valkyrie/converter/figma/ConverterResult$Success$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/github/composegears/valkyrie/converter/figma/FigmaConverterKt { public static final fun convertSvg (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/Boolean;)Ljava/lang/String; public static synthetic fun convertSvg$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZZILjava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/String; public static final fun normalizeIconName (Ljava/lang/String;)Ljava/lang/String; } + diff --git a/sdk/figma/converter/api/converter.klib.api b/sdk/figma/converter/api/converter.klib.api index 592c12367..3d0adc894 100644 --- a/sdk/figma/converter/api/converter.klib.api +++ b/sdk/figma/converter/api/converter.klib.api @@ -6,5 +6,76 @@ // - Show declarations: true // Library unique name: +sealed interface io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult|null[0] + abstract val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.iconName|{}iconName[0] + abstract fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.iconName.|(){}[0] + + final class Error : io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error|null[0] + constructor (kotlin/String, kotlin/String) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.|(kotlin.String;kotlin.String){}[0] + + final val error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.error|{}error[0] + final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.error.|(){}[0] + final val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.iconName|{}iconName[0] + final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.iconName.|(){}[0] + + final fun component1(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.component1|component1(){}[0] + final fun component2(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.component2|component2(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ...): io.github.composegears.valkyrie.converter.figma/ConverterResult.Error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.copy|copy(kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer|null[0] + final val descriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): io.github.composegears.valkyrie.converter.figma/ConverterResult.Error // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, io.github.composegears.valkyrie.converter.figma/ConverterResult.Error) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;io.github.composegears.valkyrie.converter.figma.ConverterResult.Error){}[0] + } + + final object Companion { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Error.Companion.serializer|serializer(){}[0] + } + } + + final class Success : io.github.composegears.valkyrie.converter.figma/ConverterResult { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.|(kotlin.String;kotlin.String;kotlin.String){}[0] + + final val code // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.code|{}code[0] + final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.code.|(){}[0] + final val fileName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.fileName|{}fileName[0] + final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.fileName.|(){}[0] + final val iconName // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.iconName|{}iconName[0] + final fun (): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.iconName.|(){}[0] + + final fun component1(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component1|component1(){}[0] + final fun component2(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component2|component2(){}[0] + final fun component3(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ...): io.github.composegears.valkyrie.converter.figma/ConverterResult.Success // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.copy|copy(kotlin.String;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer|null[0] + final val descriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): io.github.composegears.valkyrie.converter.figma/ConverterResult.Success // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, io.github.composegears.valkyrie.converter.figma/ConverterResult.Success) // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;io.github.composegears.valkyrie.converter.figma.ConverterResult.Success){}[0] + } + + final object Companion { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Success.Companion.serializer|serializer(){}[0] + } + } + + final object Companion : kotlinx.serialization.internal/SerializerFactory { // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion.serializer|serializer(){}[0] + final fun serializer(kotlin/Array>...): kotlinx.serialization/KSerializer<*> // io.github.composegears.valkyrie.converter.figma/ConverterResult.Companion.serializer|serializer(kotlin.Array>...){}[0] + } +} + final fun io.github.composegears.valkyrie.converter.figma/convertSvg(kotlin/String, kotlin/String, kotlin/String, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/Boolean? = ...): kotlin/String // io.github.composegears.valkyrie.converter.figma/convertSvg|convertSvg(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Int;kotlin.Boolean?){}[0] final fun io.github.composegears.valkyrie.converter.figma/normalizeIconName(kotlin/String): kotlin/String // io.github.composegears.valkyrie.converter.figma/normalizeIconName|normalizeIconName(kotlin.String){}[0] diff --git a/sdk/figma/converter/build.gradle.kts b/sdk/figma/converter/build.gradle.kts index e58c68e62..efa3ae90d 100644 --- a/sdk/figma/converter/build.gradle.kts +++ b/sdk/figma/converter/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + plugins { alias(libs.plugins.valkyrie.kmp) alias(libs.plugins.valkyrie.abi) @@ -6,7 +8,7 @@ plugins { } kotlin { - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + @OptIn(ExperimentalWasmDsl::class) wasmJs { binaries.executable() } diff --git a/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt b/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt index 5d6be42c3..7981c2da8 100644 --- a/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt +++ b/sdk/figma/converter/src/commonMain/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverter.kt @@ -83,8 +83,8 @@ private fun resolveOutputFormat(outputFormat: String): OutputFormat = when (outp OutputFormat.BackingProperty.key -> OutputFormat.BackingProperty OutputFormat.LazyProperty.key -> OutputFormat.LazyProperty else -> throw IllegalArgumentException( - "Unsupported outputFormat '$outputFormat'. Supported values: " + - "'${OutputFormat.BackingProperty.key}', '${OutputFormat.LazyProperty.key}'.", + "Unsupported outputFormat '$outputFormat'. Supported values: " + + "'${OutputFormat.BackingProperty.key}', '${OutputFormat.LazyProperty.key}'.", ) } diff --git a/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt b/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt index 9ae3c8fb8..602f4430f 100644 --- a/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt +++ b/sdk/figma/converter/src/commonTest/kotlin/io/github/composegears/valkyrie/converter/figma/FigmaConverterTest.kt @@ -79,5 +79,6 @@ class FigmaConverterTest { assertThat(error.error).contains("Unsupported outputFormat") assertThat(error.iconName).isEqualTo("ic_test_icon") } + private fun String.jsonType(): String = Json.parseToJsonElement(this).jsonObject.getValue("type").jsonPrimitive.content } diff --git a/tools/figma-plugin/README.md b/tools/figma-plugin/README.md index e86b40e62..a958369d6 100644 --- a/tools/figma-plugin/README.md +++ b/tools/figma-plugin/README.md @@ -1,4 +1,4 @@ -# Valkyrie Figma Plugin (Simple Mode) +# Valkyrie Figma Plugin This package contains a Figma plugin shell for exporting selected icons into Kotlin `ImageVector` source. @@ -28,9 +28,13 @@ This package contains a Figma plugin shell for exporting selected icons into Kot ## Files - `manifest.json` - Figma plugin manifest -- `src/code.ts` - plugin main thread (selection and SVG export) -- `src/ui.ts` - plugin UI logic (conversion and result rendering) -- `src/converterAdapter.ts` - runtime bridge to Wasm converter +- `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 diff --git a/tools/figma-plugin/package.json b/tools/figma-plugin/package.json index 8a333f2a2..6d9c7a83a 100644 --- a/tools/figma-plugin/package.json +++ b/tools/figma-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@composegears/valkyrie-figma-plugin", - "version": "0.0.1", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/tools/figma-plugin/scripts/build.mjs b/tools/figma-plugin/scripts/build.mjs index 5ddfd9936..9838ad531 100644 --- a/tools/figma-plugin/scripts/build.mjs +++ b/tools/figma-plugin/scripts/build.mjs @@ -5,6 +5,8 @@ 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( @@ -26,13 +28,13 @@ const codeConfig = { // Figma's plugin sandbox uses a limited JS engine — target ES2017 to // ensure operators like ?? and ?. are compiled down. target: "es2017", - entryPoints: [resolve(srcDir, "code.ts")], + entryPoints: [resolve(mainDir, "code.ts")], outfile: resolve(distDir, "code.js"), format: "iife", platform: "browser", }; -const srcUiHtmlPath = resolve(srcDir, "ui.html"); +const srcUiHtmlPath = resolve(uiDir, "ui.html"); const distUiJsPath = resolve(distDir, "ui.js"); const distUiHtmlPath = resolve(distDir, "ui.html"); @@ -84,7 +86,7 @@ async function writeInlinedUiHtml() { const uiConfig = { ...sharedOptions, - entryPoints: [resolve(srcDir, "ui.ts")], + entryPoints: [resolve(uiDir, "ui.ts")], outfile: resolve(distDir, "ui.js"), format: "iife", platform: "browser", diff --git a/tools/figma-plugin/src/code.ts b/tools/figma-plugin/src/main/code.ts similarity index 90% rename from tools/figma-plugin/src/code.ts rename to tools/figma-plugin/src/main/code.ts index ae2019658..f55eee4b7 100644 --- a/tools/figma-plugin/src/code.ts +++ b/tools/figma-plugin/src/main/code.ts @@ -6,17 +6,14 @@ import type { SettingsErrorMessage, SettingsLoadedMessage, UiToMainMessage, -} from "./messages"; -import { createExportError, createInternalError, createSelectionError, formatPluginError } from "./errorFormatter"; -import { sanitizePluginSettings } from "./pluginSettings"; +} 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; -const RELAUNCH_DATA = { - "open-exporter": "Open exporter", -} as const; type ActiveRun = { requestId: number; @@ -120,8 +117,8 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { icons: [], error: formatPluginError( createSelectionError( - "Select at least one exportable node.", - "Pick icon nodes in the canvas and run export again.", + "Select at least one exportable icon.", + "Pick icons in the canvas and run export again.", ), ), } satisfies ConversionReadyMessage); @@ -140,8 +137,6 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { return; } - applyRelaunchData(exportableNodes, icons); - if (failedCount > 0 && icons.length > 0) { figma.notify(firstError ?? `Some icons failed to export (${failedCount}).`); figma.ui.postMessage({ @@ -241,14 +236,3 @@ async function exportNodesAsSvg( superseded: supersedeToken.superseded, }; } - -function applyRelaunchData(nodes: Array, successfulIcons: ExportedIcon[]): void { - const successfulIds = new Set(successfulIcons.map((icon) => icon.id)); - for (const node of nodes) { - if (!successfulIds.has(node.id)) { - continue; - } - - node.setRelaunchData(RELAUNCH_DATA); - } -} diff --git a/tools/figma-plugin/src/errorFormatter.ts b/tools/figma-plugin/src/shared/errorFormatter.ts similarity index 100% rename from tools/figma-plugin/src/errorFormatter.ts rename to tools/figma-plugin/src/shared/errorFormatter.ts diff --git a/tools/figma-plugin/src/messages.ts b/tools/figma-plugin/src/shared/messages.ts similarity index 100% rename from tools/figma-plugin/src/messages.ts rename to tools/figma-plugin/src/shared/messages.ts diff --git a/tools/figma-plugin/src/pluginSettings.ts b/tools/figma-plugin/src/shared/pluginSettings.ts similarity index 87% rename from tools/figma-plugin/src/pluginSettings.ts rename to tools/figma-plugin/src/shared/pluginSettings.ts index aec2e049f..f5f481e51 100644 --- a/tools/figma-plugin/src/pluginSettings.ts +++ b/tools/figma-plugin/src/shared/pluginSettings.ts @@ -34,7 +34,7 @@ function asOutputFormat(value: unknown): OutputFormat | null { return value === "backing_property" || value === "lazy_property" ? value : null; } -function asAutoMirrorOption(value: unknown): AutoMirrorOption | 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; @@ -43,6 +43,11 @@ function asAutoMirrorOption(value: unknown): AutoMirrorOption | null { 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) { @@ -68,7 +73,7 @@ export function sanitizePluginSettings(value: unknown): PluginSettings | null { typeof raw.usePathDataString === "boolean" ? raw.usePathDataString : DEFAULT_PLUGIN_SETTINGS.usePathDataString, - autoMirror: asAutoMirrorOption(raw.autoMirror) ?? DEFAULT_PLUGIN_SETTINGS.autoMirror, + 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/messageHandlers.ts b/tools/figma-plugin/src/ui/controllers/messageHandlers.ts similarity index 77% rename from tools/figma-plugin/src/messageHandlers.ts rename to tools/figma-plugin/src/ui/controllers/messageHandlers.ts index 662ee5a76..f1333e03c 100644 --- a/tools/figma-plugin/src/messageHandlers.ts +++ b/tools/figma-plugin/src/ui/controllers/messageHandlers.ts @@ -1,9 +1,9 @@ -import type { MainToUiMessage } from "./messages"; -import { applySettings } from "./settings"; -import { setStatus } from "./status"; -import { renderLoadingResults, showLoadingEmptyState } from "./render"; -import { runConversion } from "./conversion"; -import { applyTerminalRunState } from "./runTerminalState"; +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; @@ -40,7 +40,7 @@ export function createMainMessageHandler(deps: MessageHandlerDeps): (message: Ma } renderLoadingResults(message.selectedCount); showLoadingEmptyState(); - setStatus(`Exporting ${message.selectedCount} selected node(s)...`, "working"); + setStatus(`Exporting ${message.selectedCount} selected icon(s)...`, "working"); return; } @@ -50,7 +50,7 @@ export function createMainMessageHandler(deps: MessageHandlerDeps): (message: Ma } if (message.superseded) { - applyTerminalRunState("superseded"); + applyRunLifecycleState("superseded"); return; } diff --git a/tools/figma-plugin/src/requestController.ts b/tools/figma-plugin/src/ui/controllers/requestController.ts similarity index 92% rename from tools/figma-plugin/src/requestController.ts rename to tools/figma-plugin/src/ui/controllers/requestController.ts index a9a440f49..6c95f490a 100644 --- a/tools/figma-plugin/src/requestController.ts +++ b/tools/figma-plugin/src/ui/controllers/requestController.ts @@ -1,6 +1,6 @@ -import { sendMessage } from "./api"; -import { createTimeoutError, formatPluginError } from "./errorFormatter"; -import { setStatus } from "./status"; +import { sendMessage } from "../core/api"; +import { createTimeoutError, formatPluginError } from "../../shared/errorFormatter"; +import { setStatus } from "../core/status"; const REQUEST_TIMEOUT_MS = 5000; diff --git a/tools/figma-plugin/src/runTerminalState.ts b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts similarity index 61% rename from tools/figma-plugin/src/runTerminalState.ts rename to tools/figma-plugin/src/ui/controllers/runLifecycleState.ts index 73c0adc17..9f27b2ac4 100644 --- a/tools/figma-plugin/src/runTerminalState.ts +++ b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts @@ -1,9 +1,9 @@ -import { getConversionResults, getConversionResultsCount } from "./state"; -import { isLoadingResultsVisible, renderResults } from "./render"; -import { setStatus } from "./status"; -import { createTimeoutError, formatPluginError } from "./errorFormatter"; +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 TerminalRunState = "superseded" | "timed-out"; +export type RunLifecycleState = "superseded" | "timed-out"; function restorePreviousResultsWhenLoading(): void { if (getConversionResultsCount() === 0) { @@ -15,7 +15,7 @@ function restorePreviousResultsWhenLoading(): void { } } -export function applyTerminalRunState(state: TerminalRunState): void { +export function applyRunLifecycleState(state: RunLifecycleState): void { switch (state) { case "superseded": { setStatus("Run superseded by a newer request.", "ready"); diff --git a/tools/figma-plugin/src/selectionController.ts b/tools/figma-plugin/src/ui/controllers/selectionController.ts similarity index 91% rename from tools/figma-plugin/src/selectionController.ts rename to tools/figma-plugin/src/ui/controllers/selectionController.ts index 83ddbe0c3..af84650a8 100644 --- a/tools/figma-plugin/src/selectionController.ts +++ b/tools/figma-plugin/src/ui/controllers/selectionController.ts @@ -1,6 +1,6 @@ -import { autoExportInput } from "./dom"; -import { renderResults, showAutoExportDisabledEmptyState, showDefaultEmptyState, showLoadingEmptyState, updateSelectionPreview } from "./render"; -import { clearConversionResults } from "./state"; +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; @@ -11,6 +11,10 @@ type SelectionControllerDeps = { 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(); @@ -66,6 +70,7 @@ export function createSelectionController(deps: SelectionControllerDeps) { return { handleSelectionChanged(count: number, names: string[]): void { latestSelectionCount = count; + updateRunButtonLabel(); updateSelectionPreview(count, names); if (count === 0) { @@ -97,6 +102,7 @@ export function createSelectionController(deps: SelectionControllerDeps) { handleSettingsLoaded(): void { settingsInitialized = true; + updateRunButtonLabel(); const uiState = deriveSelectionUiState(); if (uiState === "default-empty") { @@ -116,6 +122,7 @@ export function createSelectionController(deps: SelectionControllerDeps) { }, handleSettingsInputChanged(): void { + updateRunButtonLabel(); const uiState = deriveSelectionUiState(); if (uiState === "default-empty") { diff --git a/tools/figma-plugin/src/api.ts b/tools/figma-plugin/src/ui/core/api.ts similarity index 88% rename from tools/figma-plugin/src/api.ts rename to tools/figma-plugin/src/ui/core/api.ts index b152183a9..2838299d9 100644 --- a/tools/figma-plugin/src/api.ts +++ b/tools/figma-plugin/src/ui/core/api.ts @@ -1,4 +1,4 @@ -import type { MainToUiMessage, UiToMainMessage } from "./messages"; +import type { MainToUiMessage, UiToMainMessage } from "../../shared/messages"; export function sendMessage(message: UiToMainMessage): void { parent.postMessage({ pluginMessage: message }, "*"); diff --git a/tools/figma-plugin/src/dom.ts b/tools/figma-plugin/src/ui/core/dom.ts similarity index 100% rename from tools/figma-plugin/src/dom.ts rename to tools/figma-plugin/src/ui/core/dom.ts diff --git a/tools/figma-plugin/src/state.ts b/tools/figma-plugin/src/ui/core/state.ts similarity index 100% rename from tools/figma-plugin/src/state.ts rename to tools/figma-plugin/src/ui/core/state.ts diff --git a/tools/figma-plugin/src/status.ts b/tools/figma-plugin/src/ui/core/status.ts similarity index 100% rename from tools/figma-plugin/src/status.ts rename to tools/figma-plugin/src/ui/core/status.ts diff --git a/tools/figma-plugin/src/types.ts b/tools/figma-plugin/src/ui/core/types.ts similarity index 67% rename from tools/figma-plugin/src/types.ts rename to tools/figma-plugin/src/ui/core/types.ts index ae0d76798..b359d1a91 100644 --- a/tools/figma-plugin/src/types.ts +++ b/tools/figma-plugin/src/ui/core/types.ts @@ -1,4 +1,4 @@ -import type { ConvertResult } from "./converterAdapter"; +import type { ConvertResult } from "../features/converterAdapter"; export type ConvertResultWithSvg = ConvertResult & { svg?: string }; diff --git a/tools/figma-plugin/src/utils.ts b/tools/figma-plugin/src/ui/core/utils.ts similarity index 100% rename from tools/figma-plugin/src/utils.ts rename to tools/figma-plugin/src/ui/core/utils.ts diff --git a/tools/figma-plugin/src/bulkActions.ts b/tools/figma-plugin/src/ui/features/bulkActions.ts similarity index 89% rename from tools/figma-plugin/src/bulkActions.ts rename to tools/figma-plugin/src/ui/features/bulkActions.ts index e971b10ff..fd3190a71 100644 --- a/tools/figma-plugin/src/bulkActions.ts +++ b/tools/figma-plugin/src/ui/features/bulkActions.ts @@ -1,8 +1,8 @@ import { zipSync, strToU8 } from "fflate"; -import { copyAllButton, downloadAllButton } from "./dom"; -import { getSuccessfulConversionResults, hasSuccessfulConversionResults } from "./state"; -import { setStatus } from "./status"; -import { copyText, flashButton } from "./utils"; +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 () => { diff --git a/tools/figma-plugin/src/conversion.ts b/tools/figma-plugin/src/ui/features/conversion.ts similarity index 91% rename from tools/figma-plugin/src/conversion.ts rename to tools/figma-plugin/src/ui/features/conversion.ts index 91671a89f..cdb657542 100644 --- a/tools/figma-plugin/src/conversion.ts +++ b/tools/figma-plugin/src/ui/features/conversion.ts @@ -1,12 +1,12 @@ import { convert, isConverterReady, normalizeIconName } from "./converterAdapter"; -import { packageInput } from "./dom"; -import { createConverterUnavailableError, createSelectionError, createSettingsError, formatPluginError } from "./errorFormatter"; -import type { ExportedIcon } from "./messages"; +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 "./state"; -import { setStatus } from "./status"; -import type { ConvertResultWithSvg, StatusType } from "./types"; +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; @@ -59,7 +59,7 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont } else { setStatus( formatPluginError( - createSelectionError("No exportable selected icons.", "Select one or more exportable icon nodes in Figma and retry."), + createSelectionError("No exportable selected icons.", "Select one or more exportable icons in Figma and retry."), ), "error", ); diff --git a/tools/figma-plugin/src/converterAdapter.ts b/tools/figma-plugin/src/ui/features/converterAdapter.ts similarity index 96% rename from tools/figma-plugin/src/converterAdapter.ts rename to tools/figma-plugin/src/ui/features/converterAdapter.ts index 7518082ad..33c5b699e 100644 --- a/tools/figma-plugin/src/converterAdapter.ts +++ b/tools/figma-plugin/src/ui/features/converterAdapter.ts @@ -1,4 +1,4 @@ -import type { AutoMirrorOption, OutputFormat } from "./pluginSettings"; +import type { AutoMirrorOption, OutputFormat } from "../../shared/pluginSettings"; export type ConvertOptions = { packageName: string; diff --git a/tools/figma-plugin/src/highlight.ts b/tools/figma-plugin/src/ui/features/highlight.ts similarity index 98% rename from tools/figma-plugin/src/highlight.ts rename to tools/figma-plugin/src/ui/features/highlight.ts index 05c1ccedc..f0b443d62 100644 --- a/tools/figma-plugin/src/highlight.ts +++ b/tools/figma-plugin/src/ui/features/highlight.ts @@ -1,4 +1,4 @@ -import { escapeHtml } from "./utils"; +import { escapeHtml } from "../core/utils"; const KEYWORDS = new Set([ "package", diff --git a/tools/figma-plugin/src/render.ts b/tools/figma-plugin/src/ui/features/render.ts similarity index 96% rename from tools/figma-plugin/src/render.ts rename to tools/figma-plugin/src/ui/features/render.ts index 67e94d28a..f8777cf76 100644 --- a/tools/figma-plugin/src/render.ts +++ b/tools/figma-plugin/src/ui/features/render.ts @@ -1,15 +1,15 @@ -import type { ConvertResultWithSvg } from "./types"; -import { resultsContainer, emptyState, emptyStateTitle, emptyStateDescription, selectionPreview, mainScroll } from "./dom"; -import { escapeHtml, escapeAttr, copyText, flashButton, toBase64Utf8 } from "./utils"; -import { setStatus } from "./status"; +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 icon nodes in Figma to generate Kotlin ImageVector code automatically."; +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 node(s)."; +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 icon nodes and click Refresh to export."; +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; @@ -135,11 +135,11 @@ export function renderLoadingResults(selectedCount: number): void { export function updateSelectionPreview(count: number, names: string[]): void { if (count === 0) { - selectionPreview.innerHTML = `
No nodes selected
`; + selectionPreview.innerHTML = `
No icons selected
`; return; } - const label = count === 1 ? "node" : "nodes"; + const label = count === 1 ? "icon" : "icons"; const nameList = names.join(", ") + (count > names.length ? `, +${count - names.length} more` : ""); selectionPreview.innerHTML = diff --git a/tools/figma-plugin/src/settings.ts b/tools/figma-plugin/src/ui/features/settings.ts similarity index 77% rename from tools/figma-plugin/src/settings.ts rename to tools/figma-plugin/src/ui/features/settings.ts index 4afd88955..0d00c516b 100644 --- a/tools/figma-plugin/src/settings.ts +++ b/tools/figma-plugin/src/ui/features/settings.ts @@ -1,8 +1,8 @@ -import { packageInput, outputFormatInput, useComposeColorsInput, addTrailingCommaInput, useExplicitModeInput, usePathDataStringInput, autoMirrorInput, autoExportInput, settingsInputs } from "./dom"; +import { packageInput, outputFormatInput, useComposeColorsInput, addTrailingCommaInput, useExplicitModeInput, usePathDataStringInput, autoMirrorInput, autoExportInput, settingsInputs } from "../core/dom"; import type { ConvertOptions } from "./converterAdapter"; -import { sendMessage } from "./api"; -import type { PluginSettings } from "./pluginSettings"; -import { sanitizePluginSettings } from "./pluginSettings"; +import { sendMessage } from "../core/api"; +import type { PluginSettings } from "../../shared/pluginSettings"; +import { autoMirrorOptionToSelectValue, parseAutoMirrorOption, sanitizePluginSettings } from "../../shared/pluginSettings"; let saveSettingsTimeoutId: number | null = null; @@ -21,23 +21,11 @@ export function getSettingsValues(): PluginSettings { addTrailingComma: addTrailingCommaInput.checked, useExplicitMode: useExplicitModeInput.checked, usePathDataString: usePathDataStringInput.checked, - autoMirror: parseAutoMirrorInput(autoMirrorInput.value), + autoMirror: parseAutoMirrorOption(autoMirrorInput.value), autoExport: autoExportInput.checked, }; } -function parseAutoMirrorInput(value: string): boolean | null { - if (value === "") return null; - if (value === "true") return true; - if (value === "false") return false; - return null; -} - -function autoMirrorToSelectValue(value: boolean | null): string { - if (value === null) return ""; - return value.toString(); -} - export function applySettings(settings: PluginSettings | null): void { const parsed = sanitizePluginSettings(settings); if (!parsed) { @@ -50,7 +38,7 @@ export function applySettings(settings: PluginSettings | null): void { addTrailingCommaInput.checked = parsed.addTrailingComma; useExplicitModeInput.checked = parsed.useExplicitMode; usePathDataStringInput.checked = parsed.usePathDataString; - autoMirrorInput.value = autoMirrorToSelectValue(parsed.autoMirror); + autoMirrorInput.value = autoMirrorOptionToSelectValue(parsed.autoMirror); autoExportInput.checked = parsed.autoExport; } @@ -78,6 +66,6 @@ export function getConvertOptions(): ConvertOptions { useExplicitMode: useExplicitModeInput.checked, usePathDataString: usePathDataStringInput.checked, indentSize: 4, - autoMirror: parseAutoMirrorInput(autoMirrorInput.value), + autoMirror: parseAutoMirrorOption(autoMirrorInput.value), }; } diff --git a/tools/figma-plugin/src/ui.html b/tools/figma-plugin/src/ui/ui.html similarity index 99% rename from tools/figma-plugin/src/ui.html rename to tools/figma-plugin/src/ui/ui.html index 6b45ab81f..7dc45edce 100644 --- a/tools/figma-plugin/src/ui.html +++ b/tools/figma-plugin/src/ui/ui.html @@ -342,12 +342,6 @@ cursor: not-allowed; } - .btn-icon { - display: inline-flex; - align-items: center; - gap: 4px; - } - /* === Status bar === */ .status-bar { border-top: 1px solid var(--border); @@ -707,7 +701,7 @@

Valkyrie Export

-
No nodes selected
+
No icons selected
diff --git a/tools/figma-plugin/src/ui.ts b/tools/figma-plugin/src/ui/ui.ts similarity index 61% rename from tools/figma-plugin/src/ui.ts rename to tools/figma-plugin/src/ui/ui.ts index 876fff448..2b1c8e998 100644 --- a/tools/figma-plugin/src/ui.ts +++ b/tools/figma-plugin/src/ui/ui.ts @@ -1,16 +1,16 @@ -import { runButton } from "./dom"; -import { addSettingsInputListeners, initSettingsListeners } from "./settings"; -import { onMessage, onError } from "./api"; -import { setStatus } from "./status"; -import { initializeBulkActions, updateBulkActionState } from "./bulkActions"; -import { createSelectionController } from "./selectionController"; -import { createRequestController } from "./requestController"; -import { createMainMessageHandler } from "./messageHandlers"; -import { applyTerminalRunState } from "./runTerminalState"; +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: () => { - applyTerminalRunState("timed-out"); + applyRunLifecycleState("timed-out"); }, }); From a46f770bcc40bec7f3ef64a644cc7fa50f51aff5 Mon Sep 17 00:00:00 2001 From: t-regbs Date: Wed, 4 Mar 2026 13:48:53 +0000 Subject: [PATCH 5/7] fix(figma-plugin): harden request lifecycle, converter parsing, and accessibility while cleaning post-refactor boundaries --- DEVELOPMENT.md | 11 ++---- tools/figma-plugin/DEVELOPMENT.md | 13 +++++++ tools/figma-plugin/README.md | 1 - tools/figma-plugin/package.json | 1 + tools/figma-plugin/pnpm-lock.yaml | 8 +++++ tools/figma-plugin/scripts/build.mjs | 36 +++++++++++++------ .../figma-plugin/src/fast-text-encoding.d.ts | 3 ++ tools/figma-plugin/src/main/code.ts | 16 ++++----- .../figma-plugin/src/shared/errorFormatter.ts | 4 ++- .../figma-plugin/src/shared/pluginSettings.ts | 3 +- .../src/ui/controllers/requestController.ts | 32 ++++++++++------- .../src/ui/controllers/runLifecycleState.ts | 12 +++++++ tools/figma-plugin/src/ui/core/status.ts | 7 ++-- tools/figma-plugin/src/ui/core/types.ts | 13 +++++-- .../src/ui/features/conversion.ts | 22 ++++++++++-- .../src/ui/features/converterAdapter.ts | 23 ++++++------ tools/figma-plugin/src/ui/features/render.ts | 2 +- .../figma-plugin/src/ui/features/settings.ts | 2 +- tools/figma-plugin/src/ui/ui.html | 8 ++--- tools/figma-plugin/src/ui/ui.ts | 10 +++--- 20 files changed, 155 insertions(+), 72 deletions(-) create mode 100644 tools/figma-plugin/DEVELOPMENT.md create mode 100644 tools/figma-plugin/src/fast-text-encoding.d.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 67ca318fa..75c0e795b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,11 +38,6 @@ Update changelog: `./gradlew tools:idea-plugin:patchChangelog` - Run WASM: `./gradlew tools:compose-app:wasmJsBrowserDevelopmentRun` -## FIGMA Plugin (Simple mode) - -- Build converter for Wasm executable: `./gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs` -- Install plugin package deps (pnpm): `pnpm install` (run in `tools/figma-plugin`) -- Build plugin assets: `pnpm build` (run in `tools/figma-plugin`) -- Build converter + plugin assets: `pnpm build:all` (run in `tools/figma-plugin`) -- Watch plugin assets: `pnpm watch` (run in `tools/figma-plugin`) -- Reload in Figma after build: `Plugins -> Development -> Reload plugins` +## FIGMA Plugin + +- See `tools/figma-plugin/DEVELOPMENT.md`. diff --git a/tools/figma-plugin/DEVELOPMENT.md b/tools/figma-plugin/DEVELOPMENT.md new file mode 100644 index 000000000..0199abfe3 --- /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 index a958369d6..1e0cc3d87 100644 --- a/tools/figma-plugin/README.md +++ b/tools/figma-plugin/README.md @@ -40,7 +40,6 @@ This package contains a Figma plugin shell for exporting selected icons into Kot `pnpm build` reads these converter outputs: -- `valkyrie-sdk-figma-converter.mjs` - `valkyrie-sdk-figma-converter.uninstantiated.mjs` - `valkyrie-sdk-figma-converter.wasm` diff --git a/tools/figma-plugin/package.json b/tools/figma-plugin/package.json index 6d9c7a83a..50e089e6f 100644 --- a/tools/figma-plugin/package.json +++ b/tools/figma-plugin/package.json @@ -16,6 +16,7 @@ "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 index 5627cc925..1aaa4fd97 100644 --- a/tools/figma-plugin/pnpm-lock.yaml +++ b/tools/figma-plugin/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + fast-text-encoding: + specifier: ^1.0.6 + version: 1.0.6 fflate: specifier: ^0.8.2 version: 0.8.2 @@ -188,6 +191,9 @@ packages: 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==} @@ -307,6 +313,8 @@ snapshots: '@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 index 9838ad531..397f5420d 100644 --- a/tools/figma-plugin/scripts/build.mjs +++ b/tools/figma-plugin/scripts/build.mjs @@ -50,33 +50,47 @@ async function buildWasmBridgeScript() { const wasmBytes = await readFile(wasmPath); const wasmBase64 = wasmBytes.toString("base64"); + const instantiateMarker = "export async function instantiate"; + const fetchMarker = "fetch(new URL('./valkyrie-sdk-figma-converter.wasm',import.meta.url).href)"; + + if (!uninstantiated.includes(instantiateMarker)) { + throw new Error(`Failed to patch converter bridge: missing marker '${instantiateMarker}'.`); + } + if (!uninstantiated.includes(fetchMarker)) { + throw new Error(`Failed to patch converter bridge: missing marker '${fetchMarker}'.`); + } + wasmBridgeScript = uninstantiated - .replace("export async function instantiate", "async function instantiate") - .replace( - "fetch(new URL('./valkyrie-sdk-figma-converter.wasm',import.meta.url).href)", - `fetch('data:application/wasm;base64,${wasmBase64}')`, - ) + .replace(instantiateMarker, "async function instantiate") + .replace(fetchMarker, `fetch('data:application/wasm;base64,${wasmBase64}')`) .concat( "\nconst __converter = (await instantiate({})).exports;\n" + "window.ValkyrieFigmaWasmConverter = { convertSvg: __converter.convertSvg, normalizeIconName: __converter.normalizeIconName };\n", ); - } catch { - process.stderr.write( - "Converter artifacts missing. Run ../../gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs first.\n", - ); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + process.stderr.write( + "Converter artifacts missing. Run ../../gradlew :sdk:figma:converter:compileProductionExecutableKotlinWasmJs first.\n", + ); + } else { + throw error; + } } return wasmBridgeScript; } -const wasmBridgeScript = await buildWasmBridgeScript(); - async function writeInlinedUiHtml() { + const wasmBridgeScript = await buildWasmBridgeScript(); const [uiHtml, uiScript] = await Promise.all([ readFile(srcUiHtmlPath, "utf8"), readFile(distUiJsPath, "utf8"), ]); + if (!uiHtml.includes("/*__WASM_BRIDGE__*/") || !uiHtml.includes("/*__UI_SCRIPT__*/")) { + throw new Error("ui.html placeholders are missing: /*__WASM_BRIDGE__*/ and/or /*__UI_SCRIPT__*/"); + } + const inlinedHtml = uiHtml .replace("/*__WASM_BRIDGE__*/", escapeScriptTag(wasmBridgeScript)) .replace("/*__UI_SCRIPT__*/", escapeScriptTag(uiScript)); 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 index f55eee4b7..825cd7e53 100644 --- a/tools/figma-plugin/src/main/code.ts +++ b/tools/figma-plugin/src/main/code.ts @@ -1,3 +1,5 @@ +import * as fastTextEncoding from "fast-text-encoding"; + import type { ConversionReadyMessage, ConversionStartedMessage, @@ -22,6 +24,9 @@ type ActiveRun = { }; }; +const textDecoderCtor: typeof TextDecoder | undefined = + typeof globalThis.TextDecoder !== "undefined" ? globalThis.TextDecoder : fastTextEncoding.TextDecoder; + let activeRun: ActiveRun | null = null; figma.showUI(__html__, PLUGIN_UI_SIZE); @@ -182,15 +187,10 @@ figma.ui.onmessage = async (message: UiToMainMessage) => { }; function decodeUtf8(bytes: Uint8Array): string { - if (typeof TextDecoder !== "undefined") { - return new TextDecoder("utf-8").decode(bytes); - } - - let result = ""; - for (let i = 0; i < bytes.length; i += 1) { - result += String.fromCharCode(bytes[i]); + if (!textDecoderCtor) { + throw new Error("TextDecoder is unavailable in plugin runtime."); } - return result; + return new textDecoderCtor("utf-8").decode(bytes); } async function exportNodesAsSvg( diff --git a/tools/figma-plugin/src/shared/errorFormatter.ts b/tools/figma-plugin/src/shared/errorFormatter.ts index 09e325e72..a848baead 100644 --- a/tools/figma-plugin/src/shared/errorFormatter.ts +++ b/tools/figma-plugin/src/shared/errorFormatter.ts @@ -4,13 +4,15 @@ export type PluginError = { 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: ${error.diagnostics}`; + return `${base}${DIAGNOSTICS_DELIMITER}${error.diagnostics}`; } export function createSelectionError(summary: string, nextStep: string): PluginError { diff --git a/tools/figma-plugin/src/shared/pluginSettings.ts b/tools/figma-plugin/src/shared/pluginSettings.ts index f5f481e51..91ff60c3a 100644 --- a/tools/figma-plugin/src/shared/pluginSettings.ts +++ b/tools/figma-plugin/src/shared/pluginSettings.ts @@ -55,7 +55,8 @@ export function sanitizePluginSettings(value: unknown): PluginSettings | null { } return { - packageName: typeof raw.packageName === "string" ? raw.packageName : DEFAULT_PLUGIN_SETTINGS.packageName, + 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" diff --git a/tools/figma-plugin/src/ui/controllers/requestController.ts b/tools/figma-plugin/src/ui/controllers/requestController.ts index 6c95f490a..c91bcf511 100644 --- a/tools/figma-plugin/src/ui/controllers/requestController.ts +++ b/tools/figma-plugin/src/ui/controllers/requestController.ts @@ -2,7 +2,8 @@ import { sendMessage } from "../core/api"; import { createTimeoutError, formatPluginError } from "../../shared/errorFormatter"; import { setStatus } from "../core/status"; -const REQUEST_TIMEOUT_MS = 5000; +const REQUEST_ACK_TIMEOUT_MS = 5000; +const RUN_TIMEOUT_MS = 120000; type RequestState = "requested" | "started"; @@ -27,6 +28,21 @@ export function createRequestController(deps: RequestControllerDeps = {}) { 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 { requestConversion(): void { const hadActiveRequest = activeRequest !== null; @@ -40,18 +56,7 @@ export function createRequestController(deps: RequestControllerDeps = {}) { : "Requesting export from Figma...", "working", ); - clearPendingTimeout(); - setPendingTimeout(() => { - if (!activeRequest || activeRequest.id !== requestId || activeRequest.state !== "requested") { - return; - } - activeRequest = null; - if (deps.onTimedOut) { - deps.onTimedOut(); - } else { - setStatus(formatPluginError(createTimeoutError()), "error"); - } - }, REQUEST_TIMEOUT_MS); + scheduleRequestTimeout(requestId, "requested", REQUEST_ACK_TIMEOUT_MS); sendMessage({ type: "run-conversion", requestId }); }, @@ -63,6 +68,7 @@ export function createRequestController(deps: RequestControllerDeps = {}) { clearPendingTimeout(); activeRequest.state = "started"; + scheduleRequestTimeout(requestId, "started", RUN_TIMEOUT_MS); return true; }, diff --git a/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts index 9f27b2ac4..ac08c7e5a 100644 --- a/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts +++ b/tools/figma-plugin/src/ui/controllers/runLifecycleState.ts @@ -15,6 +15,16 @@ function restorePreviousResultsWhenLoading(): void { } } +function clearLoadingResultsWhenNoPrevious(): void { + if (getConversionResultsCount() !== 0) { + return; + } + + if (isLoadingResultsVisible()) { + renderResults([]); + } +} + export function applyRunLifecycleState(state: RunLifecycleState): void { switch (state) { case "superseded": { @@ -24,6 +34,8 @@ export function applyRunLifecycleState(state: RunLifecycleState): void { } case "timed-out": { + restorePreviousResultsWhenLoading(); + clearLoadingResultsWhenNoPrevious(); setStatus(formatPluginError(createTimeoutError()), "error"); return; } diff --git a/tools/figma-plugin/src/ui/core/status.ts b/tools/figma-plugin/src/ui/core/status.ts index d384a042d..5270b1c31 100644 --- a/tools/figma-plugin/src/ui/core/status.ts +++ b/tools/figma-plugin/src/ui/core/status.ts @@ -1,16 +1,15 @@ import { statusText, statusIcon, statusDetails, statusDiagnostics } from "./dom"; import type { StatusType } from "./types"; - -const DIAGNOSTICS_MARKER = " Diagnostics: "; +import { DIAGNOSTICS_DELIMITER } from "../../shared/errorFormatter"; function splitDiagnostics(message: string): { summary: string; diagnostics: string | null } { - const markerIndex = message.indexOf(DIAGNOSTICS_MARKER); + 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_MARKER.length).trim(); + const diagnostics = message.slice(markerIndex + DIAGNOSTICS_DELIMITER.length).trim(); return { summary, diagnostics: diagnostics.length > 0 ? diagnostics : null }; } diff --git a/tools/figma-plugin/src/ui/core/types.ts b/tools/figma-plugin/src/ui/core/types.ts index b359d1a91..72430f57c 100644 --- a/tools/figma-plugin/src/ui/core/types.ts +++ b/tools/figma-plugin/src/ui/core/types.ts @@ -1,5 +1,14 @@ -import type { ConvertResult } from "../features/converterAdapter"; +export type ConvertResult = { + success: boolean; + iconName: string; + fileName: string; + code: string; + error?: string; +}; -export type ConvertResultWithSvg = ConvertResult & { svg?: string }; +export type ConvertResultWithSvg = ConvertResult & { + svg?: string; + sourceId?: string; +}; export type StatusType = "ready" | "working" | "warning" | "error"; diff --git a/tools/figma-plugin/src/ui/features/conversion.ts b/tools/figma-plugin/src/ui/features/conversion.ts index cdb657542..f01bc1642 100644 --- a/tools/figma-plugin/src/ui/features/conversion.ts +++ b/tools/figma-plugin/src/ui/features/conversion.ts @@ -96,6 +96,7 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont fileName: "", code: "", error: `Duplicate icon name '${normalized}'.`, + sourceId: icon.id, }); continue; } @@ -107,6 +108,7 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont fileName: "", code: "", error: `Case-insensitive collision for '${normalized}'.`, + sourceId: icon.id, }); continue; } @@ -114,8 +116,24 @@ async function runConversionAsync(icons: ExportedIcon[], context: ConversionCont seenExact.add(normalized); seenInsensitive.add(lowered); - const result: ConvertResultWithSvg = { ...convert(icon.svg, icon.name, options), svg: icon.svg }; - nextResults.push(result); + 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); diff --git a/tools/figma-plugin/src/ui/features/converterAdapter.ts b/tools/figma-plugin/src/ui/features/converterAdapter.ts index 33c5b699e..b6962d9e4 100644 --- a/tools/figma-plugin/src/ui/features/converterAdapter.ts +++ b/tools/figma-plugin/src/ui/features/converterAdapter.ts @@ -1,4 +1,5 @@ import type { AutoMirrorOption, OutputFormat } from "../../shared/pluginSettings"; +import type { ConvertResult } from "../core/types"; export type ConvertOptions = { packageName: string; @@ -11,14 +12,6 @@ export type ConvertOptions = { autoMirror: AutoMirrorOption; }; -export type ConvertResult = { - success: boolean; - iconName: string; - fileName: string; - code: string; - error?: string; -}; - type WasmConvertResult = { type: string; iconName: string; @@ -85,7 +78,17 @@ export function convert(svg: string, iconName: string, options: ConvertOptions): options.autoMirror, ); - return toPluginConvertResult(JSON.parse(json) as WasmConvertResult); + 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 { @@ -108,5 +111,5 @@ function toPluginConvertResult(result: WasmConvertResult): ConvertResult { } function isSuccessType(type: string): boolean { - return type === "success" || type.endsWith(".Success"); + return type === "success"; } diff --git a/tools/figma-plugin/src/ui/features/render.ts b/tools/figma-plugin/src/ui/features/render.ts index f8777cf76..302b15336 100644 --- a/tools/figma-plugin/src/ui/features/render.ts +++ b/tools/figma-plugin/src/ui/features/render.ts @@ -279,7 +279,7 @@ export function renderResults(results: ConvertResultWithSvg[]): void { } for (const result of results) { - const resultKey = `${result.success ? "ok" : "error"}:${result.iconName}:${result.fileName}`; + 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; diff --git a/tools/figma-plugin/src/ui/features/settings.ts b/tools/figma-plugin/src/ui/features/settings.ts index 0d00c516b..bd6be6263 100644 --- a/tools/figma-plugin/src/ui/features/settings.ts +++ b/tools/figma-plugin/src/ui/features/settings.ts @@ -15,7 +15,7 @@ export function addSettingsInputListeners(listener: () => void): void { export function getSettingsValues(): PluginSettings { return { - packageName: packageInput.value, + packageName: packageInput.value.trim(), outputFormat: outputFormatInput.value as PluginSettings["outputFormat"], useComposeColors: useComposeColorsInput.checked, addTrailingComma: addTrailingCommaInput.checked, diff --git a/tools/figma-plugin/src/ui/ui.html b/tools/figma-plugin/src/ui/ui.html index 7dc45edce..ef19b2e9a 100644 --- a/tools/figma-plugin/src/ui/ui.html +++ b/tools/figma-plugin/src/ui/ui.html @@ -711,12 +711,12 @@

Valkyrie Export

- Package name +
- Output format + Auto export
- Auto mirror override +