511 lines
18 KiB
JavaScript
511 lines
18 KiB
JavaScript
|
||
// Shows the UI
|
||
figma.showUI(__html__, { width: 400, height: 600 });
|
||
|
||
// Helper to log to UI
|
||
function log(msg, level = 'info') {
|
||
figma.ui.postMessage({ type: 'log', message: msg, level: level });
|
||
}
|
||
|
||
// Helper to find a page or create it
|
||
function getOrCreatePage(name) {
|
||
let page = figma.root.children.find(p => p.name === name);
|
||
if (!page) {
|
||
page = figma.createPage();
|
||
page.name = name;
|
||
log(`Created new page: "${name}"`, 'info');
|
||
}
|
||
return page;
|
||
}
|
||
|
||
// Helper to find a component by name (recursive search or search in local components)
|
||
// Note: In a real plugin, you might need to search libraries, but here we assume local components
|
||
// or components available in the file.
|
||
async function findComponent(name) {
|
||
// Search local components first
|
||
// This is a simplified search. In a large file, this might be slow.
|
||
// We can optimize by caching components map.
|
||
const localComponents = figma.root.findAll(n => n.type === 'COMPONENT' || n.type === 'COMPONENT_SET');
|
||
|
||
for (const node of localComponents) {
|
||
if (node.name === name) return node;
|
||
if (node.type === 'COMPONENT_SET') {
|
||
const variant = node.children.find(c => c.name === name); // This might need robust variant matching
|
||
if (variant) return variant;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Helper to load image
|
||
async function createImagePaint(imageBytes, scaleMode = 'FILL') {
|
||
const image = figma.createImage(imageBytes);
|
||
return {
|
||
type: 'IMAGE',
|
||
scaleMode: scaleMode,
|
||
imageHash: image.hash,
|
||
};
|
||
}
|
||
|
||
async function processRows(mode, data, images) {
|
||
const { rows, metadata, headers } = data;
|
||
|
||
log(`Starting ${mode === 'create' ? 'Automation' : 'Image Replacement'}...`, 'info');
|
||
log(`Received ${rows.length} rows and ${Object.keys(images).length} images.`, 'info');
|
||
|
||
const pageY = {
|
||
'Ports': 0,
|
||
'ATF': 0,
|
||
'BTF': 0,
|
||
'BTF_Banner': 0
|
||
};
|
||
const SPACING = 50;
|
||
|
||
// Store created/updated instances to export later
|
||
const processedInstances = [];
|
||
|
||
// Helper to generate name
|
||
// Format: Cycle_CodeName_ProductName_Color_Variation_FeatureAssociation_BTF/ATF_Dimension.PNG
|
||
const generateName = (row, type, dimensionOverride = null) => {
|
||
const parts = [];
|
||
|
||
// 1. Cycle
|
||
if (metadata.cycle) parts.push(metadata.cycle);
|
||
// 2. CodeName
|
||
if (metadata.codeName) parts.push(metadata.codeName);
|
||
// 3. ProductName
|
||
if (metadata.productName) parts.push(metadata.productName);
|
||
// 4. Color
|
||
if (metadata.color) parts.push(metadata.color);
|
||
|
||
// 5. Variation
|
||
// "if there’s data on C column... add this; if not, omit"
|
||
const label = row[0]; // FeatureAssociation
|
||
const variation = row[2];
|
||
if (variation) {
|
||
parts.push(variation);
|
||
}
|
||
|
||
// 6. FeatureAssociation (Col A)
|
||
if (label) parts.push(label);
|
||
|
||
// 7. BTF/ATF
|
||
// "id the asset being exported Is from data on column E, it should be “ATF”; all others, “BTF”"
|
||
parts.push(type === 'ATF' ? 'ATF' : 'BTF');
|
||
|
||
// 8. Dimension
|
||
if (label === 'Ports') {
|
||
parts.push('2000x1000');
|
||
} else if (dimensionOverride) {
|
||
parts.push(dimensionOverride);
|
||
} else {
|
||
parts.push('UnknownDim');
|
||
}
|
||
|
||
return parts.join('_');
|
||
};
|
||
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
// New Column Mapping:
|
||
// New Column Mapping (Indices 0-11 based on request):
|
||
// A=0: Label (Feature Number)
|
||
// C=2: Variation
|
||
// F=5: ATF Trigger
|
||
// G=6: BTF Trigger
|
||
// H=7: BTF_Banner Trigger
|
||
// I=8: Image Filename
|
||
// J=9: Screen Replacement Filename
|
||
// K=10: Overlay Component
|
||
// L=11: Template
|
||
|
||
const label = row[0];
|
||
const variation = row[2];
|
||
const colATF = row[5]; // ATF (Col F)
|
||
const colBTF = row[6]; // BTF (Col G)
|
||
const colBTFBanner = row[7]; // BTF_Banner (Col H)
|
||
const imageFilename = row[8]; // Image (Col I)
|
||
const screenRepFilename = row[9]; // Screen Replacement (Col J)
|
||
const overlayName = row[10]; // Overlay (Col K)
|
||
const template = row[11]; // Template (Col L)
|
||
|
||
// Helper to find image data
|
||
const getImageData = (name) => {
|
||
if (!name) return null;
|
||
let key = Object.keys(images).find(k => k === name);
|
||
if (!key) key = Object.keys(images).find(k => k.includes(name));
|
||
return key ? images[key] : null;
|
||
};
|
||
|
||
const imageBytes = getImageData(imageFilename);
|
||
const screenRepBytes = getImageData(screenRepFilename);
|
||
|
||
// If replacing images, we generally need at least one image to do something,
|
||
// or we might be just updating overlays?
|
||
// User: "only with the images provided... If there's a screen replacement... grab file name".
|
||
// If NO main image but YES screen replacement, we proceed? Yes.
|
||
if (mode === 'replace' && !imageBytes && !screenRepBytes) {
|
||
continue;
|
||
}
|
||
|
||
// Determine Scale Mode
|
||
// "If template (Col L) is RS or LF -> FIT. All others -> FILL"
|
||
let scaleMode = 'FILL';
|
||
if (template === 'RS' || template === 'LF') {
|
||
scaleMode = 'FIT';
|
||
}
|
||
|
||
// Helper to process a row item
|
||
const processItem = async (pageName, componentBaseName, type, dimension, isPorts = false) => {
|
||
const page = getOrCreatePage(pageName);
|
||
let targetInstance = null;
|
||
let instanceName = "";
|
||
|
||
if (isPorts) {
|
||
// Ports Logic: Find existing "PB3.0_Ports" (or already renamed one)
|
||
const generatedName = generateName(row, 'BTF');
|
||
|
||
// 1. Try to find if we already processed/renamed it
|
||
targetInstance = page.findOne(n => n.name === generatedName);
|
||
|
||
// 2. If not, find the template "PB3.0_Ports"
|
||
if (!targetInstance) {
|
||
targetInstance = page.findOne(n => n.name === 'PB3.0_Ports');
|
||
}
|
||
|
||
if (targetInstance) {
|
||
if (mode === 'create') {
|
||
targetInstance.name = generatedName; // Rename for export consistency
|
||
log(`Found/Renamed PB3.0_Ports: ${generatedName}`, 'info');
|
||
} else {
|
||
log(`Found PB3.0_Ports: ${targetInstance.name}`, 'info');
|
||
}
|
||
processedInstances.push(targetInstance);
|
||
} else {
|
||
log(`Warning: Target 'PB3.0_Ports' not found on page ${pageName}`, 'warning');
|
||
return;
|
||
}
|
||
|
||
instanceName = targetInstance.name;
|
||
|
||
} else {
|
||
// Standard Logic for other items
|
||
instanceName = generateName(row, type, dimension);
|
||
|
||
if (mode === 'replace') {
|
||
// Find existing instance/frame/group by name
|
||
targetInstance = page.findOne(n => n.name === instanceName);
|
||
|
||
if (targetInstance) {
|
||
log(`Found existing asset: ${instanceName}`, 'info');
|
||
processedInstances.push(targetInstance);
|
||
} else {
|
||
log(`Warning: Asset "${instanceName}" not found for replacement.`, 'warning');
|
||
return;
|
||
}
|
||
} else {
|
||
// Create Mode
|
||
const component = await findComponent(componentBaseName);
|
||
if (!component) {
|
||
log(`Error: Component "${componentBaseName}" not found.`, 'error');
|
||
return;
|
||
}
|
||
|
||
let instance = component.createInstance();
|
||
instance = instance.detachInstance();
|
||
instance.name = instanceName;
|
||
|
||
log(`Created instance: ${instanceName}`, 'success');
|
||
|
||
// Fill Image
|
||
if (imageBytes) {
|
||
const imageNode = instance.findOne(n => n.name === "Image");
|
||
if (imageNode) {
|
||
imageNode.fills = [await createImagePaint(imageBytes, scaleMode)];
|
||
log(` - Filled image (Mode: ${scaleMode})`, 'info');
|
||
}
|
||
}
|
||
|
||
// Overlay Logic
|
||
if (overlayName) {
|
||
const overlayComp = await findComponent(overlayName);
|
||
if (overlayComp) {
|
||
let overlayInst = overlayComp.createInstance();
|
||
overlayInst = overlayInst.detachInstance();
|
||
|
||
const imageNode = instance.findOne(n => n.name === "Image");
|
||
if (imageNode) {
|
||
// ... (Overlay placement logic) ...
|
||
let currentNode = imageNode;
|
||
let dx = 0;
|
||
let dy = 0;
|
||
while (currentNode && currentNode.id !== instance.id) {
|
||
dx += currentNode.x;
|
||
dy += currentNode.y;
|
||
currentNode = currentNode.parent;
|
||
}
|
||
|
||
// Scaling
|
||
const maxW = instance.width * 0.20;
|
||
if (overlayInst.width > maxW) {
|
||
const scale = maxW / overlayInst.width;
|
||
overlayInst.resize(maxW, overlayInst.height * scale);
|
||
}
|
||
|
||
// Centering
|
||
const centerX = dx + (imageNode.width - overlayInst.width) / 2;
|
||
const centerY = dy + (imageNode.height - overlayInst.height) / 2;
|
||
|
||
overlayInst.x = centerX;
|
||
overlayInst.y = centerY;
|
||
|
||
// Group
|
||
page.appendChild(instance);
|
||
page.appendChild(overlayInst);
|
||
|
||
const group = figma.group([instance, overlayInst], page);
|
||
group.name = instanceName;
|
||
group.y = pageY[pageName];
|
||
pageY[pageName] += group.height + SPACING;
|
||
processedInstances.push(group);
|
||
|
||
log(` - Added overlay and grouped`, 'success');
|
||
return; // Done for this item
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no overlay
|
||
page.appendChild(instance);
|
||
instance.y = pageY[pageName];
|
||
pageY[pageName] += instance.height + SPACING;
|
||
processedInstances.push(instance);
|
||
targetInstance = instance;
|
||
}
|
||
}
|
||
|
||
// Update Main Image
|
||
if (imageBytes && targetInstance) {
|
||
// Search for "Image" layer. Might be direct child or inside a group/frame.
|
||
const imageNode = targetInstance.findOne(n => n.name === "Image");
|
||
if (imageNode) {
|
||
imageNode.fills = [await createImagePaint(imageBytes, scaleMode)];
|
||
|
||
// Center logic (Ports only - from previous code)
|
||
if (isPorts && imageNode.parent && imageNode.parent.type === 'FRAME') {
|
||
const parent = imageNode.parent;
|
||
imageNode.x = (parent.width - imageNode.width) / 2;
|
||
imageNode.y = (parent.height - imageNode.height) / 2;
|
||
}
|
||
log(` - Updated Main Image for ${instanceName}`, 'success');
|
||
} else {
|
||
log(` - Warning: "Image" layer not found in ${instanceName}`, 'warning');
|
||
}
|
||
}
|
||
|
||
// Update Screen Replacement
|
||
if (screenRepBytes && targetInstance) {
|
||
// "place it on Figma on top of all the main image element"
|
||
// Strategy: Find "Image" node. Create new Rectangle/Frame named "ScreenReplacement"
|
||
// same size/pos as "Image" (or fitting?), insert it AFTER "Image" in parent so it renders on top.
|
||
|
||
const imageNode = targetInstance.findOne(n => n.name === "Image");
|
||
if (imageNode) {
|
||
const parent = imageNode.parent;
|
||
if (parent) {
|
||
// Check if we already have a replacement layer to update
|
||
let screenLayer = parent.children.find(c => c.name === "ScreenReplacement");
|
||
|
||
if (!screenLayer) {
|
||
// Create new
|
||
screenLayer = figma.createRectangle();
|
||
screenLayer.name = "ScreenReplacement";
|
||
parent.appendChild(screenLayer); // Appends to end (top)
|
||
|
||
// Match dimensions/position of Image node
|
||
screenLayer.resize(imageNode.width, imageNode.height);
|
||
screenLayer.x = imageNode.x;
|
||
screenLayer.y = imageNode.y;
|
||
|
||
// Ensure it is strictly above ImageNode in Z-order
|
||
// (parent.appendChild puts it at top, which is fine, but let's be safe if other things exist)
|
||
// If we want it *just* on top of Image, we could use insertChild.
|
||
// But "on top of all the main image" implies potentially covering it.
|
||
}
|
||
|
||
screenLayer.fills = [await createImagePaint(screenRepBytes, "FIT")]; // Using FIT for screens usually? default to FIT or FILL? User didnt specify. FIT is safer for screens.
|
||
log(` - Applied Screen Replacement for ${instanceName}`, 'success');
|
||
}
|
||
} else {
|
||
log(` - Warning: Cannot do Screen Replacement, "Image" node not found.`, 'warning');
|
||
}
|
||
}
|
||
|
||
// Handle Overlay (Only Create Mode usually, per previous logic "does not create instances" in replace)
|
||
// If Create Mode AND Overlay exists
|
||
if (mode === 'create' && overlayName && !isPorts) { // Ports didnt have overlay logic before
|
||
const overlayComp = await findComponent(overlayName);
|
||
if (overlayComp) {
|
||
const overlayInst = overlayComp.createInstance();
|
||
const detachedOverlay = overlayInst.detachInstance(); // Detach per previous logic pattern
|
||
|
||
// Place overlay logic (centering over "Image" node)
|
||
const imageNode = targetInstance.findOne(n => n.name === "Image");
|
||
if (imageNode) {
|
||
// Calculate position relative to Page is tricky if we don't group first.
|
||
// But previous logic grouped them.
|
||
|
||
// Let's adopt previous logic: Group [Instance, Overlay]
|
||
// We need to calculate absolute positions?
|
||
// Or just center overlay over image node?
|
||
|
||
// Scaling: Max 20% of instance width
|
||
const maxW = targetInstance.width * 0.20;
|
||
if (detachedOverlay.width > maxW) {
|
||
const scale = maxW / detachedOverlay.width;
|
||
detachedOverlay.resize(maxW, detachedOverlay.height * scale);
|
||
}
|
||
|
||
// Find global pos of ImageNode relative to targetInstance
|
||
// imageNode is inside targetInstance.
|
||
let dx = 0;
|
||
let dy = 0;
|
||
let curr = imageNode;
|
||
while (curr && curr.id !== targetInstance.id) {
|
||
dx += curr.x;
|
||
dy += curr.y;
|
||
curr = curr.parent;
|
||
}
|
||
|
||
detachedOverlay.x = targetInstance.x + dx + (imageNode.width - detachedOverlay.width) / 2;
|
||
detachedOverlay.y = targetInstance.y + dy + (imageNode.height - detachedOverlay.height) / 2;
|
||
|
||
// Add to page
|
||
page.appendChild(detachedOverlay);
|
||
|
||
// Group
|
||
const group = figma.group([targetInstance, detachedOverlay], page);
|
||
group.name = instanceName;
|
||
|
||
// Update processing list (remove individual, add group)
|
||
const idx = processedInstances.indexOf(targetInstance);
|
||
if (idx > -1) processedInstances[idx] = group; // Replace tracker
|
||
|
||
log(` - Added Overlay: ${overlayName}`, 'success');
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Dispatch processing based on columns
|
||
log(`Processing Row ${i + 12}: ${label}`, 'info');
|
||
|
||
// 1. Ports
|
||
if (label === 'Ports') {
|
||
await processItem('PB3.0_Ports', 'PB3.0_Ports', 'BTF', null, true);
|
||
}
|
||
|
||
// 2. ATF (Col F)
|
||
if (colATF) {
|
||
let compName = '';
|
||
if (['RSC', 'LSC', 'B'].includes(template)) compName = 'ATF_B';
|
||
else if (template === 'LS') compName = 'ATF_LS';
|
||
else if (template === 'RS') compName = 'ATF_RS';
|
||
const dim = colATF;
|
||
if (compName) await processItem('ATF', compName, 'ATF', dim);
|
||
}
|
||
|
||
// 3. BTF (Col G)
|
||
if (colBTF) {
|
||
let compName = '';
|
||
if (['RSC', 'LSC', 'B'].includes(template)) compName = 'BTF_B';
|
||
else if (template === 'LS') compName = 'BTF_LS';
|
||
else if (template === 'RS') compName = 'BTF_RS';
|
||
const dim = colBTF;
|
||
if (compName) await processItem('BTF', compName, 'BTF', dim);
|
||
}
|
||
|
||
// 4. BTF_Banner (Col H)
|
||
if (colBTFBanner) {
|
||
let compName = '';
|
||
if (colBTFBanner === '1464x250') compName = 'BTFB_Thin';
|
||
else if (colBTFBanner === '1464x600') {
|
||
if (template === 'B') compName = 'BTFB_B';
|
||
else if (template === 'LS') compName = 'BTFB_LS';
|
||
else if (template === 'RS') compName = 'BTFB_RS';
|
||
else if (template === 'LSC') compName = 'BTFB_LSC';
|
||
else if (template === 'RSC') compName = 'BTFB_RSC';
|
||
}
|
||
const dim = colBTFBanner;
|
||
if (compName) await processItem('BTF_Banner', compName, 'BTF', dim);
|
||
}
|
||
}
|
||
|
||
// Save created instances ID for export (only if we want to track them)
|
||
// In replace mode, we might want to track them too?
|
||
figma.clientStorage.setAsync('lastCreatedInstanceIds', processedInstances.map(n => n.id));
|
||
|
||
figma.ui.postMessage({ type: 'complete' });
|
||
}
|
||
|
||
|
||
figma.ui.onmessage = async (msg) => {
|
||
if (msg.type === 'run-automation' || msg.type === 'replace-images') {
|
||
const mode = msg.type === 'run-automation' ? 'create' : 'replace';
|
||
try {
|
||
await processRows(mode, msg.data, msg.images);
|
||
} catch (err) {
|
||
log(`Error: ${err.message}`, 'error');
|
||
console.error(err);
|
||
figma.ui.postMessage({ type: 'error', message: err.message });
|
||
}
|
||
}
|
||
|
||
if (msg.type === 'export-zip') {
|
||
try {
|
||
log('Preparing assets for export...', 'info');
|
||
|
||
// New Export Logic: Scan specific pages for assets
|
||
// This allows exporting without running automation in the current session
|
||
const targetPageNames = ['PB3.0_Ports', 'ATF', 'BTF', 'BTF_Banner'];
|
||
const nodesToExport = [];
|
||
|
||
for (const name of targetPageNames) {
|
||
const page = figma.root.children.find(p => p.name === name);
|
||
if (page) {
|
||
// Get all top-level exportable nodes (Frames, Instances, Groups)
|
||
const children = page.children.filter(node =>
|
||
node.visible &&
|
||
(node.type === 'FRAME' || node.type === 'INSTANCE' || node.type === 'GROUP' || node.type === 'COMPONENT') &&
|
||
node.name.toLowerCase() !== 'background reference'
|
||
);
|
||
nodesToExport.push(...children);
|
||
if (children.length > 0) {
|
||
log(`Found ${children.length} assets on page "${name}"`, 'info');
|
||
}
|
||
}
|
||
}
|
||
|
||
if (nodesToExport.length === 0) {
|
||
log('No assets found on target pages (Ports, ATF, BTF, BTF_Banner).', 'warning');
|
||
return;
|
||
}
|
||
|
||
const exportData = [];
|
||
for (const node of nodesToExport) {
|
||
// Export as PNG
|
||
// Using 2x scale as per previous logic
|
||
const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: 2 } });
|
||
exportData.push({
|
||
name: `${node.name}.png`,
|
||
bytes: bytes
|
||
});
|
||
}
|
||
|
||
figma.ui.postMessage({ type: 'export-ready', files: exportData });
|
||
log(`Sent ${exportData.length} files to UI for zipping.`, 'success');
|
||
} catch (err) {
|
||
log(`Export Error: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
};
|