UNPKG

merchi_product_editor

Version:

A React component for editing product images using Fabric.js

454 lines (387 loc) 14.7 kB
import { readPsd } from 'ag-psd'; import { MappedPreview } from '../types'; /** * Find a layer by name within a PSD layer structure * * @param layers The array of PSD layers to search through * @param name The name of the layer to find * @returns The found layer or null if not found */ function findLayerByName(layers: any[] | undefined, name: string): any | null { if (!layers) return null; for (const layer of layers) { if (layer.name === name) { return layer; } if (layer.children) { const found = findLayerByName(layer.children, name); if (found) return found; } } return null; } /** * Load an image from a URL * * @param url URL of the image to load * @returns Promise that resolves to an HTMLImageElement */ async function loadImage(url: string): Promise<HTMLImageElement> { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = (e) => reject(new Error(`Failed to load image from URL: ${url}, error: ${e}`)); img.src = url; }); } /** * Validates if a data URL represents a valid image * * @param dataUrl The data URL to validate * @returns Promise that resolves to true if valid, false otherwise */ async function isValidImageDataUrl(dataUrl: string): Promise<boolean> { return new Promise((resolve) => { if (!dataUrl || !dataUrl.startsWith('data:image/')) { console.warn('Invalid data URL format'); resolve(false); return; } const img = new Image(); img.onload = () => { // Check if the image has actual dimensions if (img.width > 0 && img.height > 0) { resolve(true); } else { console.warn('Image loaded but has zero dimensions'); resolve(false); } }; img.onerror = () => { console.warn('Failed to load image from data URL'); resolve(false); }; img.src = dataUrl; }); } /** * Creates a fallback preview image with debugging information * * @param width Width of the image * @param height Height of the image * @param draftPreviewId ID of the draft preview * @param error Optional error message * @returns PNG data URL */ function createFallbackImage(width: number, height: number, draftPreviewId: number | undefined, error?: string): string { const canvas = document.createElement('canvas'); canvas.width = Math.max(width || 300, 300); canvas.height = Math.max(height || 200, 200); const ctx = canvas.getContext('2d'); if (!ctx) { // Even more basic fallback if context fails const basicCanvas = document.createElement('canvas'); basicCanvas.width = 300; basicCanvas.height = 200; const basicCtx = basicCanvas.getContext('2d'); if (basicCtx) { basicCtx.fillStyle = '#ffcccc'; basicCtx.fillRect(0, 0, 300, 200); basicCtx.fillStyle = '#990000'; basicCtx.font = '16px Arial'; basicCtx.textAlign = 'center'; basicCtx.fillText('Preview failed', 150, 100); return basicCanvas.toDataURL('image/png'); } return ''; } // Background ctx.fillStyle = '#f8f8f8'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw a grid pattern ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; const gridSize = 20; for (let x = 0; x < canvas.width; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } for (let y = 0; y < canvas.height; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } // Border ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 2; ctx.strokeRect(0, 0, canvas.width, canvas.height); // Preview text ctx.fillStyle = '#666666'; ctx.font = 'bold 24px Arial'; ctx.textAlign = 'center'; ctx.fillText('Preview', canvas.width / 2, canvas.height / 2 - 15); // Preview ID if (draftPreviewId !== undefined) { ctx.font = '16px Arial'; ctx.fillText(`ID: ${draftPreviewId}`, canvas.width / 2, canvas.height / 2 + 15); } // Error message if provided if (error) { ctx.fillStyle = '#cc0000'; ctx.font = '14px Arial'; ctx.fillText(error, canvas.width / 2, canvas.height / 2 + 40); } return canvas.toDataURL('image/png'); } /** * Converts a data URL to a canvas element * * @param dataUrl The data URL to convert * @returns Promise that resolves to a Canvas element */ async function dataUrlToCanvas(dataUrl: string): Promise<HTMLImageElement> { // First load the data URL as an image return await loadImage(dataUrl); } /** * Process mapped previews, replacing layers in PSD files with rendered layers * and converting the results to PNG images * * @param mappedPreviews Array of mapped previews with draftPreview and draftPreviewLayers * @returns Promise that resolves to an array of processed previews with PNG data URLs */ export async function renderDraftPreviewsWithLayers( mappedPreviews: MappedPreview[] ): Promise<Array<{ draftPreviewId: number | undefined; pngDataUrl: string }>> { const results: Array<{ draftPreviewId: number | undefined; pngDataUrl: string }> = []; // Process each preview one by one for (const mappedPreview of mappedPreviews) { const { draftPreview, draftPreviewLayers } = mappedPreview; // Skip if there's no file to process if (!draftPreview?.file?.viewUrl) { console.warn('Skipping preview without a file URL', draftPreview?.id); continue; } // Check if there are any actual rendered layers we need to use const hasRenderedLayers = draftPreviewLayers.some(layer => layer.renderedLayer !== null); // Only try the simple approach if it's not a PSD file let simplePngDataUrl: string | null = null; // If we need to do layer replacement, proceed with PSD processing try { // Fetch the PSD file const response = await fetch(draftPreview.file.viewUrl, { mode: 'cors', credentials: 'same-origin', headers: { 'Accept': '*/*', } }); if (!response.ok) { throw new Error(`Failed to fetch PSD file: ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); // Check if we actually got a PSD file (PSD files start with '8BPS') const firstBytes = new Uint8Array(arrayBuffer, 0, 4); const signature = String.fromCharCode(firstBytes[0]) + String.fromCharCode(firstBytes[1]) + String.fromCharCode(firstBytes[2]) + String.fromCharCode(firstBytes[3]); if (signature !== '8BPS') { // Use the simple approach result if we have it if (simplePngDataUrl) { results.push({ draftPreviewId: draftPreview.id, pngDataUrl: simplePngDataUrl }); continue; } throw new Error('Not a PSD file and simple approach failed'); } // Read the PSD file const psd = readPsd(arrayBuffer, { skipCompositeImageData: false, skipLayerImageData: false, skipThumbnail: false }); // Create a canvas to draw the composite image const canvas = document.createElement('canvas'); canvas.width = psd.width || 500; // Default size if missing canvas.height = psd.height || 400; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas 2d context'); } // Draw a background to ensure we have something visible ctx.fillStyle = 'transparent'; ctx.fillRect(0, 0, canvas.width, canvas.height); // First draw the composite image as a base if (psd.imageData) { const imageData = new ImageData( new Uint8ClampedArray(psd.imageData.data), psd.width, psd.height ); ctx.putImageData(imageData, 0, 0); } // Now draw base layers that aren't being replaced if (psd.children && psd.children.length > 0) { for (const layer of psd.children) { // Skip if this layer is hidden or matches one of our replaceable layers const shouldReplace = draftPreviewLayers.some(dpl => dpl.layerName === layer.name && dpl.renderedLayer !== null ); if (!shouldReplace && !layer.hidden && layer.canvas) { // Draw the original layer ctx.drawImage(layer.canvas, layer.left || 0, layer.top || 0); ctx.globalAlpha = 1; } } } // Now process and draw each replacement layer if (hasRenderedLayers) { for (const draftPreviewLayer of draftPreviewLayers) { // Skip if no layer name or no rendered layer if (!draftPreviewLayer.layerName || !draftPreviewLayer.renderedLayer) { continue; } // Find the corresponding layer in the PSD const psdLayer = findLayerByName(psd.children, draftPreviewLayer.layerName); if (!psdLayer) { console.warn(`Layer "${draftPreviewLayer.layerName}" not found in PSD`); continue; } // Load the rendered image try { // Validate the rendered layer image URL if (!draftPreviewLayer.renderedLayer.draft || !draftPreviewLayer.renderedLayer.draft.startsWith('data:image/')) { throw new Error('Invalid image URL format'); } // Convert the data URL to a canvas element instead of an image const layerCanvas = await dataUrlToCanvas(draftPreviewLayer.renderedLayer.draft); // Validate the canvas dimensions if (layerCanvas.width <= 0 || layerCanvas.height <= 0) { console.error('Canvas has invalid dimensions'); throw new Error('Invalid canvas dimensions'); } // Use the position and dimensions of the original PSD layer const left = psdLayer.left || 0; const top = psdLayer.top || 0; const width = (psdLayer.right || 0) - (psdLayer.left || 0); const height = (psdLayer.bottom || 0) - (psdLayer.top || 0); if (width <= 0 || height <= 0) { console.warn(`Invalid layer dimensions: ${width}x${height}, skipping`); continue; } // Calculate how to fit the canvas within the layer bounds const aspectRatio = layerCanvas.width / layerCanvas.height; const targetAspectRatio = width / height; let drawWidth, drawHeight, offsetX = 0, offsetY = 0; if (aspectRatio > targetAspectRatio) { // Canvas is wider than target area drawWidth = width; drawHeight = width / aspectRatio; offsetY = (height - drawHeight) / 2; } else { // Canvas is taller than target area drawHeight = height; drawWidth = height * aspectRatio; offsetX = (width - drawWidth) / 2; } // Draw the rendered layer canvas // ctx.globalAlpha = psdLayer.opacity !== undefined ? psdLayer.opacity / 255 : 1; ctx.restore(); ctx.drawImage(layerCanvas, left + offsetX, top + offsetY, drawWidth, drawHeight); ctx.globalAlpha = 1; ctx.save(); } catch (error) { console.error(`Failed to load rendered layer image for "${draftPreviewLayer.layerName}"`, error); // If we fail to load the replacement, draw the original layer as fallback if (psdLayer.canvas) { ctx.globalAlpha = psdLayer.opacity !== undefined ? psdLayer.opacity / 255 : 1; ctx.drawImage(psdLayer.canvas, psdLayer.left || 0, psdLayer.top || 0); ctx.globalAlpha = 1; } } } } // Verify that something was drawn const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); let hasVisiblePixels = false; for (let i = 3; i < imageData.data.length; i += 4) { if (imageData.data[i] > 0) { hasVisiblePixels = true; break; } } if (!hasVisiblePixels) { console.warn('WARNING: Canvas appears to be completely transparent!'); // Use the simple approach result if available if (simplePngDataUrl) { results.push({ draftPreviewId: draftPreview.id, pngDataUrl: simplePngDataUrl }); continue; } // As a last resort, draw a visible placeholder ctx.fillStyle = 'rgba(200, 200, 200, 0.5)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(150, 150, 150, 1)'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; ctx.fillText('Preview', canvas.width / 2, canvas.height / 2); } // Convert the canvas to a PNG data URL const pngDataUrl = canvas.toDataURL('image/png'); // Validate the PNG data URL const isValid = await isValidImageDataUrl(pngDataUrl); if (!isValid) { console.warn('Generated PNG data URL is not valid!'); // Use the simple approach as fallback if available if (simplePngDataUrl) { results.push({ draftPreviewId: draftPreview.id, pngDataUrl: simplePngDataUrl }); } else { // Create a debug fallback image with error information const fallbackPng = createFallbackImage(500, 400, draftPreview.id, 'Invalid PNG data'); results.push({ draftPreviewId: draftPreview.id, pngDataUrl: fallbackPng }); } } else { // Add the result to our array results.push({ draftPreviewId: draftPreview.id, pngDataUrl }); } } catch (error) { console.error('Failed to process PSD file for draft preview:', error); // Use the simple approach as fallback if available if (simplePngDataUrl) { results.push({ draftPreviewId: draftPreview.id, pngDataUrl: simplePngDataUrl }); } else { // Create a debug fallback image with error information const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const fallbackPng = createFallbackImage(500, 400, draftPreview.id, errorMessage); results.push({ draftPreviewId: draftPreview.id, pngDataUrl: fallbackPng }); } } } return results; }