From c7bf7ededc46509b94ffdbfdfa40c673b5228af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Wed, 6 May 2026 10:14:54 +0200 Subject: [PATCH 1/8] refactor: improve data fetching strategy on Index page - Updated useEffect to refetch selection and connected-nodes data when returning to the Index page, ensuring changes made elsewhere are reflected. - Modified queryClient.invalidateQueries in useSetNodesDataMutation to mark connected-nodes data as stale without immediate refetch, preventing UI freezes during typing. --- src/ui/hooks/useSetNodesDataMutation.ts | 11 +++++++++-- src/ui/views/Index/Index.tsx | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) 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/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 = From f431e4e33c047a27f9578c7bbf70f71602171bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Wed, 6 May 2026 10:39:54 +0200 Subject: [PATCH 2/8] refactor: update connectNodes function to use async/await - Changed connectNodes and handleConnectOnly functions to use async/await for better handling of asynchronous operations. - Ensured local Figma node data is updated before navigating away from the Push view to prevent stale data issues. --- src/ui/views/Push/Push.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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(); From e8fa294e3e87cc033fcc0580f1027446d3a058e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Wed, 6 May 2026 11:18:18 +0200 Subject: [PATCH 3/8] refactor: optimize node visibility checks and improve text node retrieval - Simplified visibility checks in shouldIncludeNode function to enhance performance. - Updated findTextNodes to skip hidden subtrees early, reducing unnecessary parent checks. - Adjusted handleConnect to utilize new key translation fetching logic, improving data handling during connection. --- src/main/utils/nodeTools.ts | 59 ++++++++++++------------ src/tools/getPushChanges.ts | 7 +++ src/ui/views/Connect/Connect.tsx | 78 ++++++++++++++++++-------------- 3 files changed, 82 insertions(+), 62 deletions(-) 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/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx index 2eeae9e..2fd93c8 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,12 @@ 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, + ) } /> ))} From f30094f375022cfaf8624b0e0624b49ef11484f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Wed, 6 May 2026 11:22:28 +0200 Subject: [PATCH 4/8] feat: add clearPrefilledKeys functionality and enhance prefill key handling - Registered clearPrefilledKeysEndpoint in main.ts to manage prefilled key states. - Updated usePrefilledKey hook to allow dynamic enabling of queries based on the new parameter. - Enhanced StringsSection to clear prefilled keys when the prefill toggle is disabled, ensuring data consistency. --- src/main/endpoints/clearPrefilledKeys.ts | 43 ++++++++++++++++++++++++ src/main/main.ts | 2 ++ src/ui/hooks/usePrefilledKey.ts | 3 +- src/ui/views/Index/ListItem.tsx | 1 + src/ui/views/Settings/StringsSection.tsx | 16 ++++++++- 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/main/endpoints/clearPrefilledKeys.ts 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/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/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/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) => { From d502558926038c3e6b32a0c4c46ce607670e9c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Wed, 6 May 2026 12:36:05 +0200 Subject: [PATCH 5/8] feat: add tests for screenshot deduplication and prefill key handling - Implemented a test to ensure screenshot entries are deduplicated when multiple nodes share the same key in the Push component. - Added a regression test to verify no changes are shown when re-opening Push immediately after a successful push. - Created tests to confirm that prefilled keys are cleared for unconnected nodes when the prefill toggle is disabled, while preserving keys for connected nodes. --- cypress/e2e/push.cy.ts | 83 +++++++++++++++++++++++++++++++++++ cypress/e2e/settings.cy.ts | 89 +++++++++++++++++++++++++++++++++++++- src/web/main.ts | 30 ++++++++++++- 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/push.cy.ts b/cypress/e2e/push.cy.ts index 57972c7..fa587b4 100644 --- a/cypress/e2e/push.cy.ts +++ b/cypress/e2e/push.cy.ts @@ -149,6 +149,89 @@ describe("Push", () => { cy.iframe().findDcy("push_ok_button").should("be.visible").click(); }); + it("deduplicates screenshot entries when a key has multiple nodes in a frame", () => { + // Two unconnected text nodes that share the same key+characters end up in + // the same frame screenshot's `keys` list. The push payload must list + // that screenshot only once for the key (with both positions), not once + // per node — otherwise the API receives duplicate KeyScreenshotDto + // objects with the same uploadedImageId. + // Unique key per run so this test stays idempotent against the docker + // server state that survives across cypress runs. + const dedupKey = `dedup_save_${Date.now()}`; + const nodes = [ + createTestNode({ text: "Save", key: dedupKey }), + createTestNode({ text: "Save", key: dedupKey }), + ]; + visitWithState({ + config: { ...SIGNED_IN, updateScreenshots: true }, + selectedNodes: nodes, + allNodes: nodes, + }); + + cy.intercept("POST", "**/v2/projects/single-step-import-resolvable").as( + "pushKeys", + ); + + cy.iframe().findDcy("index_push_button").should("be.visible").click(); + // Wait for the diff view to actually render before reaching for the + // submit button — without this Cypress can latch onto a stale iframe + // body and the visibility check throws getBoundingClientRect. + cy.iframe().contains("New keys").should("be.visible"); + cy.iframe().findDcy("push_submit_button").should("be.visible").click(); + + cy.wait("@pushKeys").then((interception) => { + const body = interception.request.body as { + keys: Array<{ + name: string; + screenshots: Array<{ + uploadedImageId: number; + positions: Array<{ x: number; y: number }>; + }>; + }>; + }; + const target = body.keys.find((k) => k.name === dedupKey); + expect(target, "key in payload").to.exist; + // Single screenshot reference for the key, not one per node. + expect(target!.screenshots).to.have.length(1); + // Both node positions are reported under that single screenshot. + expect(target!.screenshots[0].positions).to.have.length(2); + }); + + cy.iframe().contains("Successfully updated").should("be.visible"); + }); + + it("shows no changes when re-opening Push immediately after a successful push", () => { + // Regression test for the race where the fire-and-forget connectNodes + // mutation hadn't completed by the time the user navigated back into + // Push, so the next Push view recomputed the diff against stale local + // node data and re-displayed the just-pushed changes. + const uniqueKey = `race_${Date.now()}`; + const nodes = [createTestNode({ text: "Hello race", key: uniqueKey })]; + visitWithState({ + config: SIGNED_IN, + selectedNodes: nodes, + allNodes: nodes, + }); + + // First push: shows the new key as a change. + cy.iframe().findDcy("index_push_button").should("be.visible").click(); + // Wait for the diff view before reaching for the contained key. + cy.iframe().contains("New keys").should("be.visible"); + cy.iframe() + .findDcy("changes_new_keys") + .contains(uniqueKey) + .should("be.visible"); + cy.iframe().findDcy("push_submit_button").should("be.visible").click(); + + cy.iframe().contains("Successfully updated 1 key(s)").should("be.visible"); + cy.iframe().findDcy("push_ok_button").should("be.visible").click(); + + // Back at Index, push again right away — should be a no-op now. + cy.iframe().contains("Hello race").should("be.visible"); + cy.iframe().findDcy("index_push_button").should("be.visible").click(); + cy.iframe().contains("No changes necessary").should("be.visible"); + }); + it("doesn't push screenshot when disabled", () => { const nodes = [ createTestNode({ text: "Changed text 2", key: "on-the-road-title" }), diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts index ff5f375..d8be431 100644 --- a/cypress/e2e/settings.cy.ts +++ b/cypress/e2e/settings.cy.ts @@ -1,4 +1,4 @@ -import { DEFAULT_CREDENTIALS } from "@/web/urlConfig"; +import { createTestNode, DEFAULT_CREDENTIALS } from "@/web/urlConfig"; import { visitWithState } from "../common/tools"; describe("Settings", () => { @@ -128,6 +128,93 @@ describe("Settings", () => { cy.contains("Select texts for translation").should("be.visible"); }); + it("clears prefilled keys for unconnected nodes when prefill is toggled off", () => { + const nodes = [ + createTestNode({ text: "Unconnected node", key: "prefilled_key" }), + ]; + visitWithState({ + config: { + apiUrl: DEFAULT_CREDENTIALS.apiUrl, + apiKey: DEFAULT_CREDENTIALS.apiKey, + language: "en", + namespace: "", + documentInfo: true, + pageInfo: true, + prefillKeyFormat: true, + }, + selectedNodes: nodes, + allNodes: nodes, + }); + + // Sanity check: the previously prefilled key is shown. + cy.iframe() + .findDcy("index_unconnected_key_input") + .should("have.value", "prefilled_key"); + + // Open settings and toggle prefill off. + cy.iframe().findDcy("index_settings_button").should("be.visible").click(); + cy.iframe().findDcy("settings_expandable_strings").should("exist").click(); + cy.iframe().findDcy("settings_checkbox_prefill_key_name").click(); + + // Save and return to Index. + cy.iframe().findDcy("settings_button_save").click(); + cy.iframe().contains("Unconnected node").should("be.visible"); + + // The prefilled key has been cleared. + cy.iframe() + .findDcy("index_unconnected_key_input") + .should("have.value", ""); + }); + + it("preserves connected node keys when prefill is toggled off", () => { + const nodes = [ + createTestNode({ text: "Unconnected node", key: "prefilled_key" }), + createTestNode({ + text: "Connected node", + key: "connected_key", + connected: true, + }), + ]; + visitWithState({ + config: { + apiUrl: DEFAULT_CREDENTIALS.apiUrl, + apiKey: DEFAULT_CREDENTIALS.apiKey, + language: "en", + namespace: "", + documentInfo: true, + pageInfo: true, + prefillKeyFormat: true, + }, + selectedNodes: nodes, + allNodes: nodes, + }); + + // Both nodes are visible up front, with their respective keys. + cy.iframe() + .findDcy("index_unconnected_key_input") + .should("have.value", "prefilled_key"); + cy.iframe() + .findDcy("general_node_list_row_key") + .contains("connected_key") + .should("be.visible"); + + // Toggle prefill off. + cy.iframe().findDcy("index_settings_button").should("be.visible").click(); + cy.iframe().findDcy("settings_expandable_strings").should("exist").click(); + cy.iframe().findDcy("settings_checkbox_prefill_key_name").click(); + cy.iframe().findDcy("settings_button_save").click(); + cy.iframe().contains("Connected node").should("be.visible"); + + // Unconnected key cleared, connected key untouched. + cy.iframe() + .findDcy("index_unconnected_key_input") + .should("have.value", ""); + cy.iframe() + .findDcy("general_node_list_row_key") + .contains("connected_key") + .should("be.visible"); + }); + it("tests push settings configuration", () => { visitWithState({ config: { 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(); From f518dd1f718ee76dee479ea613a056e73e35cca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Thu, 7 May 2026 18:14:22 +0200 Subject: [PATCH 6/8] refactor: streamline code formatting and enhance component logic - Removed unnecessary line breaks in Cypress tests for better readability. - Simplified the handleConnect function call in the Connect component. - Optimized the useMemo hook in ProjectSettings for branch names. - Added a mountedRef to prevent unnecessary effect execution in the Index component. --- cypress/e2e/settings.cy.ts | 8 ++------ src/ui/views/Connect/Connect.tsx | 7 +------ src/ui/views/Index/Index.tsx | 13 ++++++++++++- src/ui/views/Settings/ProjectSettings.tsx | 7 ++----- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts index d8be431..a29ab19 100644 --- a/cypress/e2e/settings.cy.ts +++ b/cypress/e2e/settings.cy.ts @@ -161,9 +161,7 @@ describe("Settings", () => { cy.iframe().contains("Unconnected node").should("be.visible"); // The prefilled key has been cleared. - cy.iframe() - .findDcy("index_unconnected_key_input") - .should("have.value", ""); + cy.iframe().findDcy("index_unconnected_key_input").should("have.value", ""); }); it("preserves connected node keys when prefill is toggled off", () => { @@ -206,9 +204,7 @@ describe("Settings", () => { cy.iframe().contains("Connected node").should("be.visible"); // Unconnected key cleared, connected key untouched. - cy.iframe() - .findDcy("index_unconnected_key_input") - .should("have.value", ""); + cy.iframe().findDcy("index_unconnected_key_input").should("have.value", ""); cy.iframe() .findDcy("general_node_list_row_key") .contains("connected_key") diff --git a/src/ui/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx index 2fd93c8..f29f2d4 100644 --- a/src/ui/views/Connect/Connect.tsx +++ b/src/ui/views/Connect/Connect.tsx @@ -159,12 +159,7 @@ export const Connect = ({ node }: Props) => { key={key.id} data={key} onClick={() => - handleConnect( - key.id, - 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 f64c170..b3eaf8b 100644 --- a/src/ui/views/Index/Index.tsx +++ b/src/ui/views/Index/Index.tsx @@ -1,5 +1,11 @@ import { Fragment, h } from "preact"; -import { useCallback, useEffect, useState, useMemo } from "preact/hooks"; +import { + useCallback, + useEffect, + useState, + useMemo, + useRef, +} from "preact/hooks"; import { Banner, Button, @@ -89,6 +95,7 @@ export const Index = () => { const { setRoute } = useGlobalActions(); const allNodes = useConnectedNodes({ ignoreSelection: true }); + const mountedRef = useRef(false); // index page is not removed on certain routes (e.g. Connect dialog). // When returning to it, refetch selection + connected-nodes so changes @@ -96,6 +103,10 @@ export const Index = () => { // node-data write (see useSetNodesDataMutation) to avoid full-page tree // walks while the user is typing a key. useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } if (route[0] === "index") { selectionLoadable.refetch(); allNodes.refetch(); 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, From 6b7f3fe461972e2e3e1724321c777a9e4939c392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Thu, 7 May 2026 21:36:38 +0200 Subject: [PATCH 7/8] feat: add spike to test out widget functionality --- poc/widget-spike/.gitignore | 6 + poc/widget-spike/README.md | 247 ++++++++++++ poc/widget-spike/build.mjs | 90 +++++ poc/widget-spike/manifest.json | 15 + poc/widget-spike/package-lock.json | 533 ++++++++++++++++++++++++ poc/widget-spike/package.json | 15 + poc/widget-spike/src/edit.html | 71 ++++ poc/widget-spike/src/edit.ts | 42 ++ poc/widget-spike/src/ui.html | 105 +++++ poc/widget-spike/src/ui.ts | 69 ++++ poc/widget-spike/src/widget.tsx | 559 ++++++++++++++++++++++++++ poc/widget-spike/tsconfig.json | 7 + poc/widget-spike/tsconfig.ui.json | 15 + poc/widget-spike/tsconfig.widget.json | 19 + src/ui/views/Index/Index.tsx | 13 +- tsconfig.tsbuildinfo | 1 + 16 files changed, 1795 insertions(+), 12 deletions(-) create mode 100644 poc/widget-spike/.gitignore create mode 100644 poc/widget-spike/README.md create mode 100644 poc/widget-spike/build.mjs create mode 100644 poc/widget-spike/manifest.json create mode 100644 poc/widget-spike/package-lock.json create mode 100644 poc/widget-spike/package.json create mode 100644 poc/widget-spike/src/edit.html create mode 100644 poc/widget-spike/src/edit.ts create mode 100644 poc/widget-spike/src/ui.html create mode 100644 poc/widget-spike/src/ui.ts create mode 100644 poc/widget-spike/src/widget.tsx create mode 100644 poc/widget-spike/tsconfig.json create mode 100644 poc/widget-spike/tsconfig.ui.json create mode 100644 poc/widget-spike/tsconfig.widget.json create mode 100644 tsconfig.tsbuildinfo diff --git a/poc/widget-spike/.gitignore b/poc/widget-spike/.gitignore new file mode 100644 index 0000000..8424ebc --- /dev/null +++ b/poc/widget-spike/.gitignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +*.tsbuildinfo + +# Negate root-level ignores that would hide POC files +!manifest.json diff --git a/poc/widget-spike/README.md b/poc/widget-spike/README.md new file mode 100644 index 0000000..fc4a8ef --- /dev/null +++ b/poc/widget-spike/README.md @@ -0,0 +1,247 @@ +# Tolgee Widget Spike + +POC for the proposed Widget+Plugin architecture for the Tolgee Figma plugin. +**Throwaway code** — answers specific architecture questions and proves a +minimal end-to-end Tolgee Pull/Push flow on top of a widget. Not intended to +ship. + +## Why a widget at all + +Today's Tolgee plugin stores per-text-node `pluginData` and walks the entire +scenegraph (`findTextNodes(figma.currentPage.children)`) on every operation. +This causes the performance + storage pain we're trying to address. + +A widget-based model swaps: + +- Tree walk → `figma.root.findWidgetNodesByWidgetId(WIDGET_ID)` (O(K)). +- Per-node JSON `pluginData` → structured `useSyncedState` per widget. +- `formatText.ts` font-loading pipeline → `` with + declarative `fontWeight` / `italic` / `textDecoration`. +- Plugin-only lifecycle → live re-render on `setWidgetSyncedState` from any + push/pull, even without the plugin UI open. + +## Architecture findings (validated) + +### ✅ What works + +| Finding | Detail | +|---|---| +| Inline rich text | `` inside `` supports `fontWeight`, `italic`, `textDecoration: "underline"`, `fill`. The 250 LOC font-loading pipeline in `formatText.ts` becomes ~30 LOC of HTML-tag parsing + JSX. | +| Bulk re-render performance | 250 widgets, sequential `setWidgetSyncedState`: **67ms** (~0.27ms/widget). Linear; 1000 widgets ≈ 270ms. Push/Pull and live language switching are well within budget. | +| Widget index lookup | `figma.root.findWidgetNodesByWidgetId(WIDGET_ID)` is the indexed lookup we wanted. Combined with `loadAllPagesAsync()` it's the replacement for the full tree walk. | +| Plugin → Widget bridge | `WidgetNode.setWidgetSyncedState(state)` from widget code (incl. UI iframe message handlers) reliably triggers a re-render. The function is gated to `widgetId` matching, so combined widget+plugin manifest is required. | +| Combined manifest | `{ "containsWidget": true, "main": ..., "ui": ... }` works. Widget code and showUI iframe coexist; `figma.showUI` is reachable from widget property menu callbacks. | +| Auto-Layout migration | `parent.insertChild(idx, widget)` after `cloneWidget` preserves Auto-Layout flow when replacing a TextNode in-place. Position, padding, and stretching all behave correctly. | +| Layer-panel naming | Setting `WidgetNode.name = stripMarkup(translation)` via `useEffect` keeps the layer name aligned with the rendered text instead of "Tolgee Widget Spike". `` names the sub-layer with the Tolgee key. | +| Property-menu UX | Property menu is for *widget-instance* actions (Edit text, Show info). Plugin-level actions (Convert, Sync) belong in the spike UI / plugin UI — the property menu only shows when the widget is selected, which is incompatible with selection-driven actions like "Convert selected TEXT". | +| Tolgee API integration | Pull (`GET /v2/projects/translations`) and Push (`POST /v2/projects/single-step-import-resolvable`) work end-to-end from widget code with `X-API-Key` header and `networkAccess: ["*"]` in manifest. | + +### ❌ What does NOT work (API limits) + +| Limit | Implication | +|---|---| +| `figma.root` (and most plugin API) is forbidden during widget render | Translations cannot live in a shared root pluginData cache and be read synchronously from each widget render. They must live in `useSyncedState` per widget instance. **Storage scales with instances, not unique keys** — no win vs. today's per-text-node pluginData. | +| Widgets have no native drag-resize handles | Widget bounding-box is determined by the declarative render output. There's no `onResize` event. Width changes require explicit syncedState updates (UI input, not drag). This is a real UX regression vs. TextNode for source-language editing. | +| `` as root: no `` children | Inline editing means losing inline formatting during edit. Markup tags `...` would show as raw text. We chose `+` (formatted display) + Edit modal (key + translation editor) instead. | +| `setWidgetSyncedState` rejects `undefined` values | Must strip undefined keys before calling; `useSyncedState` falls back to its declared default. Helper `selfUpdate` does this. | +| `setWidgetSyncedState` replaces state, doesn't merge | Always spread current state: `{ ...node.widgetSyncedState, ...patch }`. | +| `documentAccess: "dynamic-page"` is required for new widgets | Forces async plugin API: `figma.getNodeByIdAsync` instead of `figma.getNodeById`, `figma.loadAllPagesAsync()` before cross-page reads. **Virally affects the existing main plugin** if we share the manifest. Estimated 30+ call sites in `src/main/`. | + +### ⚠️ Behaviour to know + +| Topic | Detail | +|---|---| +| Widget version snapshots | Widget instances embed a snapshot of the widget code at insert time. They do **not** auto-upgrade when a new version is published. Upgrade triggers: user clicks "Update widget" in resource panel, OR plugin code calls `setWidgetSyncedState` on an older instance (auto-upgrades). In practice: any Pull/Push run upgrades all touched widgets. **Architectural consequence**: never make breaking syncedState schema changes; always read defensively (`state.key ?? state.keyName`). | +| `__html__` injection | The default `@create-figma-plugin` build replaces `__html__` with the UI HTML automatically. Our custom esbuild build does it via `define: { __html__: JSON.stringify(html) }`. We use the same trick for a second `EDIT_HTML` constant. | +| `figma.showUI` lifetime | The promise returned from a property-menu callback must stay open until the UI signals close (via `CLOSE` message → `resolve()`). Resolving immediately after `figma.showUI` makes the modal flash and disappear. | +| `figma.ui.onmessage` registration | Must be set up *inside* the `figma.showUI` promise body, not in `useEffect`, because `figma.ui` only exists after `showUI` is called. | + +## Architecture decisions for production + +If we go ahead with this refactor: + +1. **Per-widget `useSyncedState` for translation data**. + `{ keyName, translation, fontSize, fontFamily, fontWeight, fill, horizontalAlignText, verticalAlignText, widgetWidth }`. Treated as snapshot; written by Pull, read by render. +2. **Combined manifest** with `containsWidget: true`, single `id` matching the + widget. Widget code carries the `figma.showUI` flow for the existing + plugin views (Settings, Pull review, Push review, Push diff). +3. **Migration command** in the new plugin UI: convert legacy text nodes + (with `tolgee_info` pluginData) to widgets. Already prototyped here as + `convertTextNodes(scope: "selection" | "page")`. +4. **Edit modal** for source-language editing of `keyName + translation` with + markup support. Inline `` rejected for v1 due to markup loss. +5. **No per-instance drag-resize**. Width changes via property menu, plugin + UI (slider/input), or the widget's sub-layer (Figma's native text + resize on the inner `` node — see "Resize via sub-layer" below). +6. **Schema discipline**: every `useSyncedState` reads defensively with a + fallback default; new fields are always optional + additive. + +## What this POC actually does + +### Widget render (`src/widget.tsx`) + +- Root `` with `` children parsed from `//` markup. +- `useEffect` keeps `WidgetNode.name = stripMarkup(translation)` in sync so + the layer panel shows real text. +- `` so the sub-layer has the Tolgee key. +- Property menu: Edit text, Open Spike UI, Show info. +- Hover style: pink stroke for visual indication that this is a Tolgee + widget. + +### Spike UI (`src/ui.html` + `src/ui.ts`) + +- **Settings**: API URL, API key, language. Persisted in + `figma.clientStorage` (`tolgee_spike_settings_v1`). Auto-loaded when + the spike UI opens. +- **Sync**: Pull / Push buttons. Both use the configured language. +- **Migration**: Convert selected TEXT, Convert ALL TEXT on page. +- **Debug**: Dump synced states, Benchmark bump. + +### Edit modal (`src/edit.html` + `src/edit.ts`) + +- Key input + translation textarea with markup hint. +- Cmd+Enter saves, Escape cancels. +- Pre-populated via `INIT` message from the widget on open. + +### Tolgee API calls (in widget code) + +```ts +// Pull +GET /v2/projects/translations?languages=&size=10000 +// Push +POST /v2/projects/single-step-import-resolvable + body: { keys: [{ name, translations: { [lang]: { text, resolution: "OVERRIDE" } } }] } +// Headers (both) +X-API-Key: +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..495c495
--- /dev/null
+++ b/poc/widget-spike/src/widget.tsx
@@ -0,0 +1,559 @@
+/// 
+/// 
+
+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({
+      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 };
+}
+
+async function selfUpdate(
+  myWidgetNodeId: string,
+  patch: Record,
+) {
+  const myNode = (await figma.getNodeByIdAsync(myWidgetNodeId)) as
+    | WidgetNode
+    | null;
+  if (!myNode) return;
+  const merged: Record = {
+    ...(myNode.widgetSyncedState ?? {}),
+    ...patch,
+    rev: ((myNode.widgetSyncedState?.rev as number) ?? 0) + 1,
+  };
+  // Figma rejects `undefined` values. Strip them so useSyncedState falls back to its default.
+  for (const k of Object.keys(merged)) {
+    if (merged[k] === undefined) delete merged[k];
+  }
+  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/ui/views/Index/Index.tsx b/src/ui/views/Index/Index.tsx
index b3eaf8b..f64c170 100644
--- a/src/ui/views/Index/Index.tsx
+++ b/src/ui/views/Index/Index.tsx
@@ -1,11 +1,5 @@
 import { Fragment, h } from "preact";
-import {
-  useCallback,
-  useEffect,
-  useState,
-  useMemo,
-  useRef,
-} from "preact/hooks";
+import { useCallback, useEffect, useState, useMemo } from "preact/hooks";
 import {
   Banner,
   Button,
@@ -95,7 +89,6 @@ export const Index = () => {
 
   const { setRoute } = useGlobalActions();
   const allNodes = useConnectedNodes({ ignoreSelection: true });
-  const mountedRef = useRef(false);
 
   // index page is not removed on certain routes (e.g. Connect dialog).
   // When returning to it, refetch selection + connected-nodes so changes
@@ -103,10 +96,6 @@ export const Index = () => {
   // node-data write (see useSetNodesDataMutation) to avoid full-page tree
   // walks while the user is typing a key.
   useEffect(() => {
-    if (!mountedRef.current) {
-      mountedRef.current = true;
-      return;
-    }
     if (route[0] === "index") {
       selectionLoadable.refetch();
       allNodes.refetch();
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

From 1396c3164ab15808e96d0fbe81fdb9ce79aa3992 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nico=20H=C3=BClscher?=
 <25116822+eweren@users.noreply.github.com>
Date: Thu, 7 May 2026 21:53:04 +0200
Subject: [PATCH 8/8] refactor: streamline widget state management by stripping
 undefined values

- Introduced stripUndefined function to remove undefined properties from widget state before syncing.
- Updated convertTextNodes and selfUpdate functions to utilize stripUndefined, ensuring cleaner state management.
---
 poc/widget-spike/src/widget.tsx | 42 ++++++++++++++++++++-------------
 1 file changed, 25 insertions(+), 17 deletions(-)

diff --git a/poc/widget-spike/src/widget.tsx b/poc/widget-spike/src/widget.tsx
index 495c495..18ab985 100644
--- a/poc/widget-spike/src/widget.tsx
+++ b/poc/widget-spike/src/widget.tsx
@@ -351,17 +351,19 @@ async function convertTextNodes(
 
     const props = readTextProps(textNode);
 
-    const widget = myNode.cloneWidget({
-      keyName,
-      translation,
-      fontSize: props.fontSize,
-      fontFamily: props.fontFamily,
-      fontWeight: props.fontWeight,
-      fill: props.fill,
-      horizontalAlignText: props.horizontalAlignText,
-      verticalAlignText: props.verticalAlignText,
-      widgetWidth: props.widgetWidth,
-    });
+    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) {
@@ -382,6 +384,16 @@ async function convertTextNodes(
   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,
@@ -390,15 +402,11 @@ async function selfUpdate(
     | WidgetNode
     | null;
   if (!myNode) return;
-  const merged: Record = {
+  const merged = stripUndefined({
     ...(myNode.widgetSyncedState ?? {}),
     ...patch,
     rev: ((myNode.widgetSyncedState?.rev as number) ?? 0) + 1,
-  };
-  // Figma rejects `undefined` values. Strip them so useSyncedState falls back to its default.
-  for (const k of Object.keys(merged)) {
-    if (merged[k] === undefined) delete merged[k];
-  }
+  });
   myNode.setWidgetSyncedState(merged);
 }