| @@ -1,0 +1,400 @@ | ||
| 1 | +(function($, elementor) { | |
| 2 | + 'use strict'; | |
| 3 | + | |
| 4 | + // Ensure AI field localization exists | |
| 5 | + if (!window.KingAddonsAiImageField) { | |
| 6 | + return; | |
| 7 | + } | |
| 8 | + var data = window.KingAddonsAiImageField; | |
| 9 | + | |
| 10 | + /** | |
| 11 | + * Injects the Generate Image button and prompt UI into media controls | |
| 12 | + * @param {jQuery} $container Elementor panel container | |
| 13 | + */ | |
| 14 | + function injectImageControls($container) { | |
| 15 | + $container.find( | |
| 16 | + '.elementor-control.elementor-control-type-media, ' | |
| 17 | + + '.elementor-control.elementor-control-type-image, ' | |
| 18 | + + '.elementor-control.elementor-control-type-gallery' | |
| 19 | + ).each(function() { | |
| 20 | + var $ctrl = $(this); | |
| 21 | + // Skip if already injected | |
| 22 | + if ($ctrl.find('.kng-ai-image-btn-wrapper').length) { | |
| 23 | + return; | |
| 24 | + } | |
| 25 | + // Find the label of the media field (not nested labels like Resolution) | |
| 26 | + var $mediaTitle = $ctrl.find('.elementor-control-field.elementor-control-media > .elementor-control-title').first(); | |
| 27 | + if (!$mediaTitle.length) { | |
| 28 | + // fallback to any control title | |
| 29 | + $mediaTitle = $ctrl.find('.elementor-control-title').first(); | |
| 30 | + } | |
| 31 | + // Wrap button and prompt in container | |
| 32 | + var $outer = $('<div class="kng-ai-image-field-wrapper"></div>'); | |
| 33 | + // Insert button/prompt wrapper after the input wrapper if available, otherwise after the title | |
| 34 | + var $inputWrapper = $ctrl.find('.elementor-control-input-wrapper').first(); | |
| 35 | + if ($inputWrapper.length) { | |
| 36 | + $inputWrapper.after($outer); | |
| 37 | + } else { | |
| 38 | + $mediaTitle.after($outer); | |
| 39 | + } | |
| 40 | + var $wrapper = $('<div class="kng-ai-image-btn-wrapper" style="margin-top:8px;"></div>'); | |
| 41 | + // Button with icon, matching AI text feature | |
| 42 | + var $btn = $('<button type="button" class="kng-ai-image-generate-btn elementor-button elementor-size-sm"><img src="' + data.icon_url + '" style="width:16px;height:16px;margin-right:6px;vertical-align:middle;"/><span>Generate Image</span></button>'); | |
| 43 | + $outer.append($wrapper); | |
| 44 | + $wrapper.append($btn); | |
| 45 | + | |
| 46 | + $btn.on('click', function(e) { | |
| 47 | + e.preventDefault(); | |
| 48 | + // Remove existing prompt UI | |
| 49 | + $outer.find('.kng-ai-image-prompt-container').remove(); | |
| 50 | + | |
| 51 | + // FIRST_EDIT: Check API key validity before proceeding | |
| 52 | + var apiKeyValid = true; | |
| 53 | + $.ajax({ | |
| 54 | + url: KingAddonsAiImageField.ajax_url, | |
| 55 | + method: 'POST', | |
| 56 | + async: false, | |
| 57 | + dataType: 'json', | |
| 58 | + data: { | |
| 59 | + action: 'king_addons_ai_image_check_limits', | |
| 60 | + nonce: KingAddonsAiImageField.generate_nonce | |
| 61 | + }, | |
| 62 | + success: function(resp) { | |
| 63 | + apiKeyValid = resp.success && resp.data.api_key_valid === true; | |
| 64 | + }, | |
| 65 | + error: function() { | |
| 66 | + apiKeyValid = true; // on error assume valid | |
| 67 | + } | |
| 68 | + }); | |
| 69 | + if (!apiKeyValid) { | |
| 70 | + var settingsUrl = window.KingAddonsAiImageField && window.KingAddonsAiImageField.settings_url | |
| 71 | + ? window.KingAddonsAiImageField.settings_url | |
| 72 | + : '/wp-admin/admin.php?page=king-addons-ai-settings'; | |
| 73 | + var $errorMessage = $('<div class="kng-ai-image-error-message"></div>').css({ | |
| 74 | + background: '#e7f3fe', | |
| 75 | + color: '#084d7a', | |
| 76 | + padding: '10px 15px', | |
| 77 | + borderRadius: '4px', | |
| 78 | + border: '1px solid #b6e0fe', | |
| 79 | + margin: '8px 0', | |
| 80 | + fontSize: '13px', | |
| 81 | + fontWeight: '500', | |
| 82 | + display: 'flex', | |
| 83 | + alignItems: 'center', | |
| 84 | + justifyContent: 'space-between' | |
| 85 | + }); | |
| 86 | + $errorMessage.html( | |
| 87 | + '<span>' + ((window.KingAddonsAiImageField && KingAddonsAiImageField.missing_key_message) || 'The AI API key is missing or invalid. Please configure your API key in AI Settings.') + '</span>' + | |
| 88 | + '<a href="' + settingsUrl + '" style="color:#0073aa;text-decoration:underline;white-space:nowrap;margin-left:10px;" target="_blank">Settings</a>' | |
| 89 | + ); | |
| 90 | + $wrapper.after($errorMessage).hide().fadeIn(200); | |
| 91 | + setTimeout(function() { $errorMessage.fadeOut(200, function() { $(this).remove(); }); }, 5000); | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + | |
| 95 | + // Proceed to show prompt UI | |
| 96 | + // Create prompt UI | |
| 97 | + var $promptContainer = $('<div class="kng-ai-image-prompt-container"></div>').css({margin:'8px 0'}); | |
| 98 | + var $input = $('<input type="text" class="kng-ai-image-prompt-input" placeholder="Enter image prompt..." style="width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;margin-bottom:6px;"/>'); | |
| 99 | + var $quality = $('<select class="kng-ai-image-quality" style="margin-right:6px;"></select>'); | |
| 100 | + ['hd','standard'].forEach(function(q) { | |
| 101 | + $quality.append('<option value="'+q+'">'+q.charAt(0).toUpperCase()+q.slice(1)+'</option>'); | |
| 102 | + }); | |
| 103 | + var $resolution = $('<select class="kng-ai-image-resolution" style="margin-right:6px;"></select>'); | |
| 104 | + // Define resolutions with display labels | |
| 105 | + var resolutions = [ | |
| 106 | + { value: '1024x1024', label: '1024×1024 (square)' }, | |
| 107 | + { value: '1024x1792', label: '1024×1792 (portrait)' }, | |
| 108 | + { value: '1792x1024', label: '1792×1024 (landscape)' } | |
| 109 | + ]; | |
| 110 | + resolutions.forEach(function(res) { | |
| 111 | + $resolution.append('<option value="' + res.value + '">' + res.label + '</option>'); | |
| 112 | + }); | |
| 113 | + // Model selector reflecting the configured provider and default | |
| 114 | + var $modelSelect = $('<select class="kng-ai-image-model" style="margin-right:6px;"></select>'); | |
| 115 | + var imageModels = KingAddonsAiImageField.image_models; | |
| 116 | + if (!Array.isArray(imageModels) || !imageModels.length) { | |
| 117 | + imageModels = [ | |
| 118 | + { value: 'dall-e-3', label: 'DALL\u00b7E 3' }, | |
| 119 | + { value: 'gpt-image-1', label: 'GPT Image 1' } | |
| 120 | + ]; | |
| 121 | + } | |
| 122 | + imageModels.forEach(function(m) { | |
| 123 | + $modelSelect.append($('<option></option>').val(m.value).text(m.label)); | |
| 124 | + }); | |
| 125 | + // Apply default from settings | |
| 126 | + if (KingAddonsAiImageField.image_model) { | |
| 127 | + $modelSelect.val(KingAddonsAiImageField.image_model); | |
| 128 | + } | |
| 129 | + if (!$modelSelect.val() && imageModels.length) { | |
| 130 | + $modelSelect.val(imageModels[0].value); | |
| 131 | + } | |
| 132 | + // Define controls per model | |
| 133 | + var modelControls = { | |
| 134 | + 'dall-e-3': { | |
| 135 | + qualities: [ | |
| 136 | + { value: 'hd', label: 'HD (default)' }, | |
| 137 | + { value: 'standard', label: 'Standard' } | |
| 138 | + ], | |
| 139 | + sizes: [ | |
| 140 | + { value: '1024x1024', label: '1024×1024 (square)' }, | |
| 141 | + { value: '1024x1792', label: '1024×1792 (portrait)' }, | |
| 142 | + { value: '1792x1024', label: '1792×1024 (landscape)' } | |
| 143 | + ] | |
| 144 | + }, | |
| 145 | + 'gpt-image-1': { | |
| 146 | + qualities: [ | |
| 147 | + { value: 'high', label: 'High (default)' }, | |
| 148 | + { value: 'medium', label: 'Medium' }, | |
| 149 | + { value: 'low', label: 'Low' }, | |
| 150 | + { value: 'auto', label: 'Auto' }, | |
| 151 | + ], | |
| 152 | + sizes: [ | |
| 153 | + { value: 'auto', label: 'Auto (default)' }, | |
| 154 | + { value: '1024x1024', label: '1024×1024 (square)' }, | |
| 155 | + { value: '1536x1024', label: '1536×1024 (landscape)' }, | |
| 156 | + { value: '1024x1536', label: '1024×1536 (portrait)' } | |
| 157 | + ] | |
| 158 | + } | |
| 159 | + }; | |
| 160 | + // Function to update selects based on chosen model. Models that | |
| 161 | + // are not OpenAI's own (for example anything routed through | |
| 162 | + // OpenRouter) take no quality/size arguments, so those rows are | |
| 163 | + // hidden rather than sending options the provider will reject. | |
| 164 | + function updateControlsByModel() { | |
| 165 | + var m = $modelSelect.val(); | |
| 166 | + var controls = modelControls[m]; | |
| 167 | + | |
| 168 | + if (!controls) { | |
| 169 | + $quality.empty().closest('.kng-ai-image-field-row').hide(); | |
| 170 | + $resolution.empty().closest('.kng-ai-image-field-row').hide(); | |
| 171 | + return; | |
| 172 | + } | |
| 173 | + | |
| 174 | + $quality.closest('.kng-ai-image-field-row').show(); | |
| 175 | + $resolution.closest('.kng-ai-image-field-row').show(); | |
| 176 | + | |
| 177 | + // update quality options | |
| 178 | + $quality.empty(); | |
| 179 | + controls.qualities.forEach(function(opt) { | |
| 180 | + $quality.append('<option value="'+opt.value+'">'+opt.label+'</option>'); | |
| 181 | + }); | |
| 182 | + // update size options | |
| 183 | + $resolution.empty(); | |
| 184 | + controls.sizes.forEach(function(opt) { | |
| 185 | + $resolution.append('<option value="'+opt.value+'">'+opt.label+'</option>'); | |
| 186 | + }); | |
| 187 | + } | |
| 188 | + // Bind change handler and initialize | |
| 189 | + $modelSelect.on('change', updateControlsByModel); | |
| 190 | + updateControlsByModel(); | |
| 191 | + // Create transparent background checkbox (only for GPT Image 1) | |
| 192 | + var $bgCheckbox = $('<label class="kng-ai-image-bg-checkbox-label" style="margin-right: 6px;margin-bottom: 8px;margin-top: 8px; display:none;"><input type="checkbox" class="kng-ai-image-bg-checkbox" style="margin-right: 8px; border-color: #ccc;"/>Transparent background</label>'); | |
| 193 | + // Update show/hide of background checkbox | |
| 194 | + function updateBgCheckbox() { | |
| 195 | + if ($modelSelect.val() === 'gpt-image-1') { | |
| 196 | + $bgCheckbox.show(); | |
| 197 | + } else { | |
| 198 | + $bgCheckbox.hide().find('input').prop('checked', false); | |
| 199 | + } | |
| 200 | + } | |
| 201 | + $modelSelect.on('change', updateBgCheckbox); | |
| 202 | + updateBgCheckbox(); | |
| 203 | + // Prompt submit button with icon | |
| 204 | + var $submit = $('<button type="button" class="kng-ai-image-prompt-submit elementor-button elementor-button-primary elementor-size-sm"><img src="' + data.icon_url + '" style="width:16px;height:16px;margin-right:6px;vertical-align:middle;"/><span>Generate</span></button>').css({marginRight:'6px'}); | |
| 205 | + // Prompt cancel button with cross icon | |
| 206 | + var $cancel = $('<button type="button" class="kng-ai-image-prompt-cancel elementor-button elementor-button-link elementor-size-sm" title="Cancel"><span class="kng-ai-image-cancel-icon">✕</span></button>'); | |
| 207 | + // Append input and labelled rows for each control and buttons | |
| 208 | + $promptContainer.append( | |
| 209 | + $input, | |
| 210 | + $('<div class="kng-ai-image-field-row"></div>').append( | |
| 211 | + '<label class="kng-ai-image-field-label">Quality</label>', | |
| 212 | + $quality | |
| 213 | + ), | |
| 214 | + $('<div class="kng-ai-image-field-row"></div>').append( | |
| 215 | + '<label class="kng-ai-image-field-label">Size</label>', | |
| 216 | + $resolution | |
| 217 | + ), | |
| 218 | + $('<div class="kng-ai-image-field-row"></div>').append( | |
| 219 | + '<label class="kng-ai-image-field-label">Model</label>', | |
| 220 | + $modelSelect | |
| 221 | + ), | |
| 222 | + $('<div class="kng-ai-image-field-row"></div>').append($bgCheckbox), | |
| 223 | + $('<div class="kng-ai-image-field-row kng-ai-image-field-row--buttons"></div>').append( | |
| 224 | + $submit, | |
| 225 | + $cancel | |
| 226 | + ) | |
| 227 | + ); | |
| 228 | + $outer.append($promptContainer); | |
| 229 | + // Re-run now that the rows exist, so a model without quality / | |
| 230 | + // size options can hide them. | |
| 231 | + updateControlsByModel(); | |
| 232 | + // Add user note about processing time | |
| 233 | + $promptContainer.append($('<p class="kng-ai-image-prompt-note">Complex prompts may take up to 2 minutes to process.</p>')); | |
| 234 | + // Mark the Generate Image button as active (container open) | |
| 235 | + $btn.addClass('is-active'); | |
| 236 | + | |
| 237 | + $submit.on('click', function() { | |
| 238 | + var prompt = $input.val().trim(); | |
| 239 | + if (!prompt) { $input.css('border-color','red'); return; } | |
| 240 | + var selectedResolution = $resolution.val(); | |
| 241 | + var selectedQuality = $quality.val(); | |
| 242 | + var selectedModel = $modelSelect.val(); | |
| 243 | + // Disable UI and animate while processing | |
| 244 | + $submit.prop('disabled', true).addClass('is-processing'); | |
| 245 | + // Change button text preserving icon | |
| 246 | + $submit.find('span').text('Generating...'); | |
| 247 | + // Animate image preview border | |
| 248 | + var $preview = $ctrl.find('.elementor-control-media__preview'); | |
| 249 | + if ($preview.length) { | |
| 250 | + $preview.addClass('king-addons-field-pulsing'); | |
| 251 | + } | |
| 252 | + // AJAX request | |
| 253 | + $.post(KingAddonsAiImageField.ajax_url, { | |
| 254 | + action: KingAddonsAiImageField.generate_action, | |
| 255 | + nonce: KingAddonsAiImageField.generate_nonce, | |
| 256 | + prompt: prompt, | |
| 257 | + quality: selectedQuality, | |
| 258 | + size: selectedResolution, | |
| 259 | + model: selectedModel, | |
| 260 | + background: $bgCheckbox.find('input').is(':checked') ? 'transparent' : '', | |
| 261 | + }).done(function(response) { | |
| 262 | + if (response.success && response.data.attachment_id) { | |
| 263 | + var attachmentID = response.data.attachment_id; | |
| 264 | + var imageURL = response.data.url; | |
| 265 | + | |
| 266 | + // Get the current element being edited | |
| 267 | + var currentElement = elementor.getPanelView().getCurrentPageView().getOption('editedElementView'); | |
| 268 | + if (currentElement) { | |
| 269 | + // Get the control name from the hidden input | |
| 270 | + var controlName = $ctrl.find('input[type="hidden"][data-setting]').data('setting'); | |
| 271 | + if (controlName) { | |
| 272 | + // Create the image data object | |
| 273 | + var imageData = { | |
| 274 | + id: attachmentID, | |
| 275 | + url: imageURL | |
| 276 | + }; | |
| 277 | + | |
| 278 | + // Update the element's settings | |
| 279 | + var settings = {}; | |
| 280 | + settings[controlName] = imageData; | |
| 281 | + | |
| 282 | + // Use $e.run to properly update the element | |
| 283 | + $e.run('document/elements/settings', { | |
| 284 | + container: currentElement.getContainer(), | |
| 285 | + settings: settings | |
| 286 | + }); | |
| 287 | + | |
| 288 | + // Update the control preview | |
| 289 | + if ($preview.length) { | |
| 290 | + $preview.css('background-image', 'url(' + imageURL + ')'); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + } | |
| 294 | + } else { | |
| 295 | + alert('Error generating image. ' + response.responseJSON.data.message); | |
| 296 | + } | |
| 297 | + }).fail(function(response) { | |
| 298 | + alert('Error generating image. ' + response.responseJSON.data.message); | |
| 299 | + }).always(function() { | |
| 300 | + // Clean up animations and UI | |
| 301 | + $submit.prop('disabled', false).removeClass('is-processing').find('span').text('Generate'); | |
| 302 | + if (typeof $preview !== 'undefined' && $preview.length) { | |
| 303 | + $preview.removeClass('king-addons-field-pulsing'); | |
| 304 | + } | |
| 305 | + // Remove active state on complete | |
| 306 | + $btn.removeClass('is-active'); | |
| 307 | + $promptContainer.remove(); | |
| 308 | + }); | |
| 309 | + }); | |
| 310 | + | |
| 311 | + $cancel.on('click', function() { | |
| 312 | + // Remove active state when closing container | |
| 313 | + $btn.removeClass('is-active'); | |
| 314 | + $promptContainer.remove(); | |
| 315 | + }); | |
| 316 | + }); | |
| 317 | + }); | |
| 318 | + } | |
| 319 | + | |
| 320 | + // Global observer: watch for any media control fields added anywhere | |
| 321 | + var __kngGlobalObserver = new MutationObserver(function(mutations) { | |
| 322 | + mutations.forEach(function(mutation) { | |
| 323 | + Array.prototype.forEach.call(mutation.addedNodes, function(node) { | |
| 324 | + if (node.nodeType !== 1) { | |
| 325 | + return; | |
| 326 | + } | |
| 327 | + var $node = $(node); | |
| 328 | + // Check for media field or containing media field | |
| 329 | + var $mediaField = $node.is('.elementor-control-field.elementor-control-media') ? $node : $node.find('.elementor-control-field.elementor-control-media'); | |
| 330 | + if (!$mediaField.length) { | |
| 331 | + return; | |
| 332 | + } | |
| 333 | + // Find the parent control wrapper and its panel | |
| 334 | + var $ctrl = $mediaField.closest('.elementor-control.elementor-control-type-media, .elementor-control.elementor-control-type-image, .elementor-control.elementor-control-type-gallery'); | |
| 335 | + if (!$ctrl.length) { | |
| 336 | + return; | |
| 337 | + } | |
| 338 | + var $panel = $ctrl.closest('.elementor-panel'); | |
| 339 | + if ($panel.length) { | |
| 340 | + injectImageControls($panel); | |
| 341 | + } | |
| 342 | + }); | |
| 343 | + }); | |
| 344 | + }); | |
| 345 | + __kngGlobalObserver.observe(document.body, { childList: true, subtree: true }); | |
| 346 | + | |
| 347 | + // On widget panel open, inject image controls and watch for dynamically added controls | |
| 348 | + elementor.hooks.addAction('panel/open_editor/widget', function(panel) { | |
| 349 | + var $panelEl = panel.$el; | |
| 350 | + // Initial injection after panel renders | |
| 351 | + setTimeout(function() { | |
| 352 | + injectImageControls($panelEl); | |
| 353 | + }, 250); | |
| 354 | + // Setup a MutationObserver to catch lazy-loaded controls | |
| 355 | + var root = $panelEl[0]; | |
| 356 | + if (!root.__kngAiObserver) { | |
| 357 | + var observer = new MutationObserver(function(mutations) { | |
| 358 | + mutations.forEach(function(mutation) { | |
| 359 | + Array.prototype.forEach.call(mutation.addedNodes, function(node) { | |
| 360 | + if (node.nodeType === 1 && (node.matches('.elementor-control.elementor-control-type-media, .elementor-control.elementor-control-type-image, .elementor-control.elementor-control-type-gallery') || | |
| 361 | + $(node).find('.elementor-control.elementor-control-type-media, .elementor-control.elementor-control-type-image, .elementor-control.elementor-control-type-gallery').length)) { | |
| 362 | + injectImageControls($panelEl); | |
| 363 | + } | |
| 364 | + }); | |
| 365 | + }); | |
| 366 | + }); | |
| 367 | + observer.observe(root, { childList: true, subtree: true }); | |
| 368 | + root.__kngAiObserver = observer; | |
| 369 | + } | |
| 370 | + // Extra delayed injections to catch any late-rendered controls | |
| 371 | + setTimeout(function() { injectImageControls($panelEl); }, 800); | |
| 372 | + setTimeout(function() { injectImageControls($panelEl); }, 1500); | |
| 373 | + }); | |
| 374 | + | |
| 375 | + // Also monitor section changes (e.g., switching tabs) | |
| 376 | + elementor.channels.editor.on('section:activated', function(sectionName, editor) { | |
| 377 | + var panelView = editor.getOption('editedElementView').getContainer().panel; | |
| 378 | + if (panelView && panelView.$el) { | |
| 379 | + setTimeout(function() { | |
| 380 | + injectImageControls(panelView.$el); | |
| 381 | + }, 150); | |
| 382 | + } | |
| 383 | + }); | |
| 384 | + | |
| 385 | + // Re-inject image controls whenever a control view is opened | |
| 386 | + elementor.hooks.addAction('panel/open_editor/control', function(controlView) { | |
| 387 | + var panelEl = controlView.$el.closest('.elementor-panel'); | |
| 388 | + if (panelEl.length) { | |
| 389 | + injectImageControls(panelEl); | |
| 390 | + } | |
| 391 | + }); | |
| 392 | + | |
| 393 | + // Listen for Background Type toggles and re-inject Generate Image buttons when sub-controls appear | |
| 394 | + $(document).on('change', '.elementor-control.elementor-control-background_background input[type="radio"]', function() { | |
| 395 | + var panelEl = $(this).closest('.elementor-panel'); | |
| 396 | + setTimeout(function() { | |
| 397 | + injectImageControls(panelEl); | |
| 398 | + }, 100); | |
| 399 | + }); | |
| 400 | +})(jQuery, window.elementor); | |