pencil_automator/code.js

511 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 theres 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');
}
}
};