+Content-Type: application/json
+```
+
+`tolgeeFetch(path, init)` wraps these and throws on non-2xx with the body
+truncated to 200 chars.
+
+### Convert command (TextNode → widget)
+
+`convertTextNodes(widgetNodeId, scope)` walks the selection or current page,
+picks `TEXT` nodes, and for each one:
+
+1. Resolves `keyName` (3-step priority):
+ 1. `pluginData.tolgee_info.key` (legacy plugin)
+ 2. Layer name with `t:` prefix (legacy README convention)
+ 3. Random `key-xxxxxx` placeholder
+2. Resolves `translation` from `pluginData.tolgee_info.translation`, falling
+ back to `textNode.characters`.
+3. Reads `fontSize`, `fontFamily`, `fontWeight`, fill color, horizontal +
+ vertical alignment, width (from `textAutoResize`).
+4. Calls `myWidgetNode.cloneWidget(state)` and `parent.insertChild(idx, ...)`
+ so Auto-Layout takes over positioning.
+5. Removes the original TextNode.
+
+## Out of scope for this spike (deferred to v2)
+
+- Namespacing
+- Branching
+- Cursor-based pagination on Pull (currently `size=10000`)
+- Plurals / ICU formatting
+- Screenshot upload
+- Tags
+- Push conflict resolution (currently hardcoded `OVERRIDE`)
+- Per-instance language overrides
+- Multi-language preview
+- `connected` / `isPlural` / `pluralParamValue` / `paramsValues` migration
+ fields (read but not used)
+
+## Build
+
+```sh
+cd poc/widget-spike
+npm install
+npm run build # one-shot
+npm run watch # rebuild on change
+```
+
+## Load in Figma
+
+1. Figma Desktop → Plugins → Development → **Import plugin from manifest…**
+2. Pick `poc/widget-spike/manifest.json`.
+3. Resources panel (Shift+I) → Widgets tab → "Tolgee Widget Spike" → drag
+ onto canvas.
+4. Property menu → **Open Spike UI**.
+
+## End-to-end test protocol
+
+### S1 — Settings + Pull
+
+1. Open spike UI from a widget property menu.
+2. Enter `https://app.tolgee.io` (or your self-hosted URL), a project API
+ key, and a language (e.g. `en`). Save.
+3. Drop ~3 widgets, give them keys via Edit ("Edit text" → set Key).
+4. Click **Pull** → log shows `{updated, unchanged, missing,
+ totalKeysOnServer}`. Widgets that have a matching `keyName` on the
+ server now show the server's translation. Layer-panel names update.
+
+### S2 — Edit + Push
+
+1. Edit a widget's translation locally (Edit modal, Cmd+Enter saves).
+2. Click **Push** → log shows `{pushed: N}`.
+3. Verify in the Tolgee web UI that the translation arrived for the
+ configured language.
+
+### S3 — Migration from legacy plugin
+
+1. Open a Figma file that already used the legacy Tolgee plugin (TextNodes
+ with `tolgee_info` pluginData).
+2. Drop one spike widget anywhere (just to access its property menu / spike
+ UI).
+3. Open spike UI → **Convert ALL TEXT on page**.
+4. Verify: widgets carry the Tolgee key from the legacy pluginData (not
+ `key-xxxxxx`), translations are pre-populated, alignment / fontSize /
+ fontFamily / color are preserved.
+
+### S4 — Auto-Layout fidelity
+
+1. Build a Figma frame with `layoutMode: "VERTICAL"` containing several
+ TextNodes with mixed alignments.
+2. Convert via spike UI.
+3. Verify: layout flows correctly, no x/y shift, alignment respected on
+ each widget.
+
+### S5 — Performance baseline
+
+1. Convert ~50 TextNodes (or use the convert + clone-50 from selection
+ pattern).
+2. Click **Benchmark bump** → log shows ms for full bulk
+ `setWidgetSyncedState` round-trip. Sanity-check against the 67ms / 250
+ widgets baseline.
+
+## Files
+
+```
+poc/widget-spike/
+├── manifest.json # combined widget+plugin (containsWidget + ui)
+├── package.json # esbuild + figma typings
+├── tsconfig.json # references the two below
+├── tsconfig.widget.json # widget compile config (no DOM lib)
+├── tsconfig.ui.json # ui compile config (with DOM lib)
+├── build.mjs # esbuild driver, inlines ui.js+edit.js into HTML
+└── src/
+ ├── widget.tsx # widget render + plugin message handlers
+ ├── ui.ts / ui.html # spike UI (settings, pull/push, convert, debug)
+ └── edit.ts / edit.html # inline edit modal (key + translation)
+```
+
+## Open questions for production scoping
+
+1. **dynamic-page migration cost**: how much of `src/main/` needs to go
+ async? Spike count of `figma.getNodeById` and `figma.currentPage.children`
+ call sites would inform this.
+2. **Component-instance behaviour**: legacy TextNodes inside Figma
+ components — does conversion preserve the master-instance relationship?
+ Not yet tested in this spike.
+3. **Mixed font runs**: `getRangeAllFontNames` on a TextNode with mixed
+ fonts → how do we serialize that into a single `fontFamily` syncedState
+ value? Spike currently reads only the font of the first character.
+4. **Schema versioning** for the syncedState: introduce `version: 1` field
+ from day one to make additive migrations explicit.
+5. **Push conflict UX**: today's plugin has a diff view + conflict
+ resolution (`SimpleImportConflictResult`). Reproducing that is a
+ separate larger task; spike skips it.
diff --git a/poc/widget-spike/build.mjs b/poc/widget-spike/build.mjs
new file mode 100644
index 0000000..9085278
--- /dev/null
+++ b/poc/widget-spike/build.mjs
@@ -0,0 +1,90 @@
+import * as esbuild from "esbuild";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const watch = process.argv.includes("--watch");
+
+const distDir = path.join(__dirname, "dist");
+fs.mkdirSync(distDir, { recursive: true });
+
+async function buildScript(src, outName, format = "iife") {
+ return esbuild.context({
+ entryPoints: [path.join(__dirname, src)],
+ bundle: true,
+ outfile: path.join(distDir, outName),
+ target: "es2017",
+ format,
+ loader: { ".ts": "ts" },
+ logLevel: "info",
+ });
+}
+
+const uiCtx = await buildScript("src/ui.ts", "ui.js");
+const editCtx = await buildScript("src/edit.ts", "edit.js");
+
+function inlineHtml(template, scriptFile) {
+ const js = fs.readFileSync(path.join(distDir, scriptFile), "utf8");
+ const tpl = fs.readFileSync(path.join(__dirname, template), "utf8");
+ return tpl.replace("", ``);
+}
+
+function buildHtmls() {
+ const main = inlineHtml("src/ui.html", "ui.js");
+ const edit = inlineHtml("src/edit.html", "edit.js");
+ fs.writeFileSync(path.join(distDir, "ui.html"), main);
+ fs.writeFileSync(path.join(distDir, "edit.html"), edit);
+ return { main, edit };
+}
+
+async function buildWidget(htmls) {
+ const ctx = await esbuild.context({
+ entryPoints: [path.join(__dirname, "src/widget.tsx")],
+ bundle: true,
+ outfile: path.join(distDir, "widget.js"),
+ target: "es2017",
+ jsxFactory: "figma.widget.h",
+ jsxFragment: "figma.widget.Fragment",
+ loader: { ".tsx": "tsx", ".ts": "ts" },
+ define: {
+ __html__: JSON.stringify(htmls.main),
+ EDIT_HTML: JSON.stringify(htmls.edit),
+ },
+ logLevel: "info",
+ });
+ if (watch) {
+ await ctx.watch();
+ return ctx;
+ }
+ await ctx.rebuild();
+ await ctx.dispose();
+ return null;
+}
+
+if (watch) {
+ await uiCtx.watch();
+ await editCtx.watch();
+ let widgetCtx = null;
+ const rebuildAll = async () => {
+ const htmls = buildHtmls();
+ if (widgetCtx) await widgetCtx.dispose();
+ widgetCtx = await buildWidget(htmls);
+ };
+ for (const f of ["src/ui.html", "src/edit.html"]) {
+ fs.watchFile(path.join(__dirname, f), rebuildAll);
+ }
+ for (const f of ["ui.js", "edit.js"]) {
+ fs.watchFile(path.join(distDir, f), rebuildAll);
+ }
+ await rebuildAll();
+ console.log("watching…");
+} else {
+ await uiCtx.rebuild();
+ await editCtx.rebuild();
+ await uiCtx.dispose();
+ await editCtx.dispose();
+ const htmls = buildHtmls();
+ await buildWidget(htmls);
+ console.log("build complete → dist/");
+}
diff --git a/poc/widget-spike/manifest.json b/poc/widget-spike/manifest.json
new file mode 100644
index 0000000..3424e73
--- /dev/null
+++ b/poc/widget-spike/manifest.json
@@ -0,0 +1,15 @@
+{
+ "name": "Tolgee Widget Spike",
+ "id": "tolgee-widget-spike-local",
+ "api": "1.0.0",
+ "widgetApi": "1.0.0",
+ "containsWidget": true,
+ "main": "dist/widget.js",
+ "ui": "dist/ui.html",
+ "editorType": ["figma"],
+ "documentAccess": "dynamic-page",
+ "networkAccess": {
+ "allowedDomains": ["*"],
+ "reasoning": "POC: contact any Tolgee instance"
+ }
+}
diff --git a/poc/widget-spike/package-lock.json b/poc/widget-spike/package-lock.json
new file mode 100644
index 0000000..31b7b9b
--- /dev/null
+++ b/poc/widget-spike/package-lock.json
@@ -0,0 +1,533 @@
+{
+ "name": "tolgee-widget-spike",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tolgee-widget-spike",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@figma/plugin-typings": "^1.110.0",
+ "@figma/widget-typings": "^1.10.0",
+ "esbuild": "^0.27.3",
+ "typescript": "^5.4.5"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@figma/plugin-typings": {
+ "version": "1.125.0",
+ "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.125.0.tgz",
+ "integrity": "sha512-8cXB4iKyRFl+/DryImvTngkFtgnowZUeFu/dt/jSaFL04mOKhGoZE1d1Vz+sUKUdZWXibGIWexCCdFK5gH5zxg==",
+ "dev": true,
+ "license": "MIT License"
+ },
+ "node_modules/@figma/widget-typings": {
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/@figma/widget-typings/-/widget-typings-1.12.1.tgz",
+ "integrity": "sha512-gGbbt3QObRMiuGrehq+awTcRfNP64o4RKdpRvsNFbQZNL1JlqIKma+RfuPgBvs2X8b1tFeSJQHNBnou9rHFEYw==",
+ "dev": true,
+ "license": "MIT License",
+ "peerDependencies": {
+ "@figma/plugin-typings": "1.x"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/poc/widget-spike/package.json b/poc/widget-spike/package.json
new file mode 100644
index 0000000..294aa7a
--- /dev/null
+++ b/poc/widget-spike/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "tolgee-widget-spike",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "build": "node build.mjs",
+ "watch": "node build.mjs --watch"
+ },
+ "devDependencies": {
+ "@figma/plugin-typings": "^1.110.0",
+ "@figma/widget-typings": "^1.10.0",
+ "esbuild": "^0.27.3",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/poc/widget-spike/src/edit.html b/poc/widget-spike/src/edit.html
new file mode 100644
index 0000000..d053049
--- /dev/null
+++ b/poc/widget-spike/src/edit.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+ Markup: <b>…</b>, <i>…</i>, <u>…</u>
+
+
+
+
+
+
+
diff --git a/poc/widget-spike/src/edit.ts b/poc/widget-spike/src/edit.ts
new file mode 100644
index 0000000..5e80f01
--- /dev/null
+++ b/poc/widget-spike/src/edit.ts
@@ -0,0 +1,42 @@
+const keyInput = document.getElementById("key") as HTMLInputElement;
+const translationInput = document.getElementById(
+ "translation",
+) as HTMLTextAreaElement;
+
+window.addEventListener("message", (e) => {
+ const data = e.data?.pluginMessage;
+ if (data?.type === "INIT") {
+ keyInput.value = data.key ?? "";
+ translationInput.value = data.translation ?? "";
+ translationInput.focus();
+ translationInput.select();
+ }
+});
+
+const send = (msg: Record) => {
+ parent.postMessage({ pluginMessage: msg }, "*");
+};
+
+document.getElementById("save")!.addEventListener("click", () => {
+ send({
+ type: "EDIT_SAVE",
+ key: keyInput.value.trim(),
+ translation: translationInput.value,
+ });
+});
+
+document.getElementById("cancel")!.addEventListener("click", () => {
+ send({ type: "EDIT_CANCEL" });
+});
+
+window.addEventListener("keydown", (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ send({
+ type: "EDIT_SAVE",
+ key: keyInput.value.trim(),
+ translation: translationInput.value,
+ });
+ } else if (e.key === "Escape") {
+ send({ type: "EDIT_CANCEL" });
+ }
+});
diff --git a/poc/widget-spike/src/ui.html b/poc/widget-spike/src/ui.html
new file mode 100644
index 0000000..645477c
--- /dev/null
+++ b/poc/widget-spike/src/ui.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ Sync
+
+
+
+
+
+ Migration
+
+
+
+ Debug
+
+
+
+
+
+
+
+
diff --git a/poc/widget-spike/src/ui.ts b/poc/widget-spike/src/ui.ts
new file mode 100644
index 0000000..06abbff
--- /dev/null
+++ b/poc/widget-spike/src/ui.ts
@@ -0,0 +1,69 @@
+const apiUrl = document.getElementById("apiUrl") as HTMLInputElement;
+const apiKey = document.getElementById("apiKey") as HTMLInputElement;
+const lang = document.getElementById("lang") as HTMLInputElement;
+const out = document.getElementById("out")!;
+
+const log = (line: string) => {
+ const ts = new Date().toISOString().split("T")[1].replace("Z", "");
+ out.textContent = `[${ts}] ${line}\n` + out.textContent;
+};
+
+const send = (msg: Record) => {
+ parent.postMessage({ pluginMessage: msg }, "*");
+};
+
+window.addEventListener("message", (e) => {
+ const data = e.data?.pluginMessage;
+ if (!data) return;
+ if (data.type === "SETTINGS") {
+ apiUrl.value = data.apiUrl ?? "";
+ apiKey.value = data.apiKey ?? "";
+ lang.value = data.language ?? "en";
+ log("settings loaded");
+ } else if (data.type === "DONE") {
+ if (data.error) {
+ log(`error ${data.op}: ${data.error}`);
+ } else {
+ log(`${data.op} ok: ${JSON.stringify({ ...data, type: undefined, op: undefined })}`);
+ }
+ }
+});
+
+const wire = (id: string, handler: () => void) => {
+ document.getElementById(id)!.addEventListener("click", handler);
+};
+
+wire("saveSettings", () => {
+ send({
+ type: "SAVE_SETTINGS",
+ apiUrl: apiUrl.value.trim(),
+ apiKey: apiKey.value.trim(),
+ language: lang.value.trim() || "en",
+ });
+});
+
+wire("pull", () => {
+ log(`pull lang=${lang.value || "en"}`);
+ send({ type: "PULL", language: lang.value.trim() || "en" });
+});
+
+wire("push", () => {
+ log(`push lang=${lang.value || "en"}`);
+ send({ type: "PUSH", language: lang.value.trim() || "en" });
+});
+
+wire("convert", () => {
+ log("convert selected TEXT → widgets");
+ send({ type: "CONVERT_SELECTED" });
+});
+
+wire("convertPage", () => {
+ log("convert ALL TEXT on this page → widgets");
+ send({ type: "CONVERT_PAGE" });
+});
+
+wire("dump", () => send({ type: "DUMP_STATES" }));
+wire("bench", () => send({ type: "BENCHMARK_BUMP" }));
+wire("close", () => send({ type: "CLOSE" }));
+
+log("ready");
diff --git a/poc/widget-spike/src/widget.tsx b/poc/widget-spike/src/widget.tsx
new file mode 100644
index 0000000..18ab985
--- /dev/null
+++ b/poc/widget-spike/src/widget.tsx
@@ -0,0 +1,567 @@
+///
+///
+
+declare const EDIT_HTML: string;
+
+const { widget } = figma;
+const {
+ Text,
+ Span,
+ useSyncedState,
+ usePropertyMenu,
+ useWidgetNodeId,
+ useEffect,
+} = widget;
+
+const WIDGET_ID = "tolgee-widget-spike-local";
+const TOLGEE_NODE_INFO = "tolgee_info";
+
+type Token = {
+ text: string;
+ bold?: boolean;
+ italic?: boolean;
+ underline?: boolean;
+};
+
+type HAlign = "left" | "right" | "center" | "justified";
+type VAlign = "top" | "center" | "bottom";
+
+function stripMarkup(input: string): string {
+ return input.replace(/<\/?(b|strong|i|em|u)>/gi, "");
+}
+
+function parseInline(input: string): Token[] {
+ const tokens: Token[] = [];
+ const regex = /<(\/?)(b|strong|i|em|u)>|([^<]+)/gi;
+ let m: RegExpExecArray | null;
+ let bold = false;
+ let italic = false;
+ let underline = false;
+ while ((m = regex.exec(input)) !== null) {
+ if (m[3] !== undefined) {
+ tokens.push({ text: m[3], bold, italic, underline });
+ continue;
+ }
+ const closing = m[1] === "/";
+ const tag = m[2].toLowerCase();
+ if (tag === "b" || tag === "strong") bold = !closing;
+ else if (tag === "i" || tag === "em") italic = !closing;
+ else if (tag === "u") underline = !closing;
+ }
+ return tokens;
+}
+
+type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
+
+function styleToWeight(style: string): Weight {
+ const s = style.toLowerCase();
+ if (s.includes("thin")) return 100;
+ if (s.includes("extra light") || s.includes("ultralight")) return 200;
+ if (s.includes("light")) return 300;
+ if (s.includes("medium")) return 500;
+ if (s.includes("semi") || s.includes("demi")) return 600;
+ if (s.includes("extra bold") || s.includes("ultra")) return 800;
+ if (s.includes("black") || s.includes("heavy")) return 900;
+ if (s.includes("bold")) return 700;
+ return 400;
+}
+
+function rgbToHex(c: RGB): string {
+ const to = (n: number) =>
+ Math.round(n * 255)
+ .toString(16)
+ .padStart(2, "0");
+ return `#${to(c.r)}${to(c.g)}${to(c.b)}`;
+}
+
+function readTextProps(node: TextNode) {
+ let fontSize = 16;
+ let fontFamily = "Inter";
+ let fontWeight: Weight = 400;
+ let fill = "#000000";
+
+ try {
+ const fs = node.getRangeFontSize(0, Math.min(1, node.characters.length));
+ if (typeof fs === "number") fontSize = fs;
+ } catch {
+ /* ignore */
+ }
+
+ try {
+ const fn = node.getRangeFontName(0, Math.min(1, node.characters.length));
+ if (typeof fn === "object" && "family" in fn) {
+ fontFamily = fn.family;
+ fontWeight = styleToWeight(fn.style);
+ }
+ } catch {
+ /* ignore */
+ }
+
+ const fills = node.fills;
+ if (Array.isArray(fills) && fills.length > 0) {
+ const first = fills[0];
+ if (first.type === "SOLID") fill = rgbToHex(first.color);
+ }
+
+ const hAlignMap: Record = {
+ LEFT: "left",
+ RIGHT: "right",
+ CENTER: "center",
+ JUSTIFIED: "justified",
+ };
+ const vAlignMap: Record = {
+ TOP: "top",
+ CENTER: "center",
+ BOTTOM: "bottom",
+ };
+ const horizontalAlignText: HAlign = hAlignMap[node.textAlignHorizontal];
+ const verticalAlignText: VAlign = vAlignMap[node.textAlignVertical];
+
+ // For alignment to take effect, width must be fixed (not hug).
+ const widthFixed = node.textAutoResize !== "WIDTH_AND_HEIGHT";
+ const widgetWidth = widthFixed ? node.width : undefined;
+
+ return {
+ fontSize,
+ fontFamily,
+ fontWeight,
+ fill,
+ horizontalAlignText,
+ verticalAlignText,
+ widgetWidth,
+ };
+}
+
+async function findAllSpikeWidgets() {
+ await figma.loadAllPagesAsync();
+ return figma.root.findWidgetNodesByWidgetId(WIDGET_ID);
+}
+
+const SETTINGS_KEY = "tolgee_spike_settings_v1";
+
+type Settings = { apiUrl: string; apiKey: string; language: string };
+
+async function getSettings(): Promise {
+ const raw = await figma.clientStorage.getAsync(SETTINGS_KEY);
+ if (raw) {
+ try {
+ return { language: "en", ...(JSON.parse(raw) as Partial) } as Settings;
+ } catch {
+ /* fallthrough */
+ }
+ }
+ return { apiUrl: "https://app.tolgee.io", apiKey: "", language: "en" };
+}
+
+async function saveSettings(s: Settings) {
+ await figma.clientStorage.setAsync(SETTINGS_KEY, JSON.stringify(s));
+}
+
+type FetchInit = {
+ method?: string;
+ body?: string;
+ headers?: Record;
+};
+
+async function tolgeeFetch(path: string, init?: FetchInit) {
+ const { apiUrl, apiKey } = await getSettings();
+ if (!apiKey) throw new Error("API key not set");
+ const url = apiUrl.replace(/\/$/, "") + path;
+ const res = await fetch(url, {
+ method: init?.method,
+ body: init?.body,
+ headers: {
+ ...(init?.headers ?? {}),
+ "X-API-Key": apiKey,
+ "Content-Type": "application/json",
+ },
+ });
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Tolgee ${res.status}: ${body.slice(0, 200)}`);
+ }
+ return res;
+}
+
+async function pullTranslations(language: string) {
+ // simple non-paginated pull; size=10000 covers most projects for spike purposes
+ const json = (await (
+ await tolgeeFetch(
+ `/v2/projects/translations?languages=${encodeURIComponent(language)}&size=10000`,
+ )
+ ).json()) as {
+ _embedded?: {
+ keys?: Array<{
+ keyName: string;
+ translations: Record;
+ }>;
+ };
+ };
+
+ const map = new Map();
+ for (const k of json._embedded?.keys ?? []) {
+ const t = k.translations?.[language]?.text;
+ if (typeof t === "string") map.set(k.keyName, t);
+ }
+
+ const nodes = await findAllSpikeWidgets();
+ let updated = 0;
+ let unchanged = 0;
+ let missing = 0;
+ for (const node of nodes) {
+ const state = node.widgetSyncedState ?? {};
+ const k = state.keyName as string | undefined;
+ if (!k) continue;
+ const next = map.get(k);
+ if (next === undefined) {
+ missing++;
+ continue;
+ }
+ if (next === state.translation) {
+ unchanged++;
+ continue;
+ }
+ node.setWidgetSyncedState({
+ ...state,
+ translation: next,
+ rev: ((state.rev as number) ?? 0) + 1,
+ });
+ updated++;
+ }
+ return { updated, unchanged, missing, totalKeysOnServer: map.size };
+}
+
+async function pushTranslations(language: string) {
+ const nodes = await findAllSpikeWidgets();
+ const byKey = new Map();
+ for (const node of nodes) {
+ const state = node.widgetSyncedState ?? {};
+ const k = state.keyName as string | undefined;
+ const t = state.translation as string | undefined;
+ if (!k || typeof t !== "string") continue;
+ // last write wins for duplicate keys; could collect conflicts in v2
+ byKey.set(k, t);
+ }
+ if (byKey.size === 0) return { pushed: 0 };
+
+ const keys = Array.from(byKey.entries()).map(([name, text]) => ({
+ name,
+ translations: {
+ [language]: { text, resolution: "OVERRIDE" as const },
+ },
+ }));
+
+ await tolgeeFetch("/v2/projects/single-step-import-resolvable", {
+ method: "POST",
+ body: JSON.stringify({ keys }),
+ });
+
+ return { pushed: byKey.size };
+}
+
+async function pushTranslationToAll(matchKey: string, translation: string) {
+ const nodes = await findAllSpikeWidgets();
+ const t0 = Date.now();
+ let updated = 0;
+ for (const node of nodes) {
+ const state = node.widgetSyncedState ?? {};
+ if (state.keyName !== matchKey) continue;
+ node.setWidgetSyncedState({
+ ...state,
+ translation,
+ rev: ((state.rev as number) ?? 0) + 1,
+ });
+ updated++;
+ }
+ return { count: nodes.length, updated, ms: Date.now() - t0 };
+}
+
+async function bumpAllWidgets() {
+ const nodes = await findAllSpikeWidgets();
+ const t0 = Date.now();
+ for (const node of nodes) {
+ const state = node.widgetSyncedState ?? {};
+ node.setWidgetSyncedState({
+ ...state,
+ rev: ((state.rev as number) ?? 0) + 1,
+ });
+ }
+ return { count: nodes.length, ms: Date.now() - t0 };
+}
+
+async function convertTextNodes(
+ myWidgetNodeId: string,
+ scope: "selection" | "page",
+) {
+ await figma.loadAllPagesAsync();
+ const myNode = (await figma.getNodeByIdAsync(myWidgetNodeId)) as
+ | WidgetNode
+ | null;
+ if (!myNode) return { converted: 0, error: "self not found" };
+
+ const candidates: TextNode[] = [];
+ if (scope === "selection") {
+ for (const n of figma.currentPage.selection) {
+ if (n.type === "TEXT") candidates.push(n);
+ }
+ } else {
+ const walk = (children: readonly SceneNode[]) => {
+ for (const c of children) {
+ if (c.type === "TEXT") candidates.push(c);
+ if ("children" in c) walk((c as ChildrenMixin).children);
+ }
+ };
+ walk(figma.currentPage.children);
+ }
+
+ let converted = 0;
+ for (const textNode of candidates) {
+ let keyName: string | null = null;
+ let translation = textNode.characters;
+
+ // 1. Prefer the key stored by the legacy plugin in pluginData.
+ const data = textNode.getPluginData(TOLGEE_NODE_INFO);
+ if (data) {
+ try {
+ const parsed = JSON.parse(data) as Partial<{
+ key: string;
+ translation: string;
+ }>;
+ if (typeof parsed.key === "string" && parsed.key.length > 0) {
+ keyName = parsed.key;
+ }
+ if (typeof parsed.translation === "string" && parsed.translation) {
+ translation = parsed.translation;
+ }
+ } catch {
+ /* ignore malformed pluginData */
+ }
+ }
+
+ // 2. Legacy README convention: TextNode named "t:my.key.name".
+ if (!keyName && textNode.name.startsWith("t:")) {
+ const stripped = textNode.name.slice(2).trim();
+ if (stripped) keyName = stripped;
+ }
+
+ // 3. Fallback: random placeholder so the user can fix it later.
+ if (!keyName) {
+ keyName = "key-" + Math.random().toString(36).slice(2, 8);
+ }
+
+ const props = readTextProps(textNode);
+
+ const widget = myNode.cloneWidget(
+ stripUndefined({
+ keyName,
+ translation,
+ fontSize: props.fontSize,
+ fontFamily: props.fontFamily,
+ fontWeight: props.fontWeight,
+ fill: props.fill,
+ horizontalAlignText: props.horizontalAlignText,
+ verticalAlignText: props.verticalAlignText,
+ widgetWidth: props.widgetWidth,
+ }),
+ );
+
+ const parent = textNode.parent;
+ if (parent && "children" in parent && "insertChild" in parent) {
+ const idx = (parent as ChildrenMixin).children.indexOf(textNode);
+ const p = parent as ChildrenMixin & {
+ insertChild(i: number, c: SceneNode): void;
+ };
+ if (idx >= 0) p.insertChild(idx, widget);
+ else (parent as ChildrenMixin).appendChild(widget);
+ }
+ widget.x = textNode.x;
+ widget.y = textNode.y;
+
+ textNode.remove();
+ converted++;
+ }
+
+ return { converted, scope };
+}
+
+// Figma rejects `undefined` values in syncedState. Strip them so useSyncedState
+// falls back to its declared default (e.g. widgetWidth = undefined → hug-content).
+function stripUndefined>(obj: T): T {
+ const out = { ...obj };
+ for (const k of Object.keys(out)) {
+ if (out[k] === undefined) delete out[k];
+ }
+ return out;
+}
+
+async function selfUpdate(
+ myWidgetNodeId: string,
+ patch: Record,
+) {
+ const myNode = (await figma.getNodeByIdAsync(myWidgetNodeId)) as
+ | WidgetNode
+ | null;
+ if (!myNode) return;
+ const merged = stripUndefined({
+ ...(myNode.widgetSyncedState ?? {}),
+ ...patch,
+ rev: ((myNode.widgetSyncedState?.rev as number) ?? 0) + 1,
+ });
+ myNode.setWidgetSyncedState(merged);
+}
+
+function TolgeeSpikeWidget() {
+ const widgetNodeId = useWidgetNodeId();
+ const [keyName] = useSyncedState("keyName", "greeting");
+ const [translation] = useSyncedState(
+ "translation",
+ "Hello Tolgee!",
+ );
+ const [fontSize] = useSyncedState("fontSize", 16);
+ const [fontFamily] = useSyncedState("fontFamily", "Inter");
+ const [fontWeight] = useSyncedState("fontWeight", 400);
+ const [fill] = useSyncedState("fill", "#000000");
+ const [horizontalAlignText] = useSyncedState(
+ "horizontalAlignText",
+ undefined,
+ );
+ const [verticalAlignText] = useSyncedState(
+ "verticalAlignText",
+ undefined,
+ );
+ const [widgetWidth] = useSyncedState(
+ "widgetWidth",
+ undefined,
+ );
+ const [rev] = useSyncedState("rev", 0);
+
+ // Keep WidgetNode.name in sync with the translation so the layer panel
+ // shows the actual text instead of "Tolgee Widget Spike".
+ useEffect(() => {
+ const desiredName = stripMarkup(translation).trim() || "(empty)";
+ figma.getNodeByIdAsync(widgetNodeId).then((node) => {
+ if (node && node.name !== desiredName) {
+ node.name = desiredName;
+ }
+ });
+ });
+
+ usePropertyMenu(
+ [
+ { itemType: "action", tooltip: "Edit text", propertyName: "edit" },
+ { itemType: "action", tooltip: "Open Spike UI", propertyName: "open" },
+ { itemType: "separator" },
+ { itemType: "action", tooltip: "Show info", propertyName: "info" },
+ ],
+ async ({ propertyName }) => {
+ if (propertyName === "edit") {
+ return new Promise((resolve) => {
+ figma.showUI(EDIT_HTML, { width: 360, height: 280, title: "Edit" });
+ figma.ui.postMessage({
+ type: "INIT",
+ key: keyName,
+ translation,
+ });
+ figma.ui.onmessage = async (msg: any) => {
+ if (msg.type === "EDIT_SAVE") {
+ await selfUpdate(widgetNodeId, {
+ keyName: msg.key,
+ translation: msg.translation,
+ });
+ resolve();
+ } else if (msg.type === "EDIT_CANCEL") {
+ resolve();
+ }
+ };
+ });
+ }
+ if (propertyName === "open") {
+ return new Promise((resolve) => {
+ figma.showUI(__html__, { width: 380, height: 540, title: "Spike" });
+ // Send current settings on open so the UI is pre-populated.
+ getSettings().then((s) =>
+ figma.ui.postMessage({ type: "SETTINGS", ...s }),
+ );
+
+ figma.ui.onmessage = async (msg: any) => {
+ const reply = (op: string, payload: Record = {}) =>
+ figma.ui.postMessage({ type: "DONE", op, ...payload });
+ try {
+ if (msg.type === "SAVE_SETTINGS") {
+ await saveSettings({
+ apiUrl: msg.apiUrl,
+ apiKey: msg.apiKey,
+ language: msg.language,
+ });
+ reply("SAVE_SETTINGS");
+ } else if (msg.type === "PULL") {
+ const result = await pullTranslations(msg.language);
+ reply("PULL", result);
+ } else if (msg.type === "PUSH") {
+ const result = await pushTranslations(msg.language);
+ reply("PUSH", result);
+ } else if (msg.type === "CONVERT_SELECTED") {
+ const result = await convertTextNodes(widgetNodeId, "selection");
+ reply("CONVERT_SELECTED", result);
+ } else if (msg.type === "CONVERT_PAGE") {
+ const result = await convertTextNodes(widgetNodeId, "page");
+ reply("CONVERT_PAGE", result);
+ } else if (msg.type === "DUMP_STATES") {
+ const nodes = await findAllSpikeWidgets();
+ reply("DUMP_STATES", {
+ states: nodes.map((n) => ({
+ id: n.id,
+ keyName: n.widgetSyncedState?.keyName,
+ translation: n.widgetSyncedState?.translation,
+ rev: n.widgetSyncedState?.rev,
+ })),
+ });
+ } else if (msg.type === "BENCHMARK_BUMP") {
+ const result = await bumpAllWidgets();
+ reply("BENCHMARK_BUMP", result);
+ } else if (msg.type === "CLOSE") {
+ resolve();
+ }
+ } catch (e) {
+ reply(msg.type, { error: String(e) });
+ }
+ };
+ });
+ }
+ if (propertyName === "info") {
+ figma.notify(
+ `key=${keyName} · rev=${rev} · ${translation.slice(0, 60)}${translation.length > 60 ? "…" : ""}`,
+ );
+ }
+ },
+ );
+
+ const tokens = parseInline(translation);
+
+ return (
+
+ {tokens.map((t, i) => (
+
+ {t.text}
+
+ ))}
+
+ );
+}
+
+widget.register(TolgeeSpikeWidget);
diff --git a/poc/widget-spike/tsconfig.json b/poc/widget-spike/tsconfig.json
new file mode 100644
index 0000000..4cc5599
--- /dev/null
+++ b/poc/widget-spike/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.widget.json" },
+ { "path": "./tsconfig.ui.json" }
+ ]
+}
diff --git a/poc/widget-spike/tsconfig.ui.json b/poc/widget-spike/tsconfig.ui.json
new file mode 100644
index 0000000..4833ae9
--- /dev/null
+++ b/poc/widget-spike/tsconfig.ui.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "target": "es2017",
+ "module": "esnext",
+ "moduleResolution": "node",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "lib": ["es2017", "dom"],
+ "types": []
+ },
+ "include": ["src/ui.ts"]
+}
diff --git a/poc/widget-spike/tsconfig.widget.json b/poc/widget-spike/tsconfig.widget.json
new file mode 100644
index 0000000..ee4c23a
--- /dev/null
+++ b/poc/widget-spike/tsconfig.widget.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "target": "es2017",
+ "module": "esnext",
+ "moduleResolution": "node",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "lib": ["es2017"],
+ "jsx": "react",
+ "jsxFactory": "figma.widget.h",
+ "jsxFragmentFactory": "figma.widget.Fragment",
+ "typeRoots": ["./node_modules/@figma", "./node_modules/@types"],
+ "types": ["plugin-typings", "widget-typings"]
+ },
+ "include": ["src/widget.tsx"]
+}
diff --git a/src/main/endpoints/clearPrefilledKeys.ts b/src/main/endpoints/clearPrefilledKeys.ts
new file mode 100644
index 0000000..d431730
--- /dev/null
+++ b/src/main/endpoints/clearPrefilledKeys.ts
@@ -0,0 +1,43 @@
+import { TOLGEE_NODE_INFO } from "@/constants";
+import { createEndpoint } from "../utils/createEndpoint";
+
+const walkTextNodes = (
+ nodes: readonly SceneNode[],
+ visit: (node: TextNode) => void,
+) => {
+ for (const node of nodes) {
+ if (node.type === "TEXT") {
+ visit(node);
+ }
+ // @ts-ignore - not all SceneNodes have children, but the check guards us
+ if (node.children) {
+ // @ts-ignore
+ walkTextNodes(node.children as SceneNode[], visit);
+ }
+ }
+};
+
+export const clearPrefilledKeysEndpoint = createEndpoint(
+ "CLEAR_PREFILLED_KEYS",
+ () => {
+ for (const page of figma.root.children) {
+ if (page.type !== "PAGE") continue;
+ walkTextNodes(page.children, (node) => {
+ const raw = node.getPluginData(TOLGEE_NODE_INFO);
+ if (!raw) return;
+ let data: Record;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ return;
+ }
+ if (data.connected) return;
+ if (!data.key) return;
+ node.setPluginData(
+ TOLGEE_NODE_INFO,
+ JSON.stringify({ ...data, key: "" }),
+ );
+ });
+ }
+ },
+);
diff --git a/src/main/main.ts b/src/main/main.ts
index 44c0d91..6f0d115 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -31,6 +31,7 @@ import { formatTextEndpoint } from "./endpoints/formatText";
import { editorTypeEndpoint } from "./endpoints/editorType";
import { notifyEndpoint } from "./endpoints/notify";
import { preformatKeyEndpoint } from "./endpoints/preformatKey";
+import { clearPrefilledKeysEndpoint } from "./endpoints/clearPrefilledKeys";
const getAllPages = () => {
const document = figma.root;
@@ -116,6 +117,7 @@ export default async function () {
editorTypeEndpoint.register();
notifyEndpoint.register();
preformatKeyEndpoint.register();
+ clearPrefilledKeysEndpoint.register();
const config = await getPluginData();
diff --git a/src/main/utils/nodeTools.ts b/src/main/utils/nodeTools.ts
index 42bc99d..c0b3169 100644
--- a/src/main/utils/nodeTools.ts
+++ b/src/main/utils/nodeTools.ts
@@ -32,30 +32,11 @@ function shouldIncludeNode(
return false;
}
if (
- settings.ignoreHiddenLayers ||
- typeof settings.ignoreHiddenLayers === "undefined"
+ (settings.ignoreHiddenLayers ||
+ typeof settings.ignoreHiddenLayers === "undefined") &&
+ !node.visible
) {
- if (!node.visible) {
- return false;
- }
- if (settings.ignoreHiddenLayersIncludingChildren) {
- let isParentHidden = false;
- let parent = node.parent;
- try {
- while (parent) {
- if ("visible" in parent && !(parent as SceneNode).visible) {
- isParentHidden = true;
- break;
- }
- parent = parent.parent;
- }
- if (isParentHidden) {
- return false;
- }
- } catch (error) {
- console.error("Error checking parent visibility:", error);
- }
- }
+ return false;
}
if (
settings.ignoreTextLayers &&
@@ -70,20 +51,40 @@ function shouldIncludeNode(
return true;
}
-export const findTextNodes = (nodes: readonly SceneNode[]): TextNode[] => {
- const documentSettings = getDocumentData();
+// `settings` and `ancestorHidden` are threaded through recursion so we read
+// document data once and skip hidden subtrees up front, instead of walking
+// each text node's parents via the (expensive) plugin bridge.
+export const findTextNodes = (
+ nodes: readonly SceneNode[],
+ settings: Partial = getDocumentData(),
+ ancestorHidden = false,
+): TextNode[] => {
+ const respectVisibility =
+ settings.ignoreHiddenLayers ||
+ typeof settings.ignoreHiddenLayers === "undefined";
+ const skipHiddenSubtrees =
+ respectVisibility && !!settings.ignoreHiddenLayersIncludingChildren;
+
const result: TextNode[] = [];
for (const node of nodes) {
+ const nodeHidden =
+ respectVisibility && "visible" in node && !(node as SceneNode).visible;
+ const subtreeHidden = ancestorHidden || nodeHidden;
+
+ if (skipHiddenSubtrees && subtreeHidden) {
+ continue;
+ }
+
if (node.type === "TEXT") {
- if (shouldIncludeNode(node, documentSettings)) {
+ if (shouldIncludeNode(node, settings)) {
result.push(node);
}
}
// @ts-ignore
if (node.children) {
- // @ts-ignore
- findTextNodes(node.children as SceneNode[]).forEach((n) =>
- result.push(n),
+ result.push(
+ // @ts-ignore
+ ...findTextNodes(node.children as SceneNode[], settings, subtreeHidden),
);
}
}
diff --git a/src/tools/getPushChanges.ts b/src/tools/getPushChanges.ts
index 36eaaa3..b387c90 100644
--- a/src/tools/getPushChanges.ts
+++ b/src/tools/getPushChanges.ts
@@ -33,8 +33,15 @@ export const getPushChanges = (
const screenshotsByKey = new Map();
screenshots.forEach((screenshot) => {
+ // A frame screenshot can contain several nodes that share the same
+ // (key, ns). We must list the screenshot at most once per key, otherwise
+ // the push payload ends up with duplicate KeyScreenshotDto entries
+ // (same uploadedImageId, same positions) for that key.
+ const seenKeys = new Set();
screenshot.keys.forEach((node) => {
const mapKey = `${node.key}\0${hasNamespacesEnabled ? node.ns || "" : ""}`;
+ if (seenKeys.has(mapKey)) return;
+ seenKeys.add(mapKey);
let list = screenshotsByKey.get(mapKey);
if (!list) {
list = [];
diff --git a/src/ui/hooks/usePrefilledKey.ts b/src/ui/hooks/usePrefilledKey.ts
index 77f084f..1e5d6f8 100644
--- a/src/ui/hooks/usePrefilledKey.ts
+++ b/src/ui/hooks/usePrefilledKey.ts
@@ -9,6 +9,7 @@ export function usePrefilledKey(
nodeId: string,
keyFormat: string,
variableCasing: TolgeeConfig["variableCasing"],
+ enabled: boolean = true,
nodeKey?: string,
) {
const result = useQuery(
@@ -18,7 +19,7 @@ export function usePrefilledKey(
return preformatKeyEndpoint.call({ keyFormat, nodeId, variableCasing });
},
{
- enabled: !!nodeId && !!keyFormat && !nodeKey,
+ enabled: enabled && !!nodeId && !!keyFormat && !nodeKey,
structuralSharing: false,
},
);
diff --git a/src/ui/hooks/useSetNodesDataMutation.ts b/src/ui/hooks/useSetNodesDataMutation.ts
index 45f95e8..7d6d328 100644
--- a/src/ui/hooks/useSetNodesDataMutation.ts
+++ b/src/ui/hooks/useSetNodesDataMutation.ts
@@ -13,8 +13,15 @@ export const useSetNodesDataMutation = () => {
delayed((props: SetNodesDataProps) => setNodesDataEndpoint.call(props)),
{
onSuccess: () => {
- // Invalidate connected nodes query to ensure fresh data is fetched
- queryClient.invalidateQueries([getConnectedNodesEndpoint.name]);
+ // Mark connected-nodes data stale without triggering an immediate
+ // refetch. Refetching on every keystroke walks the entire page tree
+ // (see getConnectedNodes with ignoreSelection: true) and froze the UI
+ // while typing. Stale data is refetched on the next mount, e.g. when
+ // navigating back to Index/Pull/Push after Connect.
+ queryClient.invalidateQueries([getConnectedNodesEndpoint.name], {
+ refetchActive: false,
+ refetchInactive: false,
+ });
},
},
);
diff --git a/src/ui/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx
index 2eeae9e..f29f2d4 100644
--- a/src/ui/views/Connect/Connect.tsx
+++ b/src/ui/views/Connect/Connect.tsx
@@ -14,19 +14,18 @@ import {
import { useGlobalActions, useGlobalState } from "@/ui/state/GlobalState";
import { TopBar } from "@/ui/components/TopBar/TopBar";
import { ActionsBottom } from "@/ui/components/ActionsBottom/ActionsBottom";
-import { useApiQuery } from "@/ui/client/useQueryApi";
+import { useApiMutation, useApiQuery } from "@/ui/client/useQueryApi";
import { FullPageLoading } from "@/ui/components/FullPageLoading/FullPageLoading";
import { useSetNodesDataMutation } from "@/ui/hooks/useSetNodesDataMutation";
import { RouteParam } from "../routes";
import styles from "./Connect.css";
import { SearchRow } from "./SearchRow";
-import { useAllTranslations } from "@/ui/hooks/useAllTranslations";
type Props = RouteParam<"connect">;
export const Connect = ({ node }: Props) => {
const { setRoute } = useGlobalActions();
- const config = useGlobalState((c) => c.config);
+ const branch = useGlobalState((c) => c.config?.branch);
const language = useGlobalState((c) => c.config?.language);
@@ -47,52 +46,57 @@ export const Connect = ({ node }: Props) => {
},
});
+ // Fetches just the picked key (with plural metadata) instead of paginating
+ // through every translation in a namespace.
+ const keyTranslationLoadable = useApiMutation({
+ url: "/v2/projects/translations",
+ method: "get",
+ });
+
const setNodesDataMutation = useSetNodesDataMutation();
- const allTranslationsLoadable = useAllTranslations();
const handleGoBack = () => {
setRoute("index");
};
const handleConnect = async (
+ keyId: number,
key: string,
ns: string | undefined,
translation: string | undefined,
) => {
- if (
- !allTranslationsLoadable.isLoading &&
- allTranslationsLoadable.translationsData == null
- ) {
- const translationData = await allTranslationsLoadable.getData({
- language: config?.language ?? "en",
- namespaces: [config?.namespace ?? "default"],
+ let resolvedTranslation = translation;
+ let isPlural: boolean | undefined;
+ let pluralParamValue: string | undefined;
+
+ try {
+ const result = await keyTranslationLoadable.mutateAsync({
+ query: {
+ filterKeyId: [keyId],
+ languages: language ? [language] : undefined,
+ size: 1,
+ branch: branch || undefined,
+ },
});
- const tolgeeTranslation =
- translationData?.[config?.namespace ?? "default"]?.[key];
- if (tolgeeTranslation) {
- translation = tolgeeTranslation.translation;
- await setNodesDataMutation.mutateAsync({
- nodes: [
- {
- ...node,
- translation: tolgeeTranslation.translation || node.characters,
- isPlural: tolgeeTranslation.keyIsPlural,
- pluralParamValue: tolgeeTranslation.keyPluralArgName,
- key,
- ns: ns || "",
- connected: true,
- },
- ],
- });
- setRoute("index");
- return;
+ const tolgeeKey = result._embedded?.keys?.[0];
+ if (tolgeeKey) {
+ isPlural = tolgeeKey.keyIsPlural;
+ pluralParamValue = tolgeeKey.keyPluralArgName;
+ resolvedTranslation =
+ (language && tolgeeKey.translations?.[language]?.text) ||
+ resolvedTranslation;
}
+ } catch (e) {
+ console.error("Failed to load key metadata, connecting without it.", e);
}
+
await setNodesDataMutation.mutateAsync({
nodes: [
{
...node,
- translation: translation || node.characters,
+ translation: resolvedTranslation || node.characters,
+ isPlural: isPlural ?? node.isPlural,
+ pluralParamValue: pluralParamValue ?? node.pluralParamValue,
key,
ns: ns || "",
connected: true,
@@ -116,9 +120,12 @@ export const Connect = ({ node }: Props) => {
setRoute("index");
};
+ const isConnecting =
+ keyTranslationLoadable.isLoading || setNodesDataMutation.isLoading;
+
return (
- {translationsLoadable.isFetching && }
+ {(translationsLoadable.isFetching || isConnecting) && }
Connect to existing key}
@@ -152,7 +159,7 @@ export const Connect = ({ node }: Props) => {
key={key.id}
data={key}
onClick={() =>
- handleConnect(key.name, key.namespace, key.translation)
+ handleConnect(key.id, key.name, key.namespace, key.translation)
}
/>
))}
diff --git a/src/ui/views/Index/Index.tsx b/src/ui/views/Index/Index.tsx
index 483efee..f64c170 100644
--- a/src/ui/views/Index/Index.tsx
+++ b/src/ui/views/Index/Index.tsx
@@ -34,11 +34,6 @@ export const Index = () => {
// index page is not removed on certain routes
// refetch when we go back to it
const route = useGlobalState((c) => c.route);
- useEffect(() => {
- if (route[0] === "index") {
- selectionLoadable.refetch();
- }
- }, [route]);
const [error, setError] = useState();
@@ -95,6 +90,18 @@ export const Index = () => {
const { setRoute } = useGlobalActions();
const allNodes = useConnectedNodes({ ignoreSelection: true });
+ // index page is not removed on certain routes (e.g. Connect dialog).
+ // When returning to it, refetch selection + connected-nodes so changes
+ // made elsewhere are reflected. We deliberately do not refetch on every
+ // node-data write (see useSetNodesDataMutation) to avoid full-page tree
+ // walks while the user is typing a key.
+ useEffect(() => {
+ if (route[0] === "index") {
+ selectionLoadable.refetch();
+ allNodes.refetch();
+ }
+ }, [route]);
+
// Combine API namespaces + all namespaces from nodes
const allAvailableNamespaces = useMemo(() => {
const apiNamespaces =
diff --git a/src/ui/views/Index/ListItem.tsx b/src/ui/views/Index/ListItem.tsx
index 7f146db..1eae4f6 100644
--- a/src/ui/views/Index/ListItem.tsx
+++ b/src/ui/views/Index/ListItem.tsx
@@ -35,6 +35,7 @@ export const ListItem = ({
nodeId,
tolgeeConfig?.keyFormat ?? "",
tolgeeConfig?.variableCasing,
+ tolgeeConfig?.prefillKeyFormat ?? false,
);
const [keyName, setKeyName] = useState((node.key || prefilledKey.key) ?? "");
diff --git a/src/ui/views/Push/Push.tsx b/src/ui/views/Push/Push.tsx
index c4c8c4a..1875dfe 100644
--- a/src/ui/views/Push/Push.tsx
+++ b/src/ui/views/Push/Push.tsx
@@ -236,8 +236,8 @@ export const Push: FunctionalComponent = () => {
setRoute("index");
};
- const connectNodes = () => {
- setNodesDataMutation.mutate({
+ const connectNodes = async () => {
+ await setNodesDataMutation.mutateAsync({
nodes: nodes.map((n) => ({
...n,
translation:
@@ -249,8 +249,8 @@ export const Push: FunctionalComponent = () => {
});
};
- const handleConnectOnly = () => {
- connectNodes();
+ const handleConnectOnly = async () => {
+ await connectNodes();
setRoute("index");
};
@@ -431,7 +431,11 @@ export const Push: FunctionalComponent = () => {
const keysPushed = changes.newKeys.length + changes.changedKeys.length;
setPushedKeysCount(keysPushed);
- connectNodes();
+ // Await so local Figma node data is updated (and the connected-nodes
+ // query is invalidated) before we leave the view. Otherwise navigating
+ // back to Push quickly can see stale node data within the 30s staleTime
+ // window and re-show the changes that were just pushed.
+ await connectNodes();
// Clear translations cache so newly pushed keys are recognized on next check
allTranslationsLoadable.clearCache();
diff --git a/src/ui/views/Settings/ProjectSettings.tsx b/src/ui/views/Settings/ProjectSettings.tsx
index b20c2a3..362c0a1 100644
--- a/src/ui/views/Settings/ProjectSettings.tsx
+++ b/src/ui/views/Settings/ProjectSettings.tsx
@@ -230,10 +230,7 @@ export const ProjectSettings: FunctionComponent = ({
return ns;
}, [namespacesLoadable.data, settings?.namespace]);
- const branchNames = useMemo(
- () => branches.map((b) => b.name),
- [branches],
- );
+ const branchNames = useMemo(() => branches.map((b) => b.name), [branches]);
useEffect(() => {
if (!namespacesLoadable.data || !languagesLoadable.data || settings) {
@@ -253,7 +250,7 @@ export const ProjectSettings: FunctionComponent = ({
language:
initialData?.language || languages?.find((l) => l.base)?.tag || "",
namespace: initialData?.namespace ?? namespaces?.[0] ?? "",
- branch: hasBranchingEnabled ? savedBranch ?? defaultBranch : undefined,
+ branch: hasBranchingEnabled ? (savedBranch ?? defaultBranch) : undefined,
});
}, [
branchNames,
diff --git a/src/ui/views/Settings/StringsSection.tsx b/src/ui/views/Settings/StringsSection.tsx
index 9eb89f5..c1b23ee 100644
--- a/src/ui/views/Settings/StringsSection.tsx
+++ b/src/ui/views/Settings/StringsSection.tsx
@@ -9,6 +9,7 @@ import {
Bold,
Inline,
} from "@create-figma-plugin/ui";
+import { useQueryClient } from "react-query";
import styles from "./Settings.css";
import { TargetedEvent } from "preact/compat";
import { TolgeeConfig } from "@/types";
@@ -19,6 +20,8 @@ import {
TOLGEE_KEY_FORMAT_PLACEHOLDERS_EXAMPLES,
} from "@/constants";
import { InfoTooltip } from "../../components/InfoTooltip/InfoTooltip";
+import { clearPrefilledKeysEndpoint } from "@/main/endpoints/clearPrefilledKeys";
+import { getConnectedNodesEndpoint } from "@/main/endpoints/getConnectedNodes";
function getPreview(
format: string,
@@ -126,6 +129,7 @@ export const StringsSection: FunctionComponent = ({
tolgeeConfig,
setTolgeeConfig,
}) => {
+ const queryClient = useQueryClient();
const [format, setFormat] = useState(tolgeeConfig.keyFormat || "");
const [prefill, setPrefill] = useState(
tolgeeConfig.prefillKeyFormat ?? false,
@@ -162,10 +166,20 @@ export const StringsSection: FunctionComponent = ({
setTolgeeConfig({ ...tolgeeConfig, keyFormat: val });
};
- const handlePrefillChange = (e: any) => {
+ const handlePrefillChange = async (e: any) => {
const checked = e.currentTarget.checked;
setPrefill(checked);
setTolgeeConfig({ ...tolgeeConfig, prefillKeyFormat: checked });
+ if (!checked) {
+ // Drop auto-prefilled values that were persisted to node pluginData while
+ // the toggle was on so they don't keep showing up after disabling.
+ try {
+ await clearPrefilledKeysEndpoint.call();
+ queryClient.invalidateQueries([getConnectedNodesEndpoint.name]);
+ } catch (err) {
+ console.error("Failed to clear prefilled keys", err);
+ }
+ }
};
const handleVariableCasingChange = (value: string) => {
diff --git a/src/web/main.ts b/src/web/main.ts
index 7ac40f7..2652c88 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -17,6 +17,7 @@ import { setNodesDataEndpoint } from "@/main/endpoints/setNodesData";
import { getSelectedNodesEndpoint } from "@/main/endpoints/getSelectedNodes";
import { getConnectedNodesEndpoint } from "@/main/endpoints/getConnectedNodes";
import { copyPageEndpoint } from "@/main/endpoints/copyPage";
+import { clearPrefilledKeysEndpoint } from "@/main/endpoints/clearPrefilledKeys";
import { formatTextEndpoint } from "../main/endpoints/formatText";
const iframe = document.getElementById("plugin_iframe") as HTMLIFrameElement;
@@ -69,7 +70,27 @@ function main() {
});
getScreenshotsEndpoint.mock(() => {
- return [exampleScreenshot] as FrameScreenshot[];
+ // Build the screenshot's `keys` array from current state so multiple
+ // nodes that share the same translation key produce multiple entries
+ // (which is what the real Figma getScreenshots returns and what
+ // exercises the dedup path in getPushChanges).
+ const keys = state.allNodes
+ .filter((n) => n.key)
+ .map((n, i) => ({
+ ...n,
+ x: 10 + i * 80,
+ y: 30,
+ width: 70,
+ height: 20,
+ }));
+ if (keys.length === 0) return [];
+ return [
+ {
+ image: exampleScreenshot.image,
+ info: exampleScreenshot.info,
+ keys,
+ },
+ ] as FrameScreenshot[];
});
getSelectedNodesEndpoint.mock(() => ({
items: state.selectedNodes,
@@ -92,6 +113,13 @@ function main() {
nodeInfo.characters = formatted;
updateNodes([nodeInfo], false);
});
+ clearPrefilledKeysEndpoint.mock(() => {
+ const clearKey = (n: NodeInfo) => (n.connected ? n : { ...n, key: "" });
+ state.allNodes = state.allNodes.map(clearKey);
+ state.selectedNodes = state.selectedNodes.map(clearKey);
+ emit("DOCUMENT_CHANGE");
+ emit("SELECTION_CHANGE");
+ });
}
main();
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..942aa6e
--- /dev/null
+++ b/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/constants.ts","./src/createformaticu.ts","./src/custom.d.ts","./src/types.ts","./src/utilities.ts","./src/main/main.ts","./src/main/endpoints/clearprefilledkeys.ts","./src/main/endpoints/copypage.ts","./src/main/endpoints/editortype.ts","./src/main/endpoints/formattext.ts","./src/main/endpoints/getconnectednodes.ts","./src/main/endpoints/getscreenshots.ts","./src/main/endpoints/getselectednodes.ts","./src/main/endpoints/highlightnode.ts","./src/main/endpoints/notify.ts","./src/main/endpoints/preformatkey.ts","./src/main/endpoints/setnodesdata.ts","./src/main/endpoints/updatenodes.ts","./src/main/utils/createendpoint.ts","./src/main/utils/delayed.ts","./src/main/utils/nodeparents.ts","./src/main/utils/nodetools.ts","./src/main/utils/pages.ts","./src/main/utils/settingstools.ts","./src/main/utils/textformattingtools.ts","./src/tools/comparens.ts","./src/tools/getconflictingnodes.ts","./src/tools/getconnectednodes.ts","./src/tools/getpullchanges.ts","./src/tools/getpushchanges.ts","./src/ui/styles.css.d.ts","./src/ui/client/apischema.custom.ts","./src/ui/client/apischema.generated.ts","./src/ui/client/client.ts","./src/ui/client/decodeapikey.ts","./src/ui/client/errorcodes.ts","./src/ui/client/types.ts","./src/ui/client/usequeryapi.ts","./src/ui/components/actionsbottom/actionsbottom.css.d.ts","./src/ui/components/autocompleteselect/autocompleteselect.css.d.ts","./src/ui/components/badge/badge.css.d.ts","./src/ui/components/branchselect/branchselect.css.d.ts","./src/ui/components/dialog/dialog.css.d.ts","./src/ui/components/editor/editor.css.d.ts","./src/ui/components/fullpageloading/fullpageloading.css.d.ts","./src/ui/components/infotooltip/infotooltip.css.d.ts","./src/ui/components/keyoptionsbutton/keyoptionsbutton.css.d.ts","./src/ui/components/locatenodebutton/locatenodebutton.css.d.ts","./src/ui/components/namespaceselect/namespaceselect.css.d.ts","./src/ui/components/nodelist/nodelist.css.d.ts","./src/ui/components/nodelist/noderow.css.d.ts","./src/ui/components/popover/popover.css.d.ts","./src/ui/components/resizehandle/resizehandle.css.d.ts","./src/ui/components/topbar/topbar.css.d.ts","./src/ui/hooks/usealltags.ts","./src/ui/hooks/usealltranslations.ts","./src/ui/hooks/useconnectedmutation.ts","./src/ui/hooks/useconnectednodes.ts","./src/ui/hooks/usecopypage.ts","./src/ui/hooks/useeditormode.ts","./src/ui/hooks/usefigmanotify.ts","./src/ui/hooks/useformattext.ts","./src/ui/hooks/usehasbranchingenabled.ts","./src/ui/hooks/usehasnamespacesenabled.ts","./src/ui/hooks/usehighlightnodemutation.ts","./src/ui/hooks/useinterpolatedtranslation.ts","./src/ui/hooks/useprefilledkey.ts","./src/ui/hooks/useselectednodes.ts","./src/ui/hooks/usesetnodesdatamutation.ts","./src/ui/hooks/usetranslation.ts","./src/ui/hooks/useupdatenodesmutation.ts","./src/ui/hooks/usewindowsize.ts","./src/ui/state/globalstate.ts","./src/ui/state/sizes.ts","./src/ui/views/routes.ts","./src/ui/views/connect/connect.css.d.ts","./src/ui/views/connect/searchrow.css.d.ts","./src/ui/views/index/index.css.d.ts","./src/ui/views/pull/pull.css.d.ts","./src/ui/views/push/changes.css.d.ts","./src/ui/views/settings/projectsettings.css.d.ts","./src/ui/views/settings/settings.css.d.ts","./src/ui/views/settings/stringseditor.css.d.ts","./src/ui/views/stringdetails/stringdetails.css.d.ts","./src/web/examplescreenshot.ts","./src/web/iframecontent.ts","./src/web/main.ts","./src/web/urlconfig.ts","./src/tools/createprovider.tsx","./src/ui/plugin.tsx","./src/ui/ui.tsx","./src/ui/components/actionsbottom/actionsbottom.tsx","./src/ui/components/autocompleteselect/autocompleteselect.tsx","./src/ui/components/badge/badge.tsx","./src/ui/components/branchselect/branchselect.tsx","./src/ui/components/dialog/dialog.tsx","./src/ui/components/editor/editor.tsx","./src/ui/components/editor/pluraleditor.tsx","./src/ui/components/editor/translationplurals.tsx","./src/ui/components/fullpageloading/fullpageloading.tsx","./src/ui/components/infotooltip/infotooltip.tsx","./src/ui/components/keyoptionsbutton/keyoptionsbutton.tsx","./src/ui/components/locatenodebutton/locatenodebutton.tsx","./src/ui/components/namespaceselect/namespaceselect.tsx","./src/ui/components/nodelist/nodelist.tsx","./src/ui/components/nodelist/noderow.tsx","./src/ui/components/popover/popover.tsx","./src/ui/components/resizehandle/resizehandle.tsx","./src/ui/components/shared/htmltext.tsx","./src/ui/components/topbar/topbar.tsx","./src/ui/icons/svgicons.tsx","./src/ui/views/router.tsx","./src/ui/views/connect/connect.tsx","./src/ui/views/connect/searchrow.tsx","./src/ui/views/copyview/copyview.tsx","./src/ui/views/createcopy/createcopy.tsx","./src/ui/views/index/index.tsx","./src/ui/views/index/keyinput.tsx","./src/ui/views/index/listitem.tsx","./src/ui/views/pagesetup/pagesetup.tsx","./src/ui/views/pull/pull.tsx","./src/ui/views/push/changes.tsx","./src/ui/views/push/push.tsx","./src/ui/views/settings/expandable.tsx","./src/ui/views/settings/projectsection.tsx","./src/ui/views/settings/projectsettings.tsx","./src/ui/views/settings/pushsection.tsx","./src/ui/views/settings/settings.tsx","./src/ui/views/settings/stringseditor.tsx","./src/ui/views/settings/stringssection.tsx","./src/ui/views/stringdetails/stringdetails.tsx","./src/web/ui.tsx"],"version":"5.9.3"}
\ No newline at end of file