From 872862b33e2ae127c25a6abdaeb30891d871f400 Mon Sep 17 00:00:00 2001 From: yiqunw700 <165221276+yiqunw700@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:18:04 -0800 Subject: [PATCH] Remember user's action to break strings into different translations (#1694) This is achieved by trying to use the saved resource name whenever possible. No overriding by group options. Fixes: #1677 --- .../src/localization-module.ts | 263 +++++++++++++++--- .../extended-layout-plugin/src/ui.html | 34 +-- 2 files changed, 237 insertions(+), 60 deletions(-) diff --git a/support-figma/extended-layout-plugin/src/localization-module.ts b/support-figma/extended-layout-plugin/src/localization-module.ts index 4e9bd5095..930e7dad9 100644 --- a/support-figma/extended-layout-plugin/src/localization-module.ts +++ b/support-figma/extended-layout-plugin/src/localization-module.ts @@ -20,6 +20,8 @@ import * as DesignSpecs from "./design-spec-module"; const STRING_RES_PLUGIN_DATA_KEY = "vsw-string-res"; const STRING_RES_EXTRAS_PLUGIN_DATA_KEY = "vsw-string-res-extras"; const EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY = "vsw-string-explicit-exclusion"; +// This saves the characters of the text nodes. If it changes, the res name can become invalid. +const STRING_RES_CHARACTERS_PLUGIN_DATA_KEY = "vsw-string-res-characters"; const CONSOLE_TAG = `${Utils.CONSOLE_TAG}-LOCALIZATION`; const OPTION_EXCLUDE_HASHTAG_NAME = "excludeHashTagName"; const OPTION_READ_CUSTOMIZATION = "readJsonCustomization"; @@ -56,15 +58,20 @@ export async function generateLocalizationData( options.includes(OPTION_READ_CUSTOMIZATION) ); - // String resource name to StringResource map. - let stringResourceMap = new Map(); - + let outputStringResMap = new Map(); // strings.xml files does not allow duplicates so no checks for duplicates here. for (let uploadedString of uploadedStrings) { let strRes = uploadedString as unknown as StringResource; - stringResourceMap.set(strRes.name, strRes); + outputStringResMap.set(strRes.name, strRes); } + // String resource name to StringResource map. + let stringResourceMap = new Map(); + // Text nodes that have changed text or text styles. + let staleTextNodes = new Array(); + // Text nodes that have not been assigned with a res name before. + let newTextNodes = new Array(); + if (clippyTextNodes.topLevelComponentIds.length === 0) { // No clippy top level components found in customization file. Localize all the nodes recursively from root. for (let page of figma.root.children) { @@ -72,6 +79,8 @@ export async function generateLocalizationData( await localizeNodeAsync( child, stringResourceMap, + staleTextNodes, + newTextNodes, options, clippyTextNodes["customizedTextNodeArray"] ); @@ -85,6 +94,8 @@ export async function generateLocalizationData( await localizeNodeAsync( node, stringResourceMap, + staleTextNodes, + newTextNodes, options, clippyTextNodes["customizedTextNodeArray"] ); @@ -99,6 +110,8 @@ export async function generateLocalizationData( await localizeNodeAsync( localComponent, stringResourceMap, + staleTextNodes, + newTextNodes, options, clippyTextNodes["customizedTextNodeArray"] ); @@ -106,11 +119,27 @@ export async function generateLocalizationData( } } + for (const textNode of staleTextNodes) { + await localizeStaleTextNodeAsync(textNode, stringResourceMap, options); + } + + for (const textNode of newTextNodes) { + await localizeNewTextNodeAsync( + textNode, + stringResourceMap, + options, + undefined, + false + ); + } + + await mergeStringResMaps(outputStringResMap, stringResourceMap, options); + // Convert to an array of key-value pairs - const stringResourceArray = Array.from(stringResourceMap); + const outputStringResArray = Array.from(outputStringResMap); figma.ui.postMessage({ msg: "localization-output", - output: stringResourceArray, + output: outputStringResArray, }); } @@ -171,7 +200,7 @@ export async function ungroupTextNode( // Otherwise find a string resource name that doesn't duplicate. while (stringResourceMap.has(stringResName)) { index += 1; - stringResName = preferredName + "_" + index; + stringResName = `${preferredName}_${index}`; } saveResName(node, stringResName, isNodeExcluded); @@ -231,10 +260,12 @@ export async function excludeTextNode(nodeId: string, excluded: boolean) { async function localizeNodeAsync( node: BaseNode, stringResourceMap: Map, + staleTextNodes: Array, + newTextNodes: Array, options: string[], clippyTextNodes: BaseNode[] ) { - if (node.type == "TEXT") { + if (node.type === "TEXT") { // Nodes with name starting with # is for local customization. if ( options.includes(OPTION_EXCLUDE_HASHTAG_NAME) && @@ -244,7 +275,12 @@ async function localizeNodeAsync( } else if (clippyTextNodes.includes(node)) { Utils.log(CONSOLE_TAG, "Ignore client side customization:", node.name); } else { - await localizeTextNodeAsync(node, stringResourceMap, options); + await localizeTextNodeAsync( + node, + stringResourceMap, + staleTextNodes, + newTextNodes + ); } } else { // Recurse into any children. @@ -254,6 +290,8 @@ async function localizeNodeAsync( await localizeNodeAsync( child, stringResourceMap, + staleTextNodes, + newTextNodes, options, clippyTextNodes ); @@ -263,21 +301,102 @@ async function localizeNodeAsync( } async function localizeTextNodeAsync( + node: TextNode, + stringResourceMap: Map, + staleTextNodes: Array, + newTextNodes: Array +) { + var preferredName = getResName(node); + if (preferredName) { + let normalizedText = normalizeTextNode(node); + const isNodeExcluded = isExplicitlyExcluded(node); + var cachedCharacters = node.getPluginData( + STRING_RES_CHARACTERS_PLUGIN_DATA_KEY + ); + // This node has been exported as string resource before and it hasn't changed, + // use its res name and put in the string res map. + if (cachedCharacters === JSON.stringify(normalizedText)) { + let existingStringRes = stringResourceMap.get(preferredName); + if (!existingStringRes) { + const stringRes = { + name: preferredName, + translatable: true, + text: normalizedText, + textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }], + extras: getSavedExtras(node), + textLength: node.characters.length, + }; + stringResourceMap.set(preferredName, stringRes); + } else { + existingStringRes.textNodes.push({ + nodeId: node.id, + isExcluded: isNodeExcluded, + }); + } + } else { + staleTextNodes.push(node); + } + } else { + newTextNodes.push(node); + } +} + +async function localizeStaleTextNodeAsync( node: TextNode, stringResourceMap: Map, options: string[] ) { - let normalizedText = normalizeTextNode(node); const isNodeExcluded = isExplicitlyExcluded(node); + var preferredName = getResName(node); + // Text node has res name, but the text or text style has changed. Try to use the saved res name first. + if (preferredName) { + if (!stringResourceMap.has(preferredName)) { + let normalizedText = normalizeTextNode(node); + saveCharacters(node, JSON.stringify(normalizedText)); + + const stringRes = { + name: preferredName, + translatable: true, + text: normalizedText, + textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }], + extras: getSavedExtras(node), + textLength: node.characters.length, + }; + stringResourceMap.set(preferredName, stringRes); + return; + } + // Treat it as a new text node to assign a string res name. + localizeNewTextNodeAsync( + node, + stringResourceMap, + options, + preferredName, + isNodeExcluded + ); + } else { + Utils.error(CONSOLE_TAG, `Node ${node.id} expects a saved res name.`); + } +} + +// This node is new to export as string resource. It does not have a res name saved before. +async function localizeNewTextNodeAsync( + node: TextNode, + stringResourceMap: Map, + options: string[], + preferredName: string | undefined, + isNodeExcluded: boolean +) { + let normalizedText = normalizeTextNode(node); + saveCharacters(node, JSON.stringify(normalizedText)); + + var isMatched = false; if (options.includes(OPTION_GROUP_SAME_TEXT)) { - // First find and tag. It will override the existing string resource name from the string resource entry read from file. + // Find and tag if option is to group the same text. const containedValue = [...stringResourceMap.values()].filter( (value) => textMatches(value, normalizedText) && value.translatable ); + // Pick the first match... if (containedValue.length > 0) { - if (!containedValue[0].textNodes) { - containedValue[0].textNodes = []; - } containedValue[0].textNodes.push({ nodeId: node.id, isExcluded: isNodeExcluded, @@ -285,43 +404,94 @@ async function localizeTextNodeAsync( Utils.log(CONSOLE_TAG, "Found and tag:", containedValue[0].name); saveResName(node, containedValue[0].name, isNodeExcluded); saveExtras(node, containedValue[0].extras); - // Set the text length to set a proper char limit range. - containedValue[0].textLength = node.characters.length; - return; + isMatched = true; } } - var preferredName = getResName(node); - if (!preferredName) { - preferredName = fromNode(node); - } else if ( - stringResourceMap.has(preferredName) && - endsWithNumbers(preferredName) - ) { - // We need to find a new name so reset preferred name to default. - preferredName = fromNode(node); + if (!isMatched) { + if (!preferredName || endsWithNumbers(preferredName)) { + preferredName = fromNode(node); + } + var stringResName = preferredName; + var index = 0; + + // Otherwise find a string resource name that doesn't duplicate. + while (stringResourceMap.has(stringResName)) { + index += 1; + stringResName = preferredName + "_" + index; + } + saveResName(node, stringResName, isNodeExcluded); + + var stringRes = { + name: stringResName, + translatable: true, + text: normalizedText, + textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }], + extras: getSavedExtras(node), + textLength: node.characters.length, + }; + stringResourceMap.set(stringResName, stringRes); } +} - var index = 0; - var stringResName = preferredName; +// The outputStringResMap has the uploaded strings and toBeMergedStringResMap has the strings from +// current figma file only. Merge toBeMergedStringResMap into the outputStringResMap. +async function mergeStringResMaps( + outputStringResMap: Map, + toBeMergedStringResMap: Map, + options: string[] +) { + for (const [resName, stringRes] of toBeMergedStringResMap) { + if (outputStringResMap.has(resName)) { + if (textMatches(stringRes, outputStringResMap.get(resName)!!.text)) { + outputStringResMap.set(resName, stringRes); + continue; + } + } - // Otherwise find a string resource name that doesn't duplicate. - while (stringResourceMap.has(stringResName)) { - index += 1; - stringResName = preferredName + "_" + index; - } + if (options.includes(OPTION_GROUP_SAME_TEXT)) { + const containedValue = [...outputStringResMap.values()].filter( + (value) => textMatches(value, stringRes.text) && value.translatable + ); - saveResName(node, stringResName, isNodeExcluded); - - var stringRes = { - name: stringResName, - translatable: true, - text: normalizedText, - textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }], - extras: getSavedExtras(node), - textLength: node.characters.length, - }; - stringResourceMap.set(stringResName, stringRes); + // There is exactly 1:1 match. + if (containedValue.length == 1) { + const duplicates = [...toBeMergedStringResMap.values()].filter( + (value) => textMatches(value, stringRes.text) + ); + if (duplicates.length == 1) { + stringRes.name = containedValue[0].name; + await updateStringRes(stringRes); + outputStringResMap.set(stringRes.name, stringRes); + continue; + } + } + } + if (outputStringResMap.has(resName)) { + // Rename the string Res and put it to the output string res map. + var preferredName = resName; + if (endsWithNumbers(preferredName)) { + var node = await figma.getNodeByIdAsync(stringRes.textNodes[0].nodeId); + if (node && node.type === "TEXT") { + preferredName = fromNode(node); + } + } + var newResName = preferredName; + var index = 0; + while ( + outputStringResMap.has(newResName) || + toBeMergedStringResMap.has(newResName) + ) { + index += 1; + newResName = `${preferredName}_${index}`; + } + stringRes.name = newResName; + await updateStringRes(stringRes); + outputStringResMap.set(newResName, stringRes); + } else { + outputStringResMap.set(resName, stringRes); + } + } } function fromNode(node: TextNode): string { @@ -401,6 +571,10 @@ function saveExtras(node: TextNode, extras?: StringResourceExtras) { } } +function saveCharacters(node: TextNode, characters: string) { + node.setPluginData(STRING_RES_CHARACTERS_PLUGIN_DATA_KEY, characters); +} + function setExplicitExcluded(node: TextNode, excluded: boolean) { if (excluded) { node.setPluginData(EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY, "true"); @@ -534,6 +708,7 @@ async function clearNodeAsync(node: SceneNode) { saveResName(node, "", false); saveExtras(node, undefined); setExplicitExcluded(node, false); + saveCharacters(node, ""); } else { // Recurse into any children. let maybeParent = node as ChildrenMixin; diff --git a/support-figma/extended-layout-plugin/src/ui.html b/support-figma/extended-layout-plugin/src/ui.html index ca0fced3c..877711540 100644 --- a/support-figma/extended-layout-plugin/src/ui.html +++ b/support-figma/extended-layout-plugin/src/ui.html @@ -334,7 +334,7 @@
- +
@@ -962,25 +962,25 @@ const block = document.createElement('div'); if (!isSingleNode) { - const editBtn = document.createElement('span'); - editBtn.style.width = '20px'; - editBtn.style.height = '20px'; - editBtn.style.padding = '0px'; - editBtn.classList.add('icon-button'); - editBtn.addEventListener('mouseover', (event) => { + const breakBtn = document.createElement('span'); + breakBtn.style.width = '20px'; + breakBtn.style.height = '20px'; + breakBtn.style.padding = '0px'; + breakBtn.classList.add('icon-button'); + breakBtn.addEventListener('mouseover', (event) => { showToast('Use the break button for a different translation.', false); }); - editBtn.addEventListener('mouseout', (event) => { + breakBtn.addEventListener('mouseout', (event) => { hideToast(); }); // Revise the cursor to default to be consistent with the icon below. - editBtn.style.cursor = 'default'; - const editIcon = document.createElement('span'); - editIcon.classList.add('icon', 'icon--break'); - editIcon.style.width = '16px'; - editIcon.style.height = '16px'; - editBtn.appendChild(editIcon); - editBtn.onclick = () => { + breakBtn.style.cursor = 'default'; + const breakIcon = document.createElement('span'); + breakIcon.classList.add('icon', 'icon--break'); + breakIcon.style.width = '16px'; + breakIcon.style.height = '16px'; + breakBtn.appendChild(breakIcon); + breakBtn.onclick = () => { parent.postMessage({ pluginMessage: { msg: 'localization-ungroup-node', @@ -988,8 +988,10 @@ stringResMap: Array.from(outputStringData) } }, '*'); + // The editButton will be removed. Clear the toast. + hideToast(); }; - block.append(editBtn); + block.append(breakBtn); } const checkbox = document.createElement('input');