index.js
526 lines
| 1 | /** |
| 2 | * SG AI Studio - AI Studio Prompt Block |
| 3 | * |
| 4 | * A Gutenberg block for generating content using AI Studio. |
| 5 | */ |
| 6 | |
| 7 | (function(wp) { |
| 8 | var registerBlockType = wp.blocks.registerBlockType; |
| 9 | var Button = wp.components.Button; |
| 10 | var ExternalLink = wp.components.ExternalLink; |
| 11 | var createElement = wp.element.createElement; |
| 12 | var Fragment = wp.element.Fragment; |
| 13 | |
| 14 | // Fix for i18n |
| 15 | var __ = wp.i18n.__ || function(text) { return text; }; |
| 16 | |
| 17 | /** |
| 18 | * Register the block |
| 19 | */ |
| 20 | registerBlockType('sg-ai-studio/ai-studio-prompt', { |
| 21 | title: 'AI Content Generator', |
| 22 | description: 'Generate content using AI Studio', |
| 23 | category: 'text', |
| 24 | icon: 'editor-paste-text', |
| 25 | keywords: ['ai', 'aistudio', 'content', 'generator'], |
| 26 | apiVersion: 3, |
| 27 | attributes: { |
| 28 | prompt: { |
| 29 | type: 'string', |
| 30 | default: '' |
| 31 | }, |
| 32 | isGenerating: { |
| 33 | type: 'boolean', |
| 34 | default: false |
| 35 | } |
| 36 | }, |
| 37 | |
| 38 | /** |
| 39 | * Edit function for the block |
| 40 | */ |
| 41 | edit: function(props) { |
| 42 | var attributes = props.attributes; |
| 43 | var setAttributes = props.setAttributes; |
| 44 | var prompt = attributes.prompt; |
| 45 | var isGenerating = attributes.isGenerating; |
| 46 | |
| 47 | // Get API key status from global variable |
| 48 | var hasApiKey = typeof sgAiStudioBlock !== 'undefined' && sgAiStudioBlock.hasApiKey; |
| 49 | |
| 50 | /** |
| 51 | * Generate content from AI Studio |
| 52 | */ |
| 53 | function generateContent() { |
| 54 | if (!prompt) { |
| 55 | return; |
| 56 | } |
| 57 | |
| 58 | // Set the generating state |
| 59 | setAttributes({ isGenerating: true }); |
| 60 | |
| 61 | // Get the current post ID |
| 62 | var postId = wp.data.select('core/editor').getCurrentPostId(); |
| 63 | |
| 64 | // Call AI Studio via WordPress REST API |
| 65 | wp.apiFetch({ |
| 66 | path: '/sg-ai-studio/generate-content', |
| 67 | method: 'POST', |
| 68 | data: { |
| 69 | prompt: prompt, |
| 70 | nonce: sgAiStudioBlock.nonce, |
| 71 | post_id: postId |
| 72 | } |
| 73 | }) |
| 74 | .then(function(response) { |
| 75 | if (response.success && (response.data || response.images)) { |
| 76 | // Insert content into the editor |
| 77 | insertContentIntoEditor(response.data, response.images); |
| 78 | } else { |
| 79 | // Store error in local variable for React to use in rendering |
| 80 | props.error = response.message || 'Error generating content.'; |
| 81 | // Force re-render |
| 82 | setAttributes({ isGenerating: false }); |
| 83 | } |
| 84 | }) |
| 85 | .catch(function(error) { |
| 86 | console.error('Error generating content:', error); |
| 87 | // Store error in local variable for React to use in rendering |
| 88 | props.error = 'Error connecting to the AI service.'; |
| 89 | // Force re-render |
| 90 | setAttributes({ isGenerating: false }); |
| 91 | }) |
| 92 | .finally(function() { |
| 93 | setAttributes({ isGenerating: false }); |
| 94 | }); |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Insert content into the editor |
| 99 | */ |
| 100 | function insertContentIntoEditor(content, images) { |
| 101 | if (!content && !images) { |
| 102 | return; |
| 103 | } |
| 104 | |
| 105 | var blocks = []; |
| 106 | |
| 107 | // Process text content if available |
| 108 | if (content) { |
| 109 | // Handle potential escaped HTML entities |
| 110 | content = content.replace(/</g, '<').replace(/>/g, '>'); |
| 111 | |
| 112 | // Check if content has an H1 heading to use as post title |
| 113 | // Support both <h1> tags and potential escaped versions |
| 114 | var titleRegex = /<h1[^>]*>(.*?)<\/h1>/i; |
| 115 | var titleMatch = content.match(titleRegex); |
| 116 | |
| 117 | // Extract the title from H1 if it exists |
| 118 | if (titleMatch && titleMatch[1]) { |
| 119 | var title = titleMatch[1].trim(); |
| 120 | // Strip any HTML tags from the title |
| 121 | var tempDiv = document.createElement('div'); |
| 122 | tempDiv.innerHTML = title; |
| 123 | title = tempDiv.textContent || tempDiv.innerText || title; |
| 124 | |
| 125 | // Set as post title |
| 126 | wp.data.dispatch('core/editor').editPost({ title: title }); |
| 127 | |
| 128 | // Remove the H1 from content before processing |
| 129 | content = content.replace(titleRegex, '').trim(); |
| 130 | } |
| 131 | |
| 132 | // Check if content contains Gutenberg block comments |
| 133 | var hasBlockComments = content.indexOf('<!-- wp:') !== -1; |
| 134 | |
| 135 | if (hasBlockComments) { |
| 136 | // Parse Gutenberg block markup |
| 137 | blocks = wp.blocks.parse(content); |
| 138 | } else { |
| 139 | // Use rawHandler to convert plain HTML to blocks |
| 140 | blocks = wp.blocks.rawHandler({ |
| 141 | HTML: content, |
| 142 | mode: 'BLOCKS' |
| 143 | }); |
| 144 | } |
| 145 | |
| 146 | // If parsing failed, fallback to paragraph |
| 147 | if (!blocks || blocks.length === 0) { |
| 148 | blocks = [wp.blocks.createBlock('core/paragraph', { |
| 149 | content: content |
| 150 | })]; |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // Add image blocks if images are provided separately (not already in content) |
| 155 | // Check if images are already embedded in the parsed blocks |
| 156 | if (images && images.length > 0) { |
| 157 | // Recursive function to check for image blocks at any nesting level |
| 158 | function hasImageBlocksRecursive(blockList) { |
| 159 | for (var j = 0; j < blockList.length; j++) { |
| 160 | var block = blockList[j]; |
| 161 | // Check for image, cover, gallery, or media-text blocks |
| 162 | if (block.name === 'core/image' || |
| 163 | block.name === 'core/cover' || |
| 164 | block.name === 'core/gallery' || |
| 165 | block.name === 'core/media-text') { |
| 166 | return true; |
| 167 | } |
| 168 | // Check inner blocks recursively |
| 169 | if (block.innerBlocks && block.innerBlocks.length > 0) { |
| 170 | if (hasImageBlocksRecursive(block.innerBlocks)) { |
| 171 | return true; |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | return false; |
| 176 | } |
| 177 | |
| 178 | var hasImageBlocks = hasImageBlocksRecursive(blocks); |
| 179 | |
| 180 | // Only add image blocks if they weren't already included in the parsed content |
| 181 | if (!hasImageBlocks) { |
| 182 | for (var i = 0; i < images.length; i++) { |
| 183 | var imageBlock = wp.blocks.createBlock('core/image', { |
| 184 | id: images[i].id, |
| 185 | url: images[i].url, |
| 186 | sizeSlug: 'large' |
| 187 | }); |
| 188 | blocks.push(imageBlock); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Replace temporary/placeholder image URLs with actual WordPress URLs if images array is provided |
| 194 | if (images && images.length > 0) { |
| 195 | var imageIndex = 0; |
| 196 | |
| 197 | // Helper function to check if URL needs replacement |
| 198 | function needsUrlReplacement(url) { |
| 199 | if (!url) return false; |
| 200 | // Check for common placeholder patterns first |
| 201 | if (url.indexOf('/internal/res_') !== -1) return true; |
| 202 | if (url.indexOf('localhost/internal') !== -1) return true; |
| 203 | if (url.indexOf('placeholder') !== -1) return true; |
| 204 | if (url.indexOf('temp') !== -1) return true; |
| 205 | // Check if it's an external URL (not from WordPress uploads) |
| 206 | if (url.indexOf('http') === 0 && url.indexOf(window.location.hostname) === -1) return true; |
| 207 | return false; |
| 208 | } |
| 209 | |
| 210 | // Recursive function to update image URLs in blocks |
| 211 | function replaceImageUrls(blockList) { |
| 212 | for (var k = 0; k < blockList.length; k++) { |
| 213 | var block = blockList[k]; |
| 214 | |
| 215 | // Update media-text blocks - remove nested image blocks and use mediaUrl instead |
| 216 | if (block.name === 'core/media-text' && block.attributes) { |
| 217 | var needsMediaUpdate = false; |
| 218 | var contentBlocks = []; // To store non-image inner blocks |
| 219 | |
| 220 | // Check if mediaUrl needs replacement |
| 221 | if (block.attributes.mediaUrl && needsUrlReplacement(block.attributes.mediaUrl)) { |
| 222 | needsMediaUpdate = true; |
| 223 | } |
| 224 | // Check for mediaLink attribute (non-standard but sometimes used) |
| 225 | else if (block.attributes.mediaLink && needsUrlReplacement(block.attributes.mediaLink)) { |
| 226 | needsMediaUpdate = true; |
| 227 | } |
| 228 | // Check for non-standard image_url attribute |
| 229 | else if (block.attributes.image_url && needsUrlReplacement(block.attributes.image_url)) { |
| 230 | needsMediaUpdate = true; |
| 231 | } |
| 232 | // Check if mediaType is image but no mediaUrl is set yet |
| 233 | else if (block.attributes.mediaType === 'image' && !block.attributes.mediaUrl) { |
| 234 | needsMediaUpdate = true; |
| 235 | } |
| 236 | |
| 237 | // Look for nested image block to extract URL and separate content blocks |
| 238 | if (block.innerBlocks && block.innerBlocks.length > 0) { |
| 239 | for (var m = 0; m < block.innerBlocks.length; m++) { |
| 240 | var innerBlock = block.innerBlocks[m]; |
| 241 | // If it's an image block, extract its URL and don't keep it |
| 242 | if (innerBlock.name === 'core/image' && innerBlock.attributes && innerBlock.attributes.url) { |
| 243 | if (needsUrlReplacement(innerBlock.attributes.url)) { |
| 244 | needsMediaUpdate = true; |
| 245 | } |
| 246 | // Don't add image block to contentBlocks - we'll use mediaUrl instead |
| 247 | } else { |
| 248 | // Keep other inner blocks (paragraphs, headings, etc.) |
| 249 | contentBlocks.push(innerBlock); |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // Create fresh block instead of modifying existing one |
| 255 | if (needsMediaUpdate && imageIndex < images.length) { |
| 256 | |
| 257 | // Create new attributes object with correct values |
| 258 | var newAttributes = {}; |
| 259 | for (var attrKey in block.attributes) { |
| 260 | if (block.attributes.hasOwnProperty(attrKey)) { |
| 261 | newAttributes[attrKey] = block.attributes[attrKey]; |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // Set correct media attributes |
| 266 | newAttributes.mediaUrl = images[imageIndex].url; |
| 267 | newAttributes.mediaId = images[imageIndex].id; |
| 268 | newAttributes.mediaType = 'image'; |
| 269 | |
| 270 | // Clean up non-standard attributes |
| 271 | delete newAttributes.image_url; |
| 272 | delete newAttributes.image_id; |
| 273 | delete newAttributes.mediaLink; |
| 274 | |
| 275 | // Create fresh block with cleaned content blocks |
| 276 | var newBlock = wp.blocks.createBlock('core/media-text', newAttributes, contentBlocks); |
| 277 | |
| 278 | // Replace in array |
| 279 | blockList[k] = newBlock; |
| 280 | |
| 281 | imageIndex++; |
| 282 | } else if (needsMediaUpdate) { |
| 283 | } else { |
| 284 | // No media update needed, but still process inner blocks |
| 285 | if (block.innerBlocks && block.innerBlocks.length > 0) { |
| 286 | replaceImageUrls(block.innerBlocks); |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | // Update standalone image blocks |
| 291 | else if (block.name === 'core/image' && block.attributes && block.attributes.url) { |
| 292 | if (needsUrlReplacement(block.attributes.url)) { |
| 293 | if (imageIndex < images.length) { |
| 294 | block.attributes.url = images[imageIndex].url; |
| 295 | block.attributes.id = images[imageIndex].id; |
| 296 | imageIndex++; |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | // Update cover blocks |
| 301 | else if (block.name === 'core/cover' && block.attributes && block.attributes.url) { |
| 302 | if (needsUrlReplacement(block.attributes.url)) { |
| 303 | if (imageIndex < images.length) { |
| 304 | block.attributes.url = images[imageIndex].url; |
| 305 | block.attributes.id = images[imageIndex].id; |
| 306 | imageIndex++; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | // Process inner blocks for cover |
| 311 | if (block.innerBlocks && block.innerBlocks.length > 0) { |
| 312 | replaceImageUrls(block.innerBlocks); |
| 313 | } |
| 314 | } |
| 315 | // Recursively update inner blocks for other block types |
| 316 | else if (block.innerBlocks && block.innerBlocks.length > 0) { |
| 317 | replaceImageUrls(block.innerBlocks); |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | replaceImageUrls(blocks); |
| 323 | } |
| 324 | |
| 325 | // Get the current block's position info |
| 326 | var selectedBlockId = wp.data.select('core/block-editor').getSelectedBlockClientId(); |
| 327 | |
| 328 | if (selectedBlockId) { |
| 329 | // Get the index and parent of the current block |
| 330 | var blockIndex = wp.data.select('core/block-editor').getBlockIndex(selectedBlockId); |
| 331 | var rootClientId = wp.data.select('core/block-editor').getBlockRootClientId(selectedBlockId); |
| 332 | |
| 333 | // Insert the new blocks at the same position |
| 334 | wp.data.dispatch('core/block-editor').insertBlocks(blocks, blockIndex, rootClientId); |
| 335 | |
| 336 | // Remove the AI Studio Prompt block (which is now at blockIndex + blocks.length) |
| 337 | wp.data.dispatch('core/block-editor').removeBlock(selectedBlockId); |
| 338 | } else { |
| 339 | // Fallback: insert at the end |
| 340 | wp.data.dispatch('core/block-editor').insertBlocks(blocks); |
| 341 | } |
| 342 | |
| 343 | wp.data.dispatch('core/editor').savePost(); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Render the block editor UI |
| 348 | */ |
| 349 | return createElement( |
| 350 | 'div', |
| 351 | { |
| 352 | className: 'wp-block-sg-ai-studio-prompt', |
| 353 | style: { |
| 354 | padding: '16px', |
| 355 | position: 'relative', |
| 356 | background: '#fff', |
| 357 | border: '1px solid #e0e0e0', |
| 358 | borderRadius: '4px' |
| 359 | } |
| 360 | }, |
| 361 | [ |
| 362 | // Loading spinner overlay when generating |
| 363 | isGenerating && createElement('div', { |
| 364 | style: { |
| 365 | position: 'absolute', |
| 366 | top: '0', |
| 367 | left: '0', |
| 368 | right: '0', |
| 369 | bottom: '0', |
| 370 | background: 'rgba(255, 255, 255, 0.8)', |
| 371 | backdropFilter: 'blur(2px)', |
| 372 | display: 'flex', |
| 373 | alignItems: 'center', |
| 374 | justifyContent: 'center', |
| 375 | zIndex: 1000, |
| 376 | pointerEvents: 'all', |
| 377 | borderRadius: '4px' |
| 378 | } |
| 379 | }, |
| 380 | createElement('div', { |
| 381 | style: { |
| 382 | width: '40px', |
| 383 | height: '40px', |
| 384 | border: '4px solid #e0e0e0', |
| 385 | borderTop: '4px solid #6366f1', |
| 386 | borderRadius: '50%', |
| 387 | animation: 'spin 1s linear infinite' |
| 388 | } |
| 389 | }) |
| 390 | ), |
| 391 | |
| 392 | // API Key notice |
| 393 | !hasApiKey && createElement( |
| 394 | 'div', |
| 395 | { |
| 396 | className: 'sg-ai-studio-settings-notice', |
| 397 | style: { |
| 398 | padding: '12px', |
| 399 | background: '#fff3cd', |
| 400 | border: '1px solid #ffc107', |
| 401 | borderRadius: '4px', |
| 402 | marginBottom: '16px', |
| 403 | display: 'flex', |
| 404 | alignItems: 'center', |
| 405 | gap: '8px' |
| 406 | } |
| 407 | }, |
| 408 | [ |
| 409 | createElement('span', { style: { flex: 1 } }, 'Please configure your AI Studio API key in the plugin settings.'), |
| 410 | createElement(ExternalLink, { href: sgAiStudioBlock.settingsUrl }, 'Settings') |
| 411 | ] |
| 412 | ), |
| 413 | |
| 414 | // Prompt container with styled text section |
| 415 | createElement( |
| 416 | 'div', |
| 417 | { className: 'sg-ai-studio-text-section', style: { marginBottom: '16px' } }, |
| 418 | [ |
| 419 | createElement('label', { |
| 420 | className: 'components-base-control__label', |
| 421 | style: { display: 'block', marginBottom: '8px', fontWeight: '600' } |
| 422 | }, 'Generate Content with AI'), |
| 423 | createElement('textarea', { |
| 424 | className: 'sg-ai-studio-text-input', |
| 425 | value: prompt, |
| 426 | onChange: function(e) { |
| 427 | setAttributes({ prompt: e.target.value }); |
| 428 | // Clear any previous error when prompt changes |
| 429 | props.error = null; |
| 430 | }, |
| 431 | placeholder: 'Describe the content you want to generate...', |
| 432 | disabled: isGenerating || !hasApiKey, |
| 433 | rows: 6, |
| 434 | style: { |
| 435 | width: '100%', |
| 436 | padding: '12px', |
| 437 | background: isGenerating ? '#f9f9f9' : '#f0f7ff', |
| 438 | border: isGenerating ? '2px solid #6164ff' : '1px solid var(--color-primary-main, #4343f0)', |
| 439 | borderRadius: '4px', |
| 440 | fontSize: '14px', |
| 441 | lineHeight: '1.6', |
| 442 | fontFamily: 'inherit', |
| 443 | resize: 'vertical', |
| 444 | minHeight: '120px', |
| 445 | boxSizing: 'border-box', |
| 446 | outline: 'none' |
| 447 | } |
| 448 | }) |
| 449 | ] |
| 450 | ), |
| 451 | |
| 452 | // Error message (if any) |
| 453 | props.error && createElement( |
| 454 | 'div', |
| 455 | { |
| 456 | className: 'sg-ai-studio-error', |
| 457 | style: { |
| 458 | padding: '12px', |
| 459 | background: '#f8d7da', |
| 460 | border: '1px solid #f5c2c7', |
| 461 | borderRadius: '4px', |
| 462 | color: '#842029', |
| 463 | marginBottom: '16px', |
| 464 | fontSize: '14px' |
| 465 | } |
| 466 | }, |
| 467 | props.error |
| 468 | ), |
| 469 | |
| 470 | // Button container |
| 471 | createElement( |
| 472 | 'div', |
| 473 | { className: 'sg-ai-studio-button-container', style: { marginBottom: '16px' } }, |
| 474 | [ |
| 475 | createElement( |
| 476 | Button, |
| 477 | { |
| 478 | className: 'sg-button-base sg-button-base--color-primary sg-button-base--type-filled sg-button sg-button--medium', |
| 479 | isPrimary: true, |
| 480 | onClick: generateContent, |
| 481 | disabled: isGenerating || !prompt || !hasApiKey, |
| 482 | style: { |
| 483 | width: '100%', |
| 484 | justifyContent: 'center', |
| 485 | background: 'var(--color-primary-main, #4343f0)', |
| 486 | color: 'var(--color-primary-contrast, #ffffff)', |
| 487 | border: 'none', |
| 488 | borderRadius: '50px', |
| 489 | fontFamily: 'Poppins, -apple-system, BlinkMacSystemFont, sans-serif', |
| 490 | fontSize: 'var(--typography-size-medium, 1.4rem)', |
| 491 | fontWeight: 'var(--typography-weight-medium, 500)', |
| 492 | padding: 'var(--space-x-small, 8px) var(--space-small, 12px)', |
| 493 | height: 'auto', |
| 494 | minHeight: '40px' |
| 495 | } |
| 496 | }, |
| 497 | isGenerating ? 'Generating...' : 'Generate' |
| 498 | ), |
| 499 | createElement( |
| 500 | 'p', |
| 501 | { |
| 502 | className: 'sg-ai-studio-info', |
| 503 | style: { |
| 504 | fontSize: '12px', |
| 505 | color: '#757575', |
| 506 | textAlign: 'center', |
| 507 | margin: '8px 0 0 0' |
| 508 | } |
| 509 | }, |
| 510 | 'Generated content will be added to your post.' |
| 511 | ) |
| 512 | ] |
| 513 | ) |
| 514 | ].filter(Boolean) // Filter out any falsy elements (like when !hasApiKey is false) |
| 515 | ); |
| 516 | }, |
| 517 | |
| 518 | /** |
| 519 | * Save function for the block |
| 520 | */ |
| 521 | save: function() { |
| 522 | // This block is dynamic and doesn't save content |
| 523 | return null; |
| 524 | } |
| 525 | }); |
| 526 | })(window.wp); |