diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 14cc4c584f..26a43dbefc 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -770,7 +770,7 @@ function RemoteFunctions(config = {}) { let rect = element.getBoundingClientRect(); marker.style.position = "fixed"; - marker.style.zIndex = "2147483646"; + marker.style.zIndex = "2147483647"; marker.style.borderRadius = "2px"; marker.style.pointerEvents = "none"; @@ -1159,6 +1159,7 @@ function RemoteFunctions(config = {}) { _clearDropMarkers(); window._currentDraggedElement = this.element; dismissUIAndCleanupState(); + dismissImageRibbonGallery(); // Add drag image styling event.dataTransfer.effectAllowed = "move"; }); @@ -1273,52 +1274,52 @@ function RemoteFunctions(config = {}) { const styles = ` :host { - all: initial; + all: initial !important; } .phoenix-more-options-box { - background-color: #4285F4; - color: white; - border-radius: 3px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - font-size: 12px; - font-family: Arial, sans-serif; - z-index: 2147483647; - position: absolute; + background-color: #4285F4 !important; + color: white !important; + border-radius: 3px !important; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; + font-size: 12px !important; + font-family: Arial, sans-serif !important; + z-index: 2147483647 !important; + position: absolute !important; left: -1000px; top: -1000px; - box-sizing: border-box; + box-sizing: border-box !important; } .node-options { - display: flex; - align-items: center; + display: flex !important; + align-items: center !important; } .node-options span { - padding: 4px 3.9px; - cursor: pointer; - display: flex; - align-items: center; - border-radius: 0; + padding: 4px 3.9px !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + border-radius: 0 !important; } .node-options span:first-child { - border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px !important; } .node-options span:last-child { - border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0 !important; } .node-options span:hover { - background-color: rgba(255, 255, 255, 0.15); + background-color: rgba(255, 255, 255, 0.15) !important; } .node-options span > svg { - width: 16px; - height: 16px; - display: block; + width: 16px !important; + height: 16px !important; + display: block !important; } `; @@ -1535,37 +1536,37 @@ function RemoteFunctions(config = {}) { const styles = ` :host { - all: initial; + all: initial !important; } .phoenix-node-info-box { - background-color: #4285F4; - color: white; - border-radius: 3px; - padding: 5px 8px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - font-size: 12px; - font-family: Arial, sans-serif; - z-index: 2147483647; - position: absolute; + background-color: #4285F4 !important; + color: white !important; + border-radius: 3px !important; + padding: 5px 8px !important; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; + font-size: 12px !important; + font-family: Arial, sans-serif !important; + z-index: 2147483647 !important; + position: absolute !important; left: ${leftPos}px; top: -1000px; - max-width: 300px; - box-sizing: border-box; - pointer-events: none; + max-width: 300px !important; + box-sizing: border-box !important; + pointer-events: none !important; } .tag-name { - font-weight: bold; + font-weight: bold !important; } .id-name, .class-name { - margin-top: 3px; + margin-top: 3px !important; } .exceeded-classes { - opacity: 0.8; + opacity: 0.8 !important; } `; @@ -1682,96 +1683,96 @@ function RemoteFunctions(config = {}) { const styles = ` :host { - all: initial; + all: initial !important; } .phoenix-ai-prompt-box { - position: absolute; - background: white; - border: 1px solid #4285F4; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - font-family: Arial, sans-serif; - z-index: 2147483647; - width: ${boxWidth}px; - padding: 0; - box-sizing: border-box; + position: absolute !important; + background: white !important; + border: 1px solid #4285F4 !important; + border-radius: 8px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; + font-family: Arial, sans-serif !important; + z-index: 2147483647 !important; + width: ${boxWidth}px !important; + padding: 0 !important; + box-sizing: border-box !important; } .phoenix-ai-prompt-input-container { - position: relative; + position: relative !important; } .phoenix-ai-prompt-textarea { - width: 100%; - height: ${boxHeight}px; - border: none; - border-radius: 8px; - padding: 12px 40px 12px 16px; - font-size: 14px; - font-family: Arial, sans-serif; - resize: none; - outline: none; - box-sizing: border-box; - background: #f9f9f9; + width: 100% !important; + height: ${boxHeight}px !important; + border: none !important; + border-radius: 8px !important; + padding: 12px 40px 12px 16px !important; + font-size: 14px !important; + font-family: Arial, sans-serif !important; + resize: none !important; + outline: none !important; + box-sizing: border-box !important; + background: #f9f9f9 !important; } .phoenix-ai-prompt-textarea:focus { - background: white; + background: white !important; } .phoenix-ai-prompt-textarea::placeholder { - color: #999; + color: #999 !important; } .phoenix-ai-prompt-send-button { - width: 28px; - height: 28px; - border: none; - border-radius: 50%; - background: #4285F4; - color: white; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - transition: background-color 0.2s; - line-height: 0.5; + width: 28px !important; + height: 28px !important; + border: none !important; + border-radius: 50% !important; + background: #4285F4 !important; + color: white !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 14px !important; + transition: background-color 0.2s !important; + line-height: 0.5 !important; } .phoenix-ai-prompt-send-button:hover:not(:disabled) { - background: #4285F4; + background: #4285F4 !important; } .phoenix-ai-prompt-send-button:disabled { - background: #dadce0; - color: #9aa0a6; - cursor: not-allowed; + background: #dadce0 !important; + color: #9aa0a6 !important; + cursor: not-allowed !important; } .phoenix-ai-bottom-controls { - border-top: 1px solid #e0e0e0; - padding: 8px 16px; - background: #f9f9f9; - border-radius: 0 0 8px 8px; - display: flex; - align-items: center; - justify-content: space-between; + border-top: 1px solid #e0e0e0 !important; + padding: 8px 16px !important; + background: #f9f9f9 !important; + border-radius: 0 0 8px 8px !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; } .phoenix-ai-model-select { - padding: 4px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 12px; - background: white; - outline: none; - cursor: pointer; + padding: 4px 8px !important; + border: 1px solid #ddd !important; + border-radius: 4px !important; + font-size: 12px !important; + background: white !important; + outline: none !important; + cursor: pointer !important; } .phoenix-ai-model-select:focus { - border-color: #4285F4; + border-color: #4285F4 !important; } `; @@ -1911,6 +1912,996 @@ function RemoteFunctions(config = {}) { } }; + // image ribbon gallery cache, to store the last query and its results + // then next time we can load it from cache itself instead of making a new API call + const _imageGalleryCache = { + currentQuery: null, + allImages: [], + totalPages: 1, + currentPage: 1, + maxImages: 50 + }; + + /** + * when user clicks on an image we call this, + * this creates a image ribbon gallery at the bottom of the live preview + */ + function ImageRibbonGallery(element) { + this.element = element; + this.remove = this.remove.bind(this); + this.currentPage = 1; + this.totalPages = 1; + this.allImages = []; + this.imagesPerPage = 10; + this.scrollPosition = 0; + this.maxWidth = '800px'; // when current image dimension is not defined we use this as unsplash images are very large + this.maxHeight = '600px'; + + this.create(); + } + + ImageRibbonGallery.prototype = { + _style: function () { + this.body = window.document.createElement("div"); + this._shadow = this.body.attachShadow({mode: 'closed'}); + + this._shadow.innerHTML = ` + +
+
+
+
+ +
+ + +
+
+
+ +
+
+
+
+
+ Loading images... +
+
+
+
+
+ `; + }, + + _getDefaultQuery: function() { + // this are the default queries, so when image ribbon gallery is shown, we select a random query and show it + const qualityQueries = [ + 'nature', 'minimal', 'workspace', 'abstract', 'coffee', + 'mountains', 'city', 'flowers', 'ocean', 'sunset', + 'architecture', 'forest', 'travel', 'technology', 'sky', + 'landscape', 'creative', 'design', 'art', 'modern', + 'food', 'patterns', 'colors', 'photography', 'studio', + 'light', 'winter', 'summer', 'vintage', 'geometric', + 'water', 'beach', 'space', 'garden', 'textures', + 'urban', 'portrait', 'music', 'books', 'home', + 'cozy', 'aesthetic', 'autumn', 'spring', 'clouds' + ]; + + const randIndex = Math.floor(Math.random() * qualityQueries.length); + return qualityQueries[randIndex]; + }, + + _fetchImages: function(searchQuery, page = 1, append = false) { + this._currentSearchQuery = searchQuery; + + if (!append && this._loadFromCache(searchQuery)) { // try cache first + return; + } + if (append && this._loadPageFromCache(searchQuery, page)) { // try to load new page from cache + return; + } + // if unable to load from cache, we make the API call + this._fetchFromAPI(searchQuery, page, append); + }, + + _fetchFromAPI: function(searchQuery, page, append) { + // when we fetch from API, we clear the cache and then store a fresh copy + if (searchQuery !== _imageGalleryCache.currentQuery) { + this._clearCache(); + } + + const apiUrl = `https://images.phcode.dev/api/images/search?q=${encodeURIComponent(searchQuery)}&per_page=10&page=${page}&safe=true`; + + if (!append) { + this._showLoading(); + } + + fetch(apiUrl) + .then(response => { + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (data.results && data.results.length > 0) { + if (append) { + this.allImages = this.allImages.concat(data.results); + this._renderImages(data.results, true); // true means need to append new images at the end + } else { + this.allImages = data.results; + this._renderImages(this.allImages, false); // false means its a new search + } + this.totalPages = data.total_pages || 1; + this.currentPage = page; + this._updateNavButtons(); + this._updateSearchInput(searchQuery); + this._updateCache(searchQuery, data, append); + } else if (!append) { + this._showError('No images found'); + } + + if (append) { + this._isLoadingMore = false; + this._hideLoadingMore(); + } + }) + .catch(error => { + console.error('Failed to fetch images:', error); + if (!append) { + this._showError('Failed to load images'); + } else { + this._isLoadingMore = false; + this._hideLoadingMore(); + } + }); + }, + + _updateCache: function(searchQuery, data, append) { + // Update cache with new data for current query + _imageGalleryCache.currentQuery = searchQuery; + _imageGalleryCache.totalPages = data.total_pages || 1; + _imageGalleryCache.currentPage = this.currentPage; + + if (append) { + // Append new results to existing cache + const newImages = _imageGalleryCache.allImages.concat(data.results); + + if (newImages.length > _imageGalleryCache.maxImages) { // max = 50 + _imageGalleryCache.allImages = newImages.slice(-_imageGalleryCache.maxImages); + } else { + _imageGalleryCache.allImages = newImages; + } + } else { + // new search replace cache + _imageGalleryCache.allImages = data.results; + } + }, + + _clearCache: function() { + // clear current cache when switching to new query + _imageGalleryCache.currentQuery = null; + _imageGalleryCache.allImages = []; + _imageGalleryCache.totalPages = 1; + _imageGalleryCache.currentPage = 1; + }, + + _updateSearchInput: function(searchQuery) { + // write the current query in the search input + const searchInput = this._shadow.querySelector('.phoenix-ribbon-search input'); + if (searchInput && searchQuery) { + searchInput.value = searchQuery; + searchInput.placeholder = searchQuery; + } + }, + + _loadFromCache: function(searchQuery) { + // Check if we can load from cache for this query + if (searchQuery === _imageGalleryCache.currentQuery && _imageGalleryCache.allImages.length > 0) { + this.allImages = _imageGalleryCache.allImages; + this.totalPages = _imageGalleryCache.totalPages; + this.currentPage = _imageGalleryCache.currentPage; + + this._renderImages(this.allImages, false); + this._updateNavButtons(); + this._updateSearchInput(searchQuery); + return true; // Successfully loaded from cache + } + return false; // unable to load from cache + }, + + _loadPageFromCache: function(searchQuery, page) { + // check if this page is in cache + if (searchQuery === _imageGalleryCache.currentQuery && page <= Math.ceil(_imageGalleryCache.allImages.length / 10)) { + const startIdx = (page - 1) * 10; + const endIdx = startIdx + 10; + const pageImages = _imageGalleryCache.allImages.slice(startIdx, endIdx); + + if (pageImages.length > 0) { + this.allImages = this.allImages.concat(pageImages); + this._renderImages(pageImages, true); + this.currentPage = page; + this._updateNavButtons(); + this._isLoadingMore = false; + this._hideLoadingMore(); + return true; // Successfully loaded page from cache + } + } + return false; + }, + + _handleNavLeft: function() { + const container = this._shadow.querySelector('.phoenix-ribbon-strip'); + if (!container) { return; } + + const containerWidth = container.clientWidth; + const scrollAmount = containerWidth; + + this.scrollPosition = Math.max(0, this.scrollPosition - scrollAmount); + container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' }); + this._updateNavButtons(); + }, + + _handleNavRight: function() { + const container = this._shadow.querySelector('.phoenix-ribbon-strip'); + if (!container) { return; } + + const containerWidth = container.clientWidth; + const totalWidth = container.scrollWidth; + const scrollAmount = containerWidth; + + // if we're near the end, we need to load more images + const nearEnd = (this.scrollPosition + containerWidth + scrollAmount) >= totalWidth - 100; + if (nearEnd && this.currentPage < this.totalPages && !this._isLoadingMore) { + this._isLoadingMore = true; + this._showLoadingMore(); + this._fetchImages(this._currentSearchQuery, this.currentPage + 1, true); + } + + this.scrollPosition = Math.min(totalWidth - containerWidth, this.scrollPosition + scrollAmount); + container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' }); + this._updateNavButtons(); + }, + + _updateNavButtons: function() { + // this function is responsible to update the nav buttons + // when we're at the very left, we hide the nav-left button completely + // when we're at the very right and no more pages available, we hide the nav-right button + const navLeft = this._shadow.querySelector('.phoenix-ribbon-nav.left'); + const navRight = this._shadow.querySelector('.phoenix-ribbon-nav.right'); + const container = this._shadow.querySelector('.phoenix-ribbon-strip'); + + if (!navLeft || !navRight || !container) { return; } + + // show/hide left button + if (this.scrollPosition <= 0) { + navLeft.style.display = 'none'; + } else { + navLeft.style.display = 'block'; + } + + // show/hide right button + const containerWidth = container.clientWidth; + const totalWidth = container.scrollWidth; + const atEnd = (this.scrollPosition + containerWidth) >= totalWidth - 10; + const hasMorePages = this.currentPage < this.totalPages; + + if (atEnd && !hasMorePages) { + navRight.style.display = 'none'; + } else { + navRight.style.display = 'block'; + } + }, + + _showLoading: function() { + const rowElement = this._shadow.querySelector('.phoenix-ribbon-row'); + if (!rowElement) { return; } + + rowElement.innerHTML = 'Loading images...'; + rowElement.className = 'phoenix-ribbon-row phoenix-ribbon-loading'; + }, + + _showLoadingMore: function() { + const rowElement = this._shadow.querySelector('.phoenix-ribbon-row'); + if (!rowElement) { return; } + + // when loading more images we need to show the message at the end of the image ribbon + const loadingIndicator = window.document.createElement('div'); + loadingIndicator.className = 'phoenix-loading-more'; + loadingIndicator.style.cssText = ` + display: flex !important; + align-items: center !important; + justify-content: center !important; + min-width: 120px !important; + height: 116px !important; + margin-left: 2px !important; + background: rgba(255,255,255,0.03) !important; + border-radius: 8px !important; + color: #e8eaf0 !important; + font-size: 12px !important; + border: 1px dashed rgba(255,255,255,0.1) !important; + `; + loadingIndicator.textContent = 'Loading...'; + rowElement.appendChild(loadingIndicator); + }, + + _hideLoadingMore: function() { + const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more'); + if (loadingIndicator) { + loadingIndicator.remove(); + } + }, + + _attachEventHandlers: function() { + const ribbonContainer = this._shadow.querySelector('.phoenix-image-ribbon'); + const searchInput = this._shadow.querySelector('.phoenix-ribbon-search input'); + const searchButton = this._shadow.querySelector('.phoenix-ribbon-search-btn'); + const closeButton = this._shadow.querySelector('.phoenix-ribbon-close'); + const navLeft = this._shadow.querySelector('.phoenix-ribbon-nav.left'); + const navRight = this._shadow.querySelector('.phoenix-ribbon-nav.right'); + const selectImageBtn = this._shadow.querySelector('.phoenix-select-image-btn'); + const fileInput = this._shadow.querySelector('.phoenix-file-input'); + + if (searchInput && searchButton) { + const performSearch = (e) => { + e.stopPropagation(); + const query = searchInput.value.trim(); + if (query) { + // reset pagination when searching + this.currentPage = 1; + this.allImages = []; + this.scrollPosition = 0; + this._fetchImages(query); + } + }; + + searchButton.addEventListener('click', performSearch); + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + performSearch(e); + } + }); + + searchInput.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + if (selectImageBtn && fileInput) { + selectImageBtn.addEventListener('click', (e) => { + e.stopPropagation(); + fileInput.click(); + }); + + fileInput.addEventListener('change', (e) => { + e.stopPropagation(); + const file = e.target.files[0]; + if (file) { + this._handleLocalImageSelection(file); + fileInput.value = ''; + } + }); + } + + if (closeButton) { + closeButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.remove(); + }); + } + + if (navLeft) { + navLeft.addEventListener('click', (e) => { + e.stopPropagation(); + this._handleNavLeft(); + }); + } + + if (navRight) { + navRight.addEventListener('click', (e) => { + e.stopPropagation(); + this._handleNavRight(); + }); + } + + // Prevent clicks anywhere inside the ribbon from bubbling up + if (ribbonContainer) { + ribbonContainer.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + }, + + // append true means load more images (user clicked on nav-right) + // append false means its a new query + _renderImages: function(images, append = false) { + const rowElement = this._shadow.querySelector('.phoenix-ribbon-row'); + if (!rowElement) { return; } + + const container = this._shadow.querySelector('.phoenix-ribbon-strip'); + const savedScrollPosition = container ? container.scrollLeft : 0; + + // if not appending we clear the phoenix ribbon + if (!append) { + rowElement.innerHTML = ''; + rowElement.className = 'phoenix-ribbon-row'; + } else { + // when appending we add the new images at the end + const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more'); + if (loadingIndicator) { + loadingIndicator.remove(); + } + } + + // Create thumbnails from API data + images.forEach(image => { + const thumbDiv = window.document.createElement('div'); + thumbDiv.className = 'phoenix-ribbon-thumb'; + + const img = window.document.createElement('img'); + img.src = image.thumb_url || image.url; + img.alt = image.alt_text || 'Unsplash image'; + img.loading = 'lazy'; + + // show hovered image along with dimensions + thumbDiv.addEventListener('mouseenter', () => { + this.element.style.width = this._originalImageStyle.width || this.maxWidth; + this.element.style.height = this._originalImageStyle.height || this.maxHeight; + + this.element.style.objectFit = this._originalImageStyle.objectFit || 'cover'; + this.element.src = image.url || image.thumb_url; + }); + + // show original image when hover ends + thumbDiv.addEventListener('mouseleave', () => { + this.element.src = this._originalImageSrc; + }); + + // attribution overlay, we show this only in the image ribbon gallery + const attribution = window.document.createElement('div'); + attribution.className = 'phoenix-ribbon-attribution'; + + const photographer = window.document.createElement('a'); + photographer.className = 'photographer'; + photographer.href = image.photographer_url; + photographer.target = '_blank'; + photographer.rel = 'noopener noreferrer'; + photographer.textContent = (image.user && image.user.name) || 'Anonymous'; + photographer.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + const source = window.document.createElement('a'); + source.className = 'source'; + source.href = image.unsplash_url; + source.target = '_blank'; + source.rel = 'noopener noreferrer'; + source.textContent = 'on Unsplash'; + source.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + attribution.appendChild(photographer); + attribution.appendChild(source); + + // download icon + const downloadIcon = window.document.createElement('div'); + downloadIcon.className = 'phoenix-download-icon'; + downloadIcon.title = config.strings.imageGalleryUseImage; + downloadIcon.innerHTML = ` + + `; + + // when the image is clicked we download the image + thumbDiv.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + // prevent multiple downloads of the same image + if (thumbDiv.classList.contains('downloading')) { return; } + + // show download indicator + this._showDownloadIndicator(thumbDiv); + + const filename = this._generateFilename(image); + const extnName = ".jpg"; + + const targetWidth = this._originalImageStyle.width || this.maxWidth; + const targetHeight = this._originalImageStyle.height || this.maxHeight; + const widthNum = parseInt(targetWidth); + const heightNum = parseInt(targetHeight); + + const downloadUrl = image.url ? `${image.url}?w=${widthNum}&h=${heightNum}&fit=crop` : image.thumb_url; + this._useImage(downloadUrl, filename, extnName, false, thumbDiv); + }); + + thumbDiv.appendChild(img); + thumbDiv.appendChild(attribution); + thumbDiv.appendChild(downloadIcon); + rowElement.appendChild(thumbDiv); + }); + + if (append && container && savedScrollPosition > 0) { + setTimeout(() => { + container.scrollLeft = savedScrollPosition; + }, 0); + } + }, + + _showError: function(message) { + const rowElement = this._shadow.querySelector('.phoenix-ribbon-row'); + if (!rowElement) { return; } + + rowElement.innerHTML = message; + rowElement.className = 'phoenix-ribbon-row phoenix-ribbon-error'; + }, + + // file name with which we need to save the image + _generateFilename: function(image) { + const photographerName = (image.user && image.user.name) || 'Anonymous'; + const searchTerm = this._currentSearchQuery || 'image'; + + // clean the search term and the photograper name to write in file name + const cleanSearchTerm = searchTerm.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const cleanPhotographerName = photographerName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + + return `${cleanSearchTerm}-by-${cleanPhotographerName}`; + }, + + _useImage: function(imageUrl, filename, extnName, isLocalFile, thumbDiv) { + // send the message to the editor instance to save the image and update the source code + const tagId = this.element.getAttribute("data-brackets-id"); + + const messageData = { + livePreviewEditEnabled: true, + useImage: true, + imageUrl: imageUrl, + filename: filename, + extnName: extnName, + element: this.element, + tagId: Number(tagId) + }; + + // if this is a local file we need some more data before sending it to the editor + if (isLocalFile) { + messageData.isLocalFile = true; + // Convert data URL to binary data array for local files + const byteCharacters = atob(imageUrl.split(',')[1]); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + messageData.imageData = byteNumbers; + } + + window._Brackets_MessageBroker.send(messageData); + + // if thumbDiv is provided, hide the download indicator after a reasonable timeout + // this is to make sure that the indicator is always removed even if there's no explicit success callback + if (thumbDiv) { + setTimeout(() => { + this._hideDownloadIndicator(thumbDiv); + }, 3000); + } + }, + + _handleLocalImageSelection: function(file) { + if (!file || !file.type.startsWith('image/')) { + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const imageDataUrl = e.target.result; + + const originalName = file.name; + const nameWithoutExt = originalName.substring(0, originalName.lastIndexOf('.')) || originalName; + const extension = originalName.substring(originalName.lastIndexOf('.')) || '.jpg'; + + // we clean the file name because the file might have some chars which might not be compatible + const cleanName = nameWithoutExt.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const filename = cleanName || 'selected-image'; + + // Use the unified _useImage method with isLocalFile flag + this._useImage(imageDataUrl, filename, extension, true, null); + + // Close the ribbon after successful selection + this.remove(); + }; + + reader.onerror = (error) => { + console.error('Something went wrong when reading the image:', error); + }; + + reader.readAsDataURL(file); + }, + + + create: function() { + this.remove(); // remove existing ribbon if already present + + // when image ribbon gallery is created we get the original image along with its dimensions + // so that on hover in we can show the hovered image and on hover out we can restore the original image + this._originalImageSrc = this.element.src; + this._originalImageStyle = { + width: window.getComputedStyle(this.element).width, + height: window.getComputedStyle(this.element).height, + objectFit: window.getComputedStyle(this.element).objectFit + }; + + this._style(); + window.document.body.appendChild(this.body); + this._attachEventHandlers(); + + const queryToUse = _imageGalleryCache.currentQuery || this._getDefaultQuery(); + this._fetchImages(queryToUse); + setTimeout(() => this._updateNavButtons(), 0); + }, + + remove: function () { + _imageRibbonGallery = null; + if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) { + window.document.body.removeChild(this.body); + this.body = null; + } + }, + + _showDownloadIndicator: function(thumbDiv) { + // add downloading class + thumbDiv.classList.add('downloading'); + + // create download indicator + const indicator = window.document.createElement('div'); + indicator.className = 'phoenix-download-indicator'; + + const spinner = window.document.createElement('div'); + spinner.className = 'phoenix-download-spinner'; + + indicator.appendChild(spinner); + thumbDiv.appendChild(indicator); + }, + + _hideDownloadIndicator: function(thumbDiv) { + // remove downloading class + thumbDiv.classList.remove('downloading'); + + // remove download indicator + const indicator = thumbDiv.querySelector('.phoenix-download-indicator'); + if (indicator) { + indicator.remove(); + } + } + }; + function Highlight(color, trigger) { this.color = color; this.trigger = !!trigger; @@ -2105,7 +3096,7 @@ function RemoteFunctions(config = {}) { "top": offset.top + "px", "width": elementBounds.width + "px", "height": elementBounds.height + "px", - "z-index": 2000000, + "z-index": 2147483647, "margin": 0, "padding": 0, "position": "absolute", @@ -2219,6 +3210,7 @@ function RemoteFunctions(config = {}) { var _nodeInfoBox; var _nodeMoreOptionsBox; var _aiPromptBox; + var _imageRibbonGallery; var _setup = false; function onMouseOver(event) { @@ -2252,6 +3244,12 @@ function RemoteFunctions(config = {}) { return getHighlightMode() !== "click"; } + // helper function to check if image ribbon gallery should be shown + function shouldShowImageRibbon() { + if (_imageRibbonGallery) { return false; } + return config.imageRibbon !== false; + } + // helper function to clear element background highlighting function clearElementBackground(element) { if (element._originalBackgroundColor !== undefined) { @@ -2323,6 +3321,7 @@ function RemoteFunctions(config = {}) { function _selectElement(element) { // dismiss all UI boxes and cleanup previous element state when selecting a different element dismissUIAndCleanupState(); + dismissImageRibbonGallery(); if(!isElementEditable(element)) { return false; } @@ -2336,6 +3335,13 @@ function RemoteFunctions(config = {}) { _nodeMoreOptionsBox = null; } + // if the selected element is an image, show the image ribbon gallery (make sure its enabled in preferences) + if(element && element.tagName.toLowerCase() === 'img' && shouldShowImageRibbon()) { + if (!_imageRibbonGallery) { + _imageRibbonGallery = new ImageRibbonGallery(element); + } + } + element._originalOutline = element.style.outline; element.style.outline = "1px solid #4285F4"; @@ -2354,22 +3360,16 @@ function RemoteFunctions(config = {}) { /** * This function handles the click event on the live preview DOM element - * it is to show the advanced DOM manipulation options in the live preview + * this just stops the propagation because otherwise users might not be able to edit buttons or hyperlinks etc * @param {Event} event */ function onClick(event) { - dismissAIPromptBox(); const element = event.target; - // when user clicks on the HTML, BODY tags or elements inside HEAD, we want to remove the boxes - if(_nodeMoreOptionsBox && !isElementEditable(element)) { - dismissUIAndCleanupState(); - } else if(isElementEditable(element)) { + if(isElementEditable(element)) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - - _selectElement(element); } } @@ -2402,9 +3402,6 @@ function RemoteFunctions(config = {}) { } function onKeyDown(event) { - if ((event.key === "Escape" || event.key === "Esc")) { - dismissUIAndCleanupState(); - } if (!_setup && _validEvent(event)) { window.document.addEventListener("keyup", onKeyUp); window.document.addEventListener("mouseover", onMouseOver); @@ -2465,6 +3462,7 @@ function RemoteFunctions(config = {}) { // if no valid element present we dismiss the boxes if (!foundValidElement) { dismissUIAndCleanupState(); + dismissImageRibbonGallery(); } } @@ -2845,11 +3843,21 @@ function RemoteFunctions(config = {}) { const highlightModeChanged = oldHighlightMode !== newHighlightMode; const isProStatusChanged = oldConfig.isProUser !== config.isProUser; const highlightSettingChanged = oldConfig.highlight !== config.highlight; + const imageRibbonJustEnabled = !oldConfig.imageRibbon && config.imageRibbon; // Handle significant configuration changes if (highlightModeChanged || isProStatusChanged || highlightSettingChanged) { _handleConfigurationChange(); } + + // if user enabled the image ribbon setting and an image is selected, then we show the image ribbon + if (imageRibbonJustEnabled && previouslyClickedElement && + previouslyClickedElement.tagName.toLowerCase() === 'img') { + if (!_imageRibbonGallery) { + _imageRibbonGallery = new ImageRibbonGallery(previouslyClickedElement); + } + } + _updateEventListeners(); return JSON.stringify(config); @@ -2934,6 +3942,18 @@ function RemoteFunctions(config = {}) { return false; } + /** + * to dismiss the image ribbon gallery if its available + */ + function dismissImageRibbonGallery() { + if (_imageRibbonGallery) { + _imageRibbonGallery.remove(); + _imageRibbonGallery = null; + return true; + } + return false; + } + /** * Helper function to dismiss all UI boxes at once * @return {boolean} true if any boxes were dismissed, false otherwise @@ -3156,6 +4176,7 @@ function RemoteFunctions(config = {}) { "finishEditing" : finishEditing, "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes, "dismissUIAndCleanupState" : dismissUIAndCleanupState, + "dismissImageRibbonGallery" : dismissImageRibbonGallery, "registerHandlers" : registerHandlers }; } diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index 3b2381dc09..f1b3de2435 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -715,6 +715,16 @@ define(function (require, exports, module) { function dismissLivePreviewBoxes() { if (_protocol) { _protocol.evaluate("_LD.dismissUIAndCleanupState()"); + _protocol.evaluate("_LD.dismissImageRibbonGallery()"); + } + } + + /** + * Dismiss image ribbon gallery if it's open + */ + function dismissImageRibbonGallery() { + if (_protocol) { + _protocol.evaluate("_LD.dismissImageRibbonGallery()"); } } @@ -804,6 +814,7 @@ define(function (require, exports, module) { exports.redrawHighlight = redrawHighlight; exports.hasVisibleLivePreviewBoxes = hasVisibleLivePreviewBoxes; exports.dismissLivePreviewBoxes = dismissLivePreviewBoxes; + exports.dismissImageRibbonGallery = dismissImageRibbonGallery; exports.registerHandlers = registerHandlers; exports.updateConfig = updateConfig; exports.init = init; diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/LiveDevelopment/LivePreviewEdit.js index 1b40ba6727..ddddb8c465 100644 --- a/src/LiveDevelopment/LivePreviewEdit.js +++ b/src/LiveDevelopment/LivePreviewEdit.js @@ -28,6 +28,9 @@ define(function (require, exports, module) { const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"); const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser"); const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"); + const ProjectManager = require("project/ProjectManager"); + const FileSystem = require("filesystem/FileSystem"); + const PathUtils = require("thirdparty/path-utils/path-utils"); /** * This function syncs text content changes between the original source code @@ -593,6 +596,204 @@ define(function (require, exports, module) { // write the AI implementation here...@abose } + /** + * this is a helper function to make sure that when saving a new image, there's no existing file with the same name + * @param {String} basePath - this is the base path where the image will be saved + * @param {String} filename - the name of the image file + * @param {String} extnName - the name of the image extension. (defaults to "jpg") + * @returns {String} - the new file name + */ + function getUniqueFilename(basePath, filename, extnName) { + let counter = 0; + let uniqueFilename = filename + extnName; + + function checkAndIncrement() { + const filePath = basePath + uniqueFilename; + const file = FileSystem.getFileForPath(filePath); + + return new Promise((resolve) => { + file.exists((err, exists) => { + if (exists) { + counter++; + uniqueFilename = `${filename}-${counter}${extnName}`; + checkAndIncrement().then(resolve); + } else { + resolve(uniqueFilename); + } + }); + }); + } + + return checkAndIncrement(); + } + + /** + * This function updates the src attribute of an image element in the source code + * @param {Number} tagId - the data-brackets-id of the image element + * @param {String} newSrcValue - the new src value to set + */ + function _updateImageSrcAttribute(tagId, newSrcValue) { + const editor = _getEditorAndValidate(tagId); + if (!editor) { + return; + } + + const range = _getElementRange(editor, tagId); + if (!range) { + return; + } + + const { startPos, endPos } = range; + const elementText = editor.getTextBetween(startPos, endPos); + + // parse it using DOM parser so that we can update the src attribute + const parser = new DOMParser(); + const doc = parser.parseFromString(elementText, "text/html"); + const imgElement = doc.querySelector('img'); + + if (imgElement) { + imgElement.setAttribute('src', newSrcValue); + const updatedElementText = imgElement.outerHTML; + + editor.document.batchOperation(function () { + editor.replaceRange(updatedElementText, startPos, endPos); + }); + } + } + + /** + * Helper function to update image src attribute and dismiss ribbon gallery + * + * @param {Number} tagId - the data-brackets-id of the image element + * @param {String} targetPath - the full path where the image was saved + * @param {String} filename - the filename of the saved image + */ + function _updateImageAndDismissRibbon(tagId, targetPath, filename) { + const editor = _getEditorAndValidate(tagId); + if (editor) { + const htmlFilePath = editor.document.file.fullPath; + const relativePath = PathUtils.makePathRelative(targetPath, htmlFilePath); + _updateImageSrcAttribute(tagId, relativePath); + } else { + _updateImageSrcAttribute(tagId, filename); + } + + // dismiss the image ribbon gallery + const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); + if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { + currLiveDoc.protocol.evaluate("_LD.dismissImageRibbonGallery()"); + } + } + + /** + * helper function to handle 'upload from computer' + * @param {Object} message - the message object + * @param {String} filename - the file name with which we need to save the image + * @param {Directory} projectRoot - the project root in which the image is to be saved + */ + function _handleUseThisImageLocalFiles(message, filename, projectRoot) { + const { tagId, imageData } = message; + + const uint8Array = new Uint8Array(imageData); + const targetPath = projectRoot.fullPath + filename; + + window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array), + { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => { + if (err) { + console.error('Failed to save image:', err); + } else { + _updateImageAndDismissRibbon(tagId, targetPath, filename); + } + }); + } + + /** + * helper function to handle 'use this image' button click on remote images + * @param {Object} message - the message object + * @param {String} filename - the file name with which we need to save the image + * @param {Directory} projectRoot - the project root in which the image is to be saved + */ + function _handleUseThisImageRemote(message, filename, projectRoot) { + const { imageUrl, tagId } = message; + + fetch(imageUrl) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.arrayBuffer(); + }) + .then(arrayBuffer => { + const uint8Array = new Uint8Array(arrayBuffer); + const targetPath = projectRoot.fullPath + filename; + + window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array), + { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => { + if (err) { + console.error('Failed to save image:', err); + } else { + _updateImageAndDismissRibbon(tagId, targetPath, filename); + } + }); + }) + .catch(error => { + console.error('Failed to fetch image:', error); + }); + } + + /** + * This function is called when 'use this image' button is clicked in the image ribbon gallery + * or user loads an image file from the computer + * this is responsible to download the image in the appropriate place + * and also change the src attribute of the element (by calling appropriate helper functions) + * @param {Object} message - the message object which stores all the required data for this operation + */ + function _handleUseThisImage(message) { + const filename = message.filename; + const extnName = message.extnName || "jpg"; + + const projectRoot = ProjectManager.getProjectRoot(); + if (!projectRoot) { return; } + + // phoenix-assets folder, all the images will be stored inside this + const phoenixAssetsPath = projectRoot.fullPath + "phoenix-code-assets/"; + const phoenixAssetsDir = FileSystem.getDirectoryForPath(phoenixAssetsPath); + + // check if the phoenix-assets dir exists + // if present, download the image inside it, if not create the dir and then download the image inside it + phoenixAssetsDir.exists((err, exists) => { + if (err) { return; } + + if (!exists) { + phoenixAssetsDir.create((err) => { + if (err) { + console.error('Error creating phoenix-code-assets directory:', err); + return; + } + _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir); + }); + } else { + _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir); + } + }); + } + + /** + * Helper function to download image to phoenix-assets folder + */ + function _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir) { + getUniqueFilename(phoenixAssetsDir.fullPath, filename, extnName).then((uniqueFilename) => { + // check if the image is loaded from computer or from remote + if (message.isLocalFile && message.imageData) { + _handleUseThisImageLocalFiles(message, uniqueFilename, phoenixAssetsDir); + } else { + _handleUseThisImageRemote(message, uniqueFilename, phoenixAssetsDir); + } + }).catch(error => { + console.error('Something went wrong when trying to use this image', error); + }); + } + /** * This is the main function that is exported. * it will be called by LiveDevProtocol when it receives a message from RemoteFunctions.js @@ -623,6 +824,12 @@ define(function (require, exports, module) { return; } + // use this image + if (message.useImage && message.imageUrl && message.filename) { + _handleUseThisImage(message); + return; + } + if (!message.element || !message.tagId) { // check for undo if (message.undoLivePreviewOperation || message.redoLivePreviewOperation) { diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index 451427b2da..902321ae2e 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -70,6 +70,7 @@ define(function main(require, exports, module) { }, isProUser: isProUser, elemHighlights: "hover", // default value, this will get updated when the extension loads + imageRibbon: true, // default value, this will get updated when the extension loads // this strings are used in RemoteFunctions.js // we need to pass this through config as remoteFunctions runs in browser context and cannot // directly reference Strings file @@ -79,7 +80,11 @@ define(function main(require, exports, module) { duplicate: Strings.LIVE_DEV_MORE_OPTIONS_DUPLICATE, delete: Strings.LIVE_DEV_MORE_OPTIONS_DELETE, ai: Strings.LIVE_DEV_MORE_OPTIONS_AI, - aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER + aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER, + imageGalleryUseImage: Strings.LIVE_DEV_IMAGE_GALLERY_USE_IMAGE, + imageGallerySelectFromComputer: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER, + imageGalleryChooseFolder: Strings.LIVE_DEV_IMAGE_GALLERY_CHOOSE_FOLDER, + imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER } }; // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. @@ -365,6 +370,20 @@ define(function main(require, exports, module) { } } + // this function is responsible to update image picker config + // called from live preview extension when preference changes + function updateImageRibbonConfig() { + const prefValue = PreferencesManager.get("livePreviewImagePicker"); + config.imageRibbon = prefValue !== false; // default to true if undefined + + if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { + if (!prefValue) { MultiBrowserLiveDev.dismissImageRibbonGallery(); } // to remove any existing image ribbons + + MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); + MultiBrowserLiveDev.registerHandlers(); + } + } + // init commands CommandManager.register(Strings.CMD_LIVE_HIGHLIGHT, Commands.FILE_LIVE_HIGHLIGHT, togglePreviewHighlight); CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, _handleReloadLivePreviewCommand); @@ -393,6 +412,7 @@ define(function main(require, exports, module) { exports.togglePreviewHighlight = togglePreviewHighlight; exports.setLivePreviewEditFeaturesActive = setLivePreviewEditFeaturesActive; exports.updateElementHighlightConfig = updateElementHighlightConfig; + exports.updateImageRibbonConfig = updateImageRibbonConfig; exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds; exports.getLivePreviewDetails = MultiBrowserLiveDev.getLivePreviewDetails; exports.hideHighlight = MultiBrowserLiveDev.hideHighlight; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 4942e15225..cb2fd9350a 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -113,6 +113,12 @@ define(function (require, exports, module) { description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE }); + // live preview image picker preference (whether to show image gallery when clicking images) + const PREFERENCE_PROJECT_IMAGE_RIBBON = "livePreviewImagePicker"; + PreferencesManager.definePreference(PREFERENCE_PROJECT_IMAGE_RIBBON, "boolean", true, { + description: Strings.LIVE_PREVIEW_EDIT_IMAGE_RIBBON + }); + const LIVE_PREVIEW_PANEL_ID = "live-preview-panel"; const LIVE_PREVIEW_IFRAME_ID = "panel-live-preview-frame"; const LIVE_PREVIEW_IFRAME_HTML = ` @@ -419,6 +425,7 @@ define(function (require, exports, module) { if (isEditFeaturesActive) { items.push("---"); items.push(Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON); + items.push(Strings.LIVE_PREVIEW_EDIT_IMAGE_RIBBON); } const rawMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); @@ -444,6 +451,12 @@ define(function (require, exports, module) { return `✓ ${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; } return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; + } else if (item === Strings.LIVE_PREVIEW_EDIT_IMAGE_RIBBON) { + const isImageRibbonEnabled = PreferencesManager.get(PREFERENCE_PROJECT_IMAGE_RIBBON) !== false; + if(isImageRibbonEnabled) { + return `✓ ${item}`; + } + return `${'\u00A0'.repeat(4)}${item}`; } return item; }); @@ -492,6 +505,15 @@ define(function (require, exports, module) { const newMode = currentMode !== "click" ? "click" : "hover"; PreferencesManager.set(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, newMode); return; // Don't dismiss highlights for this option + } else if (item === Strings.LIVE_PREVIEW_EDIT_IMAGE_RIBBON) { + // Don't allow image ribbon toggle if edit features are not active + if (!isEditFeaturesActive) { + return; + } + // Toggle image ribbon preference + const currentEnabled = PreferencesManager.get(PREFERENCE_PROJECT_IMAGE_RIBBON); + PreferencesManager.set(PREFERENCE_PROJECT_IMAGE_RIBBON, !currentEnabled); + return; // Don't dismiss highlights for this option } // need to dismiss the previous highlighting and stuff @@ -1296,8 +1318,15 @@ define(function (require, exports, module) { LiveDevelopment.updateElementHighlightConfig(); }); + // Handle image ribbon preference changes from this extension + PreferencesManager.on("change", PREFERENCE_PROJECT_IMAGE_RIBBON, function() { + LiveDevelopment.updateImageRibbonConfig(); + }); + // Initialize element highlight config on startup LiveDevelopment.updateElementHighlightConfig(); + // Initialize image ribbon config on startup + LiveDevelopment.updateImageRibbonConfig(); LiveDevelopment.openLivePreview(); LiveDevelopment.on(LiveDevelopment.EVENT_OPEN_PREVIEW_URL, _openLivePreviewURL); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 7b6f551094..3770cad5bc 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -189,12 +189,17 @@ define({ "LIVE_DEV_MORE_OPTIONS_DUPLICATE": "Duplicate", "LIVE_DEV_MORE_OPTIONS_DELETE": "Delete", "LIVE_DEV_MORE_OPTIONS_AI": "Edit with AI", + "LIVE_DEV_IMAGE_GALLERY_USE_IMAGE": "Use this image", + "LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER": "Select image from computer", + "LIVE_DEV_IMAGE_GALLERY_CHOOSE_FOLDER": "Choose download folder", + "LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER": "Search images...", "LIVE_DEV_AI_PROMPT_PLACEHOLDER": "Ask Phoenix AI to modify this element...", "LIVE_PREVIEW_CUSTOM_SERVER_BANNER": "Getting preview from your custom server {0}", "LIVE_PREVIEW_MODE_PREVIEW": "Preview Mode", "LIVE_PREVIEW_MODE_HIGHLIGHT": "Highlight Mode", "LIVE_PREVIEW_MODE_EDIT": "Edit Mode", "LIVE_PREVIEW_EDIT_HIGHLIGHT_ON": "Edit Highlights on Hover", + "LIVE_PREVIEW_EDIT_IMAGE_RIBBON": "Show Image Picker on Image click", "LIVE_PREVIEW_MODE_PREFERENCE": "{0} shows only the webpage, {1} connects the webpage to your code - click on elements to jump to their code and vice versa, {2} provides highlighting along with advanced element manipulation", "LIVE_PREVIEW_CONFIGURE_MODES": "Configure Live Preview Modes", "LIVE_PREVIEW_PRO_FEATURE_TITLE": "Pro Feature",