diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 26a43dbef..f4bb2aa50 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -60,6 +60,9 @@ function RemoteFunctions(config = {}) { const AUTO_SCROLL_SPEED = 12; // pixels per scroll const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom) + // to track the state as we want to have a selected state for image gallery + let imageGallerySelected = false; + /** * this function is responsible to auto scroll the live preview when * dragging an element to the viewport edges @@ -128,8 +131,8 @@ function RemoteFunctions(config = {}) { if(element && // element should exist element.hasAttribute("data-brackets-id") && // should have the data-brackets-id attribute - element.tagName !== "BODY" && // shouldn't be the body tag - element.tagName !== "HTML" && // shouldn't be the HTML tag + element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag + element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all return true; } @@ -142,9 +145,22 @@ function RemoteFunctions(config = {}) { function _isInsideHeadTag(element) { let parent = element; while (parent && parent !== window.document) { - if (parent.tagName === "HEAD") { + if (parent.tagName.toLowerCase() === "head") { // allow
${content}
`; this._shadow = shadow; @@ -1688,9 +1907,9 @@ function RemoteFunctions(config = {}) { .phoenix-ai-prompt-box { position: absolute !important; - background: white !important; + background: #3C3F41 !important; border: 1px solid #4285F4 !important; - border-radius: 8px !important; + border-radius: 4px !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; font-family: Arial, sans-serif !important; z-index: 2147483647 !important; @@ -1707,55 +1926,56 @@ function RemoteFunctions(config = {}) { width: 100% !important; height: ${boxHeight}px !important; border: none !important; - border-radius: 8px !important; + border-radius: 4px 4px 0 0 !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; + background: transparent !important; + color: #c5c5c5 !important; + transition: background 0.2s ease !important; } .phoenix-ai-prompt-textarea:focus { - background: white !important; + background: rgba(255, 255, 255, 0.03) !important; } .phoenix-ai-prompt-textarea::placeholder { - color: #999 !important; + color: #a0a0a0 !important; + opacity: 0.7 !important; } .phoenix-ai-prompt-send-button { - width: 28px !important; - height: 28px !important; - border: none !important; - border-radius: 50% !important; - background: #4285F4 !important; - color: white !important; + background-color: transparent !important; + border: 1px solid transparent !important; + color: #a0a0a0 !important; + border-radius: 4px !important; cursor: pointer !important; + padding: 3px 6px !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; + transition: all 0.2s ease !important; } .phoenix-ai-prompt-send-button:hover:not(:disabled) { - background: #4285F4 !important; + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; } .phoenix-ai-prompt-send-button:disabled { - background: #dadce0 !important; - color: #9aa0a6 !important; + opacity: 0.5 !important; cursor: not-allowed !important; } .phoenix-ai-bottom-controls { - border-top: 1px solid #e0e0e0 !important; + border-top: 1px solid rgba(255,255,255,0.14) !important; padding: 8px 16px !important; - background: #f9f9f9 !important; - border-radius: 0 0 8px 8px !important; + background: transparent !important; + border-radius: 0 0 4px 4px !important; display: flex !important; align-items: center !important; justify-content: space-between !important; @@ -1763,16 +1983,30 @@ function RemoteFunctions(config = {}) { .phoenix-ai-model-select { padding: 4px 8px !important; - border: 1px solid #ddd !important; + border: 1px solid transparent !important; border-radius: 4px !important; font-size: 12px !important; - background: white !important; + background: transparent !important; + color: #a0a0a0 !important; outline: none !important; cursor: pointer !important; + transition: all 0.2s ease !important; + } + + .phoenix-ai-model-select:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; } .phoenix-ai-model-select:focus { - border-color: #4285F4 !important; + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; + } + + .phoenix-ai-model-select option { + background: #000 !important; + color: #fff !important; + padding: 4px 8px !important; } `; @@ -1791,7 +2025,7 @@ function RemoteFunctions(config = {}) { @@ -1913,13 +2147,80 @@ 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 CACHE_EXPIRY_TIME = 168 * 60 * 60 * 1000; // 7 days, might need to revise this... + const CACHE_MAX_IMAGES = 50; // max number of images that we store in the localStorage const _imageGalleryCache = { - currentQuery: null, - allImages: [], - totalPages: 1, - currentPage: 1, - maxImages: 50 + get currentQuery() { + const data = this._getFromStorage(); + return data ? data.currentQuery : null; + }, + set currentQuery(val) { + this._updateStorage({currentQuery: val}); + }, + + get allImages() { + const data = this._getFromStorage(); + return data ? data.allImages : []; + }, + set allImages(val) { + this._updateStorage({allImages: val}); + }, + + get totalPages() { + const data = this._getFromStorage(); + return data ? data.totalPages : 1; + }, + set totalPages(val) { + this._updateStorage({totalPages: val}); + }, + + get currentPage() { + const data = this._getFromStorage(); + return data ? data.currentPage : 1; + }, + set currentPage(val) { + this._updateStorage({currentPage: val}); + }, + + + _getFromStorage() { + try { + const data = window.localStorage.getItem('imageGalleryCache'); + if (!data) { return null; } + + const parsed = JSON.parse(data); + + if (Date.now() > parsed.expires) { + window.localStorage.removeItem('imageGalleryCache'); + return null; + } + + return parsed; + } catch (error) { + return null; + } + }, + + _updateStorage(updates) { + try { + const current = this._getFromStorage() || {}; + const newData = { + ...current, + ...updates, + expires: Date.now() + CACHE_EXPIRY_TIME + }; + window.localStorage.setItem('imageGalleryCache', JSON.stringify(newData)); + } catch (error) { + if (error.name === 'QuotaExceededError') { + try { + window.localStorage.removeItem('imageGalleryCache'); + window.localStorage.setItem('imageGalleryCache', JSON.stringify(updates)); + } catch (retryError) { + console.error('Failed to save image cache even after clearing:', retryError); + } + } + } + } }; /** @@ -1934,8 +2235,6 @@ function RemoteFunctions(config = {}) { 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(); } @@ -1953,7 +2252,7 @@ function RemoteFunctions(config = {}) { left: 0 !important; right: 0 !important; width: 100vw !important; - background: linear-gradient(180deg, rgba(12,14,20,0.0), rgba(12,14,20,0.7)) !important; + background: #3C3F41 !important; z-index: 2147483647 !important; display: flex !important; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial !important; @@ -1963,11 +2262,7 @@ function RemoteFunctions(config = {}) { .phoenix-ribbon-container { width: 100% !important; height: 156px !important; - background: rgba(255, 255, 255, 0.3) !important; - backdrop-filter: blur(10px) !important; - -webkit-backdrop-filter: blur(10px) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; - border-radius: 12px !important; position: relative !important; } @@ -1977,7 +2272,7 @@ function RemoteFunctions(config = {}) { overflow: hidden !important; scroll-behavior: smooth !important; padding: 6px !important; - top: 34px !important; + top: 30px !important; } .phoenix-ribbon-row { @@ -2020,7 +2315,6 @@ function RemoteFunctions(config = {}) { color: #eaeaf0 !important; background: rgba(21,25,36,0.65) !important; cursor: pointer !important; - backdrop-filter: blur(8px) !important; font-size: 20px !important; font-weight: 600 !important; user-select: none !important; @@ -2066,87 +2360,164 @@ function RemoteFunctions(config = {}) { font-size: 14px !important; } + .phoenix-loading-more { + 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; + } + .phoenix-ribbon-header { display: flex !important; width: 100% !important; position: absolute !important; - top: 5px !important; + top: 7px !important; } .phoenix-ribbon-header-left { width: 80% !important; display: flex !important; + align-items: center !important; } .phoenix-ribbon-header-right { width: 20% !important; display: flex !important; justify-content: flex-end !important; + align-items: center !important; } .phoenix-ribbon-search { display: flex !important; - align-items: center !important; - background: rgba(0,0,0,0.5) !important; - padding: 5px !important; - border-radius: 5px !important; + align-items: stretch !important; + border-radius: 6px !important; margin-left: 8px !important; + border: 1px solid rgba(255,255,255,0.14) !important; + } + + .phoenix-ribbon-search:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; + } + + .phoenix-ribbon-search:focus-within { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; } .phoenix-ribbon-search input { background: transparent !important; border: none !important; outline: none !important; - color: white !important; - width: 200px !important; + color: #c5c5c5 !important; + width: 150px !important; + padding: 4px 8px !important; + border-radius: 4px 0 0 4px !important; + transition: background 0.2s ease !important; + } + + .phoenix-ribbon-search input:focus { + background: rgba(255, 255, 255, 0.03) !important; } .phoenix-ribbon-search input::placeholder { - color: rgba(255, 255, 255, 0.7) !important; - opacity: 1 !important; + color: #a0a0a0 !important; + opacity: 0.7 !important; } .phoenix-ribbon-search input::-webkit-input-placeholder { - color: rgba(255, 255, 255, 0.7) !important; + color: #a0a0a0 !important; + opacity: 0.7 !important; } .phoenix-ribbon-search input::-moz-placeholder { - color: rgba(255, 255, 255, 0.7) !important; - opacity: 1 !important; + color: #a0a0a0 !important; + opacity: 0.7 !important; } .phoenix-ribbon-search-btn { - background: none !important; - border: none !important; - color: #6aa9ff !important; + background: transparent !important; + border: 1px solid transparent !important; + border-left: 1px solid gray !important; + color: #a0a0a0 !important; cursor: pointer !important; + padding: 2px 6px !important; + border-radius: 0 4px 4px 0 !important; + font-size: 12px !important; + font-weight: 500 !important; + transition: all 0.2s ease !important; + margin-left: 0 !important; + } + + .phoenix-ribbon-search-btn:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; } .phoenix-ribbon-select { - margin-left: 10px !important; + margin-left: 4px !important; } .phoenix-select-image-btn { - background: gray !important; - border: 1px solid rgba(255, 255, 255, 0.2) !important; - color: #fff !important; - padding: 2px 4px !important; - border-radius: 6px !important; - font-size: 12px !important; + background-color: transparent !important; + border: 1px solid transparent !important; + color: #a0a0a0 !important; + border-radius: 4px !important; cursor: pointer !important; - transition: all 0.2s ease !important; + padding: 3px 6px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .phoenix-select-image-btn:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; + } + + .phoenix-ribbon-folder-settings { + background-color: transparent !important; + border: 1px solid transparent !important; + color: #a0a0a0 !important; + border-radius: 4px !important; + cursor: pointer !important; + margin-right: 2px !important; + padding: 3px 6px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .phoenix-ribbon-folder-settings:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; } .phoenix-ribbon-close { - background: rgba(0,0,0,0.5) !important; - border: none !important; - color: white !important; + background-color: transparent !important; + border: 1px solid transparent !important; + color: #a0a0a0 !important; + border-radius: 4px !important; cursor: pointer !important; - padding: 4px 8px !important; - border-radius: 3px !important; + padding: 3px 6px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; margin-right: 16px !important; } + .phoenix-ribbon-close:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; + } + .phoenix-ribbon-attribution { position: absolute !important; bottom: 6px !important; @@ -2274,26 +2645,28 @@ function RemoteFunctions(config = {}) {
- + +
- Loading images... + ${config.strings.imageGalleryLoadingInitial}
@@ -2334,7 +2707,7 @@ function RemoteFunctions(config = {}) { }, _fetchFromAPI: function(searchQuery, page, append) { - // when we fetch from API, we clear the cache and then store a fresh copy + // when we fetch from API, we clear the previous query from local storage and then store a fresh copy if (searchQuery !== _imageGalleryCache.currentQuery) { this._clearCache(); } @@ -2367,7 +2740,7 @@ function RemoteFunctions(config = {}) { this._updateSearchInput(searchQuery); this._updateCache(searchQuery, data, append); } else if (!append) { - this._showError('No images found'); + this._showError(config.strings.imageGalleryNoImages); } if (append) { @@ -2378,7 +2751,7 @@ function RemoteFunctions(config = {}) { .catch(error => { console.error('Failed to fetch images:', error); if (!append) { - this._showError('Failed to load images'); + this._showError(config.strings.imageGalleryLoadError); } else { this._isLoadingMore = false; this._hideLoadingMore(); @@ -2393,11 +2766,11 @@ function RemoteFunctions(config = {}) { _imageGalleryCache.currentPage = this.currentPage; if (append) { - // Append new results to existing cache - const newImages = _imageGalleryCache.allImages.concat(data.results); + const currentImages = _imageGalleryCache.allImages; + const newImages = currentImages.concat(data.results); - if (newImages.length > _imageGalleryCache.maxImages) { // max = 50 - _imageGalleryCache.allImages = newImages.slice(-_imageGalleryCache.maxImages); + if (newImages.length > CACHE_MAX_IMAGES) { + _imageGalleryCache.allImages = newImages.slice(-CACHE_MAX_IMAGES); } else { _imageGalleryCache.allImages = newImages; } @@ -2408,11 +2781,11 @@ function RemoteFunctions(config = {}) { }, _clearCache: function() { - // clear current cache when switching to new query - _imageGalleryCache.currentQuery = null; - _imageGalleryCache.allImages = []; - _imageGalleryCache.totalPages = 1; - _imageGalleryCache.currentPage = 1; + try { + window.localStorage.removeItem('imageGalleryCache'); + } catch (error) { + console.error('Failed to clear image cache:', error); + } }, _updateSearchInput: function(searchQuery) { @@ -2425,7 +2798,6 @@ function RemoteFunctions(config = {}) { }, _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; @@ -2434,13 +2806,12 @@ function RemoteFunctions(config = {}) { this._renderImages(this.allImages, false); this._updateNavButtons(); this._updateSearchInput(searchQuery); - return true; // Successfully loaded from cache + return true; } - return false; // unable to load from cache + return false; }, _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; @@ -2453,7 +2824,7 @@ function RemoteFunctions(config = {}) { this._updateNavButtons(); this._isLoadingMore = false; this._hideLoadingMore(); - return true; // Successfully loaded page from cache + return true; } } return false; @@ -2526,7 +2897,7 @@ function RemoteFunctions(config = {}) { const rowElement = this._shadow.querySelector('.phoenix-ribbon-row'); if (!rowElement) { return; } - rowElement.innerHTML = 'Loading images...'; + rowElement.innerHTML = config.strings.imageGalleryLoadingInitial; rowElement.className = 'phoenix-ribbon-row phoenix-ribbon-loading'; }, @@ -2537,20 +2908,7 @@ function RemoteFunctions(config = {}) { // 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...'; + loadingIndicator.textContent = config.strings.imageGalleryLoadingMore; rowElement.appendChild(loadingIndicator); }, @@ -2566,6 +2924,7 @@ function RemoteFunctions(config = {}) { 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 folderSettingsButton = this._shadow.querySelector('.phoenix-ribbon-folder-settings'); 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'); @@ -2616,6 +2975,22 @@ function RemoteFunctions(config = {}) { closeButton.addEventListener('click', (e) => { e.stopPropagation(); this.remove(); + imageGallerySelected = false; + dismissUIAndCleanupState(); + }); + } + + if (folderSettingsButton) { + folderSettingsButton.addEventListener('click', (e) => { + e.stopPropagation(); + // send message to LivePreviewEdit to show folder selection dialog + const tagId = this.element.getAttribute("data-brackets-id"); + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + resetImageFolderSelection: true, + element: this.element, + tagId: Number(tagId) + }); }); } @@ -2674,8 +3049,8 @@ function RemoteFunctions(config = {}) { // 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.width = this._originalImageStyle.width; + this.element.style.height = this._originalImageStyle.height; this.element.style.objectFit = this._originalImageStyle.objectFit || 'cover'; this.element.src = image.url || image.thumb_url; @@ -2717,9 +3092,7 @@ function RemoteFunctions(config = {}) { const downloadIcon = window.document.createElement('div'); downloadIcon.className = 'phoenix-download-icon'; downloadIcon.title = config.strings.imageGalleryUseImage; - downloadIcon.innerHTML = ` - - `; + downloadIcon.innerHTML = ICONS.downloadImage; // when the image is clicked we download the image thumbDiv.addEventListener('click', (e) => { @@ -2735,12 +3108,18 @@ function RemoteFunctions(config = {}) { 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.thumb_url; + + // we need to make a req to the download endpoint + // its required by the Unsplash API guidelines to track downloads for photographers + // this is just a tracking call, we don't need to wait for the response + if (image.download_location) { + fetch(image.download_location) + .catch(error => { + // + }); + } - const downloadUrl = image.url ? `${image.url}?w=${widthNum}&h=${heightNum}&fit=crop` : image.thumb_url; this._useImage(downloadUrl, filename, extnName, false, thumbDiv); }); @@ -3147,9 +3526,7 @@ function RemoteFunctions(config = {}) { window.document.body.appendChild(highlight); }, - // shouldAutoScroll is whether to scroll page to element if not in view - // true when user clicks on the source code of some element, in that case we want to scroll the live preview - add: function (element, doAnimation, shouldAutoScroll) { + add: function (element, doAnimation) { if (this._elementExists(element) || element === window.document) { return; } @@ -3157,15 +3534,7 @@ function RemoteFunctions(config = {}) { _trigger(element, "highlight", 1); } - if (shouldAutoScroll && (!window.event || window.event instanceof MessageEvent) && !isInViewport(element)) { - var top = getDocumentOffsetTop(element); - if (top) { - top -= (window.innerHeight / 2); - window.scrollTo(0, top); - } - } this.elements.push(element); - this._makeHighlightDiv(element, doAnimation); }, @@ -3177,10 +3546,11 @@ function RemoteFunctions(config = {}) { body.removeChild(highlights[i]); } - if (this.trigger) { - for (i = 0; i < this.elements.length; i++) { + for (i = 0; i < this.elements.length; i++) { + if (this.trigger) { _trigger(this.elements[i], "highlight", 0); } + clearElementBackground(this.elements[i]); } this.elements = []; @@ -3199,7 +3569,7 @@ function RemoteFunctions(config = {}) { this.clear(); for (i = 0; i < highlighted.length; i++) { - this.add(highlighted[i], false, false); // 3rd arg is for auto-scroll + this.add(highlighted[i], false); } } }; @@ -3212,12 +3582,13 @@ function RemoteFunctions(config = {}) { var _aiPromptBox; var _imageRibbonGallery; var _setup = false; + var _hoverLockTimer = null; function onMouseOver(event) { if (_validEvent(event)) { const element = event.target; if(isElementEditable(element) && element.nodeType === Node.ELEMENT_NODE ) { - _localHighlight.add(element, true, false); // false means no-auto scroll + _localHighlight.add(element, true); } } } @@ -3244,12 +3615,6 @@ 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) { @@ -3289,7 +3654,7 @@ function RemoteFunctions(config = {}) { element._originalBackgroundColor = element.style.backgroundColor; element.style.backgroundColor = "rgba(0, 162, 255, 0.2)"; - _hoverHighlight.add(element, false, false); // false means no auto-scroll + _hoverHighlight.add(element, false); // Create info box for the hovered element dismissNodeInfoBox(); @@ -3314,6 +3679,21 @@ function RemoteFunctions(config = {}) { } } + function scrollElementToViewPort(element) { + if (!element) { + return; + } + + // Check if element is in viewport, if not scroll to it + if (!isInViewport(element)) { + let top = getDocumentOffsetTop(element); + if (top) { + top -= (window.innerHeight / 2); + window.scrollTo(0, top); + } + } + } + /** * this function is responsible to select an element in the live preview * @param {Element} element - The DOM element to select @@ -3322,10 +3702,23 @@ function RemoteFunctions(config = {}) { // dismiss all UI boxes and cleanup previous element state when selecting a different element dismissUIAndCleanupState(); dismissImageRibbonGallery(); + + // this should always happen before isElementEditable check because this is not a live preview edit feature + // this should also be there when users are in highlight mode + scrollElementToViewPort(element); + if(!isElementEditable(element)) { return false; } + // if imageGallerySelected is true, show the image gallery directly + if(element && element.tagName.toLowerCase() === 'img' && imageGallerySelected) { + if (!_imageRibbonGallery) { + _imageRibbonGallery = new ImageRibbonGallery(element); + scrollImageToViewportIfRequired(element, _imageRibbonGallery); + } + } + // make sure that the element is actually visible to the user if (isElementVisible(element)) { _nodeMoreOptionsBox = new NodeMoreOptionsBox(element); @@ -3335,13 +3728,6 @@ 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"; @@ -3352,12 +3738,40 @@ function RemoteFunctions(config = {}) { if (_hoverHighlight) { _hoverHighlight.clear(); - _hoverHighlight.add(element, true, false); // false means no auto-scroll + _hoverHighlight.add(element, true); } previouslyClickedElement = element; } + function disableHoverListeners() { + window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mouseout", onElementHoverOut); + } + + function enableHoverListeners() { + if (config.isProUser && (config.highlight || shouldShowHighlightOnHover())) { + window.document.addEventListener("mouseover", onElementHover); + window.document.addEventListener("mouseout", onElementHoverOut); + } + } + + /** + * this function activates hover lock to prevent hover events + * Used when user performs click actions to avoid UI box conflicts + */ + function activateHoverLock() { + if (_hoverLockTimer) { + clearTimeout(_hoverLockTimer); + } + + disableHoverListeners(); + _hoverLockTimer = setTimeout(() => { + enableHoverListeners(); + _hoverLockTimer = null; + }, 1500); // 1.5s + } + /** * This function handles the click event on the live preview DOM element * this just stops the propagation because otherwise users might not be able to edit buttons or hyperlinks etc @@ -3370,6 +3784,7 @@ function RemoteFunctions(config = {}) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); + activateHoverLock(); } } @@ -3433,7 +3848,7 @@ function RemoteFunctions(config = {}) { _clickHighlight.clear(); } if (isElementEditable(element, true) && element.nodeType === Node.ELEMENT_NODE) { - _clickHighlight.add(element, true, true); // 3rd arg is for auto-scroll + _clickHighlight.add(element, true); } } @@ -3509,7 +3924,7 @@ function RemoteFunctions(config = {}) { // but the element stays at position which will lead to drift between the element & boxes function _dismissBoxesForFixedElements() { // first we try more options box, because its position is generally fixed even in overlapping cases - if (_nodeMoreOptionsBox && _nodeMoreOptionsBox.element) { + if (_nodeMoreOptionsBox && _nodeMoreOptionsBox.element && _nodeMoreOptionsBox._shadow) { const moreOptionsBoxElement = _nodeMoreOptionsBox._shadow.querySelector('.phoenix-more-options-box'); if(moreOptionsBoxElement) { @@ -3526,11 +3941,13 @@ function RemoteFunctions(config = {}) { // 4 is just for pixelated differences if (Math.abs(calcNewDifference - prevDifference) > 4) { - dismissUIAndCleanupState(); + dismissNodeInfoBox(); + dismissNodeMoreOptionsBox(); + cleanupPreviousElementState(); } } } - } else if (_nodeInfoBox && _nodeInfoBox.element) { + } else if (_nodeInfoBox && _nodeInfoBox.element && _nodeInfoBox._shadow) { // if more options box didn't exist, we check with info box (logic is same) const infoBoxElement = _nodeInfoBox._shadow.querySelector('.phoenix-node-info-box'); if (infoBoxElement) { @@ -3550,7 +3967,9 @@ function RemoteFunctions(config = {}) { const prevDifference = _nodeInfoBox._possDifference; if (Math.abs(calcNewDifference - prevDifference) > 4) { - dismissUIAndCleanupState(); + dismissNodeInfoBox(); + dismissNodeMoreOptionsBox(); + cleanupPreviousElementState(); } } } @@ -3558,6 +3977,37 @@ function RemoteFunctions(config = {}) { } } + // this function is responsible to reposition the AI box + // so we need to reposition it when for ex: a fixed positioned element is selected in the live preview + // now when live preview is scrolled the element remains at a fixed position but the AI box will drift away + // so we reposition it when its a fixed element + function _repositionAIBox() { + if (!_aiPromptBox || !_aiPromptBox.element || !_aiPromptBox._shadow) { return; } + + const aiBox = _aiPromptBox._shadow.querySelector('.phoenix-ai-prompt-box'); + if (!aiBox) { return; } + + const aiBoxBounds = aiBox.getBoundingClientRect(); + const elementBounds = _aiPromptBox.element.getBoundingClientRect(); + + // this is to store the prev value, so that we can compare it the second time + if(!_aiPromptBox._possDifference) { + _aiPromptBox._possDifference = aiBoxBounds.top - elementBounds.top; + } else { + const calcNewDifference = aiBoxBounds.top - elementBounds.top; + const prevDifference = _aiPromptBox._possDifference; + + // 4 is just for pixelated differences + if (Math.abs(calcNewDifference - prevDifference) > 4) { + const boxPositions = _aiPromptBox._getBoxPosition(aiBoxBounds.width, aiBoxBounds.height); + + aiBox.style.left = boxPositions.leftPos + 'px'; + aiBox.style.top = boxPositions.topPos + 'px'; + _aiPromptBox._possDifference = calcNewDifference; + } + } + } + function _scrollHandler(e) { // Document scrolls can be updated immediately. Any other scrolls // need to be updated on a timer to ensure the layout is correct. @@ -3565,11 +4015,13 @@ function RemoteFunctions(config = {}) { redrawHighlights(); // need to dismiss the box if the elements are fixed, otherwise they drift at times _dismissBoxesForFixedElements(); + _repositionAIBox(); // and reposition the AI box } else { if (_localHighlight || _clickHighlight || _hoverHighlight) { window.setTimeout(redrawHighlights, 0); } _dismissBoxesForFixedElements(); + _repositionAIBox(); } } @@ -3843,23 +4295,12 @@ 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 + // Handle 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); } @@ -3905,41 +4346,32 @@ function RemoteFunctions(config = {}) { /** * Helper function to dismiss NodeMoreOptionsBox if it exists - * @return {boolean} true if box was dismissed, false if it didn't exist */ function dismissNodeMoreOptionsBox() { if (_nodeMoreOptionsBox) { _nodeMoreOptionsBox.remove(); _nodeMoreOptionsBox = null; - return true; } - return false; } /** * Helper function to dismiss NodeInfoBox if it exists - * @return {boolean} true if box was dismissed, false if it didn't exist */ function dismissNodeInfoBox() { if (_nodeInfoBox) { _nodeInfoBox.remove(); _nodeInfoBox = null; - return true; } - return false; } /** * Helper function to dismiss AIPromptBox if it exists - * @return {boolean} true if box was dismissed, false if it didn't exist */ function dismissAIPromptBox() { if (_aiPromptBox) { _aiPromptBox.remove(); _aiPromptBox = null; - return true; } - return false; } /** @@ -3949,26 +4381,21 @@ function RemoteFunctions(config = {}) { 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 */ function dismissAllUIBoxes() { - let dismissed = false; - dismissed = dismissNodeMoreOptionsBox() || dismissed; - dismissed = dismissAIPromptBox() || dismissed; - dismissed = dismissNodeInfoBox() || dismissed; - return dismissed; + dismissNodeMoreOptionsBox(); + dismissAIPromptBox(); + dismissNodeInfoBox(); + dismissImageRibbonGallery(); } /** * Helper function to cleanup previously clicked element highlighting and state - * @return {boolean} true if cleanup was performed, false if no element to cleanup */ function cleanupPreviousElementState() { if (previouslyClickedElement) { @@ -3985,26 +4412,16 @@ function RemoteFunctions(config = {}) { } previouslyClickedElement = null; - return true; } - return false; } /** * This function dismisses all UI elements and cleans up application state * Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events - * @return {boolean} true if any cleanup was performed, false otherwise */ function dismissUIAndCleanupState() { - let dismissed = false; - - // Dismiss all UI boxes - dismissed = dismissAllUIBoxes() || dismissed; - - // Cleanup previously clicked element state and highlighting - dismissed = cleanupPreviousElementState() || dismissed; - - return dismissed; + dismissAllUIBoxes(); + cleanupPreviousElementState(); } @@ -4176,7 +4593,7 @@ function RemoteFunctions(config = {}) { "finishEditing" : finishEditing, "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes, "dismissUIAndCleanupState" : dismissUIAndCleanupState, - "dismissImageRibbonGallery" : dismissImageRibbonGallery, + "enableHoverListeners" : enableHoverListeners, "registerHandlers" : registerHandlers }; } diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index f1b3de243..e424e5271 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -714,17 +714,8 @@ define(function (require, exports, module) { */ function dismissLivePreviewBoxes() { if (_protocol) { + _protocol.evaluate("_LD.enableHoverListeners()"); // so that if hover lock is there it will get cleared _protocol.evaluate("_LD.dismissUIAndCleanupState()"); - _protocol.evaluate("_LD.dismissImageRibbonGallery()"); - } - } - - /** - * Dismiss image ribbon gallery if it's open - */ - function dismissImageRibbonGallery() { - if (_protocol) { - _protocol.evaluate("_LD.dismissImageRibbonGallery()"); } } @@ -814,7 +805,6 @@ 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 7b71ef105..860abf049 100644 --- a/src/LiveDevelopment/LivePreviewEdit.js +++ b/src/LiveDevelopment/LivePreviewEdit.js @@ -31,7 +31,16 @@ define(function (require, exports, module) { const ProjectManager = require("project/ProjectManager"); const FileSystem = require("filesystem/FileSystem"); const PathUtils = require("thirdparty/path-utils/path-utils"); + const StringMatch = require("utils/StringMatch"); + const Dialogs = require("widgets/Dialogs"); + const StateManager = require("preferences/StateManager"); const ProDialogs = require("services/pro-dialogs"); + const Mustache = require("thirdparty/mustache/mustache"); + const Strings = require("strings"); + const ImageFolderDialogTemplate = require("text!htmlContent/image-folder-dialog.html"); + + // state manager key, to save the download location of the image + const IMAGE_DOWNLOAD_FOLDER_KEY = "imageGallery.downloadFolder"; const KernalModeTrust = window.KernalModeTrust; if(!KernalModeTrust){ @@ -693,10 +702,10 @@ define(function (require, exports, module) { _updateImageSrcAttribute(tagId, filename); } - // dismiss the image ribbon gallery + // dismiss all UI boxes including the image ribbon gallery const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { - currLiveDoc.protocol.evaluate("_LD.dismissImageRibbonGallery()"); + currLiveDoc.protocol.evaluate("_LD.dismissUIAndCleanupState()"); } } @@ -757,58 +766,444 @@ define(function (require, exports, module) { } /** - * 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 + * Downloads image to the specified folder + * @private + * @param {Object} message - The message containing image download info + * @param {string} folderPath - Relative path to the folder */ - function _handleUseThisImage(message) { + function _downloadToFolder(message, folderPath) { + const projectRoot = ProjectManager.getProjectRoot(); + if (!projectRoot) { + console.error('No project root found'); + return; + } + const filename = message.filename; const extnName = message.extnName || "jpg"; - const projectRoot = ProjectManager.getProjectRoot(); - if (!projectRoot) { return; } + // the folder path should always end with / + if (!folderPath.endsWith('/')) { + folderPath += '/'; + } - // phoenix-assets folder, all the images will be stored inside this - const phoenixAssetsPath = projectRoot.fullPath + "phoenix-code-assets/"; - const phoenixAssetsDir = FileSystem.getDirectoryForPath(phoenixAssetsPath); + const targetPath = projectRoot.fullPath + folderPath; + const targetDir = FileSystem.getDirectoryForPath(targetPath); - // 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) => { + // the directory name that user wrote, first check if it exists or not + // if it doesn't exist we create it and then download the image inside it + targetDir.exists((err, exists) => { if (err) { return; } if (!exists) { - phoenixAssetsDir.create((err) => { - if (err) { - console.error('Error creating phoenix-code-assets directory:', err); - return; + targetDir.create((err) => { + if (err) { return; } + _downloadImageToDirectory(message, filename, extnName, targetDir); + }); + } else { + _downloadImageToDirectory(message, filename, extnName, targetDir); + } + }); + } + + /** + * This function is to determine whether we need to exclude a folder from the suggestions list + * so we exclude all the folders that start with . 'dot' as this are generally irrelevant dirs + * secondly, we also exclude large dirs like node modules as they might freeze the UI if we scan them + * @param {String} folderName - the folder name to check if we need to exclude it or not + * @returns {Boolean} - true if we should exclude otherwise false + */ + function _isExcludedFolder(folderName) { + if (folderName.startsWith('.')) { return true; } + + const UNNECESSARY_FOLDERS = ['node_modules', 'bower_components']; + if (UNNECESSARY_FOLDERS.includes(folderName)) { return true; } + + return false; + } + + /** + * this function scans all the root directories + * root directories means those directories that are directly inside the project folder + * we need this to show when the query is empty + * + * @param {Directory} directory - project root directory + * @param {Array} folderList - array to store discovered root folder paths + * @return {Promise} Resolves when root scan is complete + */ + function _scanRootDirectoriesOnly(directory, folderList) { + return new Promise((resolve) => { + directory.getContents((err, contents) => { + if (err) { + resolve(); + return; + } + + const directories = contents.filter(entry => entry.isDirectory); + + directories.forEach(dir => { + if (_isExcludedFolder(dir.name)) { return; } + // add root folder name with trailing slash + folderList.push(dir.name + '/'); + }); + resolve(); + }); + }); + } + + /** + * this function scans all the directories recursively + * and then add the relative paths of the directories to the folderList array + * + * @param {Directory} directory - The parent directory to scan + * @param {string} relativePath - The relative path from project root + * @param {Array} folderList - Array to store all discovered folder paths + * @return {Promise} Resolves when scanning is complete + */ + function _scanDirectories(directory, relativePath, folderList) { + return new Promise((resolve) => { + directory.getContents((err, contents) => { + if (err) { + resolve(); + return; + } + + const directories = contents.filter(entry => entry.isDirectory); + const scanPromises = []; + + directories.forEach(dir => { + if (_isExcludedFolder(dir.name)) { return; } + + const dirRelativePath = relativePath ? `${relativePath}${dir.name}/` : `${dir.name}/`; + folderList.push(dirRelativePath); + + // also check subdirectories for this dir + scanPromises.push(_scanDirectories(dir, dirRelativePath, folderList)); + }); + + Promise.all(scanPromises).then(() => resolve()); + }); + }); + } + + /** + * this function is responsible to get the subdirectories inside a directory + * we need this because we need to show the drilled down folders... + * @param {String} parentPath - Parent folder path (e.g., "images/") + * @param {Array} folderList - Complete list of all folder paths + * @return {Array} Array of direct subfolders only + */ + function _getSubfolders(parentPath, folderList) { + return folderList.filter(folder => { + if (!folder.startsWith(parentPath)) { return false; } + + const relativePath = folder.substring(parentPath.length); + const pathWithoutTrailingSlash = relativePath.replace(/\/$/, ''); + return !pathWithoutTrailingSlash.includes('/'); + }); + } + + /** + * Renders folder suggestions as a dropdown in the UI with fuzzy match highlighting + * + * @param {Array} matches - Array of folder paths (strings) or fuzzy match objects with stringRanges + * @param {JQuery} $suggestions - jQuery element for the suggestions container + * @param {JQuery} $input - jQuery element for the input field + */ + function _renderFolderSuggestions(matches, $suggestions, $input) { + if (matches.length === 0) { + $suggestions.empty(); + return; + } + + let html = '
    '; + matches.forEach((match, index) => { + let displayHTML = ''; + let folderPath = ''; + + // Check if match is a string or an object + if (typeof match === 'string') { + // Simple string (from empty query showing folders) + displayHTML = match; + folderPath = match; + } else if (match && match.stringRanges) { + // fuzzy match, highlight matched chars + match.stringRanges.forEach(range => { + if (range.matched) { + displayHTML += `${range.text}`; + } else { + displayHTML += range.text; } - _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir); }); + folderPath = match.label || ''; + } + + // first item should be selected by default + const selectedClass = index === 0 ? ' selected' : ''; + html += `
  • ${displayHTML}
  • `; + }); + html += '
'; + + $suggestions.html(html); + $suggestions.scrollTop(0); // always need to scroll to top when query changes + + // when a suggestion is clicked we add the folder path in the input box + $suggestions.find('.folder-suggestion-item').on('click', function() { + const folderPath = $(this).data('path'); + $input.val(folderPath).trigger('input'); + }); + } + + /** + * This function is responsible to update the folder suggestion everytime a new char is inserted in the input field + * + * @param {string} query - The search query from the input field + * @param {Array} folderList - List of all available folder paths + * @param {Array} rootFolders - list of root-level folder paths + * @param {StringMatch.StringMatcher} stringMatcher - StringMatcher instance for fuzzy matching + * @param {JQuery} $suggestions - jQuery element for the suggestions container + * @param {JQuery} $input - jQuery element for the input field + */ + function _updateFolderSuggestions(query, folderList, rootFolders, stringMatcher, $suggestions, $input) { + if (!query || query.trim() === '') { + // when input is empty we show the root folders + _renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input); + return; + } + + // if the query ends with a / + // we then show the drilled down list of dirs inside that parent directory + if (query.endsWith('/')) { + const subfolders = _getSubfolders(query, folderList); + const formattedSubfolders = subfolders.map(folder => { + return stringMatcher.match(folder, query) || { label: folder, stringRanges: [{ text: folder, matched: false }] }; + }); + + _renderFolderSuggestions(formattedSubfolders.slice(0, 15), $suggestions, $input); + return; + } + + if (!stringMatcher) { return; } + + // filter folders using fuzzy matching + const matches = folderList + .map(folder => { + const result = stringMatcher.match(folder, query); + if (result) { + // get the last folder name (e.g., "assets/images/" -> "images") + const folderPath = result.label || folder; + const segments = folderPath.split('/').filter(s => s.length > 0); + const lastSegment = segments[segments.length - 1] || ''; + result.folderName = lastSegment.toLowerCase(); + + // we need to boost the score significantly if the last folder segment starts with the query + // This ensures folders like "images/" rank higher than "testing/maps/google/" when typing "image" + // note: here testing/maps/google has all the chars of 'image' + if (lastSegment.toLowerCase().startsWith(query.toLowerCase())) { + // Use a large positive boost (matchGoodness is negative, so we subtract a large negative number) + result.matchGoodness -= 10000; + } + // Also boost (but less) if the last segment contains the query as a substring + else if (lastSegment.toLowerCase().includes(query.toLowerCase())) { + result.matchGoodness -= 1000; + } + } + return result; + }) + .filter(result => result !== null && result !== undefined); + + // Sort by matchGoodness first (prefix matches will have best scores), + // then alphabetically by folder name, then by full path + StringMatch.multiFieldSort(matches, { matchGoodness: 0, folderName: 1, label: 2 }); + + const topMatches = matches.slice(0, 15); + _renderFolderSuggestions(topMatches, $suggestions, $input); + } + + /** + * register the input box handlers (folder selection dialog) + * also registers the 'arrow up/down and enter' key handler for folder selection and move the selected folder, + * in the list of suggestions + * + * @param {JQuery} $input - the input box element + * @param {JQuery} $suggestions - the suggestions list element + * @param {JQuery} $dlg - the dialog box element + */ + function _registerFolderDialogInputHandlers($input, $suggestions, $dlg) { + // keyboard navigation handler for arrow keys + $input.on('keydown', function(e) { + const isArrowDown = e.keyCode === 40; + const isArrowUp = e.keyCode === 38; + // we only want to handle the arrow up arrow down keys + if (!isArrowDown && !isArrowUp) { return; } + + e.preventDefault(); + const $items = $suggestions.find('.folder-suggestion-item'); + if ($items.length === 0) { return; } + + const $selected = $items.filter('.selected'); + + // determine which item to select next + let $nextItem; + if ($selected.length === 0) { + // no selection - select first or last based on direction + $nextItem = isArrowDown ? $items.first() : $items.last(); } else { - _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir); + // move selection + const currentIndex = $items.index($selected); + $selected.removeClass('selected'); + const nextIndex = isArrowDown + ? (currentIndex + 1) % $items.length + : (currentIndex - 1 + $items.length) % $items.length; + $nextItem = $items.eq(nextIndex); + } + + // apply selection and scroll the selected item into view (if not in view) + $nextItem.addClass('selected'); + if ($nextItem.length > 0) { + $nextItem[0].scrollIntoView({ block: "nearest", behavior: "auto" }); + } + }); + + // for enter key, we're using keyup handler because keydown was interfering with dialog's default behaviour + // when enter key is pressed, we check if there are any selected folders in the suggestions + // if yes, we type the folder path in the input box, + // if no, we click the ok button of the dialog + $input.on('keyup', function(e) { + if (e.keyCode === 13) { // enter key + const $items = $suggestions.find('.folder-suggestion-item'); + const $selected = $items.filter('.selected'); + + // if there's a selected suggestion, use it + if ($selected.length > 0) { + const folderPath = $selected.data('path'); + $input.val(folderPath).trigger('input'); + } else { + // no suggestions, trigger OK button click + $dlg.find('[data-button-id="ok"]').click(); + } } }); } /** - * Helper function to download image to phoenix-assets folder + * this shows the folder selection dialog for choosing where to download images + * @param {Object} message - the message object (optional, only needed when downloading image) + * @private */ - function _downloadImageToPhoenixAssets(message, filename, extnName, phoenixAssetsDir) { - getUniqueFilename(phoenixAssetsDir.fullPath, filename, extnName).then((uniqueFilename) => { + function _showFolderSelectionDialog(message) { + const projectRoot = ProjectManager.getProjectRoot(); + if (!projectRoot) { return; } + + // show the dialog with a text box to select a folder + // dialog html is written in 'image-folder-dialog.html' + const templateVars = { + Strings: Strings + }; + const dialog = Dialogs.showModalDialogUsingTemplate(Mustache.render(ImageFolderDialogTemplate, templateVars), false); + const $dlg = dialog.getElement(); + const $input = $dlg.find("#folder-path-input"); + const $suggestions = $dlg.find("#folder-suggestions"); + const $rememberCheckbox = $dlg.find("#remember-folder-checkbox"); + + let folderList = []; + let rootFolders = []; + let stringMatcher = null; + + _scanRootDirectoriesOnly(projectRoot, rootFolders).then(() => { + stringMatcher = new StringMatch.StringMatcher({ segmentedSearch: true }); + _renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input); + }); + + _scanDirectories(projectRoot, '', folderList); + + // input event handler + $input.on('input', function() { + _updateFolderSuggestions($input.val(), folderList, rootFolders, stringMatcher, $suggestions, $input); + }); + _registerFolderDialogInputHandlers($input, $suggestions, $dlg); + // focus the input box + setTimeout(function() { + $input.focus(); + }, 100); + + // handle dialog button clicks + // so the logic is either its an ok button click or cancel button click, so if its ok click + // then we download image in that folder and close the dialog, in close btn click we directly close the dialog + $dlg.one("buttonClick", function(e, buttonId) { + if (buttonId === Dialogs.DIALOG_BTN_OK) { + const folderPath = $input.val().trim(); + + // if the checkbox is checked, we save the folder preference for this project + if ($rememberCheckbox.is(':checked')) { + StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, folderPath, StateManager.PROJECT_CONTEXT); + } + + // if message is provided, download the image + if (message) { + _downloadToFolder(message, folderPath); + } + } + dialog.close(); + }); + } + + /** + * 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 projectRoot = ProjectManager.getProjectRoot(); + if (!projectRoot) { return; } + + // check if user has already saved a folder preference for this project + const savedFolder = StateManager.get(IMAGE_DOWNLOAD_FOLDER_KEY, StateManager.PROJECT_CONTEXT); + // we specifically check for nullish type vals because empty string is possible as it means project root + if (savedFolder !== null && savedFolder !== undefined) { + _downloadToFolder(message, savedFolder); + } else { + // show the folder selection dialog + _showFolderSelectionDialog(message); + } + } + + /** + * Helper function to download image to the specified directory + * + * @param {Object} message - Message containing image download info + * @param {string} filename - Name of the image file + * @param {string} extnName - File extension (e.g., "jpg") + * @param {Directory} targetDir - Target directory to save the image + */ + function _downloadImageToDirectory(message, filename, extnName, targetDir) { + getUniqueFilename(targetDir.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); + _handleUseThisImageLocalFiles(message, uniqueFilename, targetDir); } else { - _handleUseThisImageRemote(message, uniqueFilename, phoenixAssetsDir); + _handleUseThisImageRemote(message, uniqueFilename, targetDir); } }).catch(error => { console.error('Something went wrong when trying to use this image', error); }); } + /** + * Handles reset of image folder selection - clears the saved preference and shows the dialog + * @private + */ + function _handleResetImageFolderSelection() { + // clear the saved folder preference for this project + StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, null, StateManager.PROJECT_CONTEXT); + + // show the folder selection dialog for the user to choose a new folder + // we pass null because we're not downloading an image, just setting the preference + _showFolderSelectionDialog(null); + } + /** * This is the main function that is exported. * it will be called by LiveDevProtocol when it receives a message from RemoteFunctions.js @@ -833,6 +1228,12 @@ define(function (require, exports, module) { * these are the main properties that are passed through the message */ function handleLivePreviewEditOperation(message) { + // handle reset image folder selection + if (message.resetImageFolderSelection) { + _handleResetImageFolderSelection(); + return; + } + // handle move(drag & drop) if (message.move && message.sourceId && message.targetId) { _moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside); diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index 902321ae2..c5ac3a6a8 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -70,7 +70,6 @@ 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 @@ -80,11 +79,17 @@ 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, + imageGallery: Strings.LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY, 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 + imageGallerySelectDownloadFolder: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER, + imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER, + imageGallerySearchButton: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON, + imageGalleryLoadingInitial: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL, + imageGalleryLoadingMore: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_MORE, + imageGalleryNoImages: Strings.LIVE_DEV_IMAGE_GALLERY_NO_IMAGES, + imageGalleryLoadError: Strings.LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR } }; // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. @@ -370,20 +375,6 @@ 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); @@ -412,7 +403,6 @@ 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 97e23adce..2e5eda7d5 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -113,12 +113,6 @@ 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 = ` @@ -429,7 +423,6 @@ 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(); @@ -455,12 +448,6 @@ 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; }); @@ -509,15 +496,6 @@ 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 @@ -1323,15 +1301,8 @@ 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/htmlContent/image-folder-dialog.html b/src/htmlContent/image-folder-dialog.html new file mode 100644 index 000000000..40504da9e --- /dev/null +++ b/src/htmlContent/image-folder-dialog.html @@ -0,0 +1,32 @@ + diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 30a58a3be..5ea03991e 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -189,17 +189,27 @@ define({ "LIVE_DEV_MORE_OPTIONS_DUPLICATE": "Duplicate", "LIVE_DEV_MORE_OPTIONS_DELETE": "Delete", "LIVE_DEV_MORE_OPTIONS_AI": "Edit with AI", + "LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY": "Image Gallery", "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_SELECT_DOWNLOAD_FOLDER": "Choose image download folder", "LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER": "Search images...", + "LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON": "Search", + "LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL": "Loading images...", + "LIVE_DEV_IMAGE_GALLERY_LOADING_MORE": "Loading...", + "LIVE_DEV_IMAGE_GALLERY_NO_IMAGES": "No images found", + "LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR": "Failed to load images", + "LIVE_DEV_IMAGE_FOLDER_DIALOG_TITLE": "Select Folder to Save Image", + "LIVE_DEV_IMAGE_FOLDER_DIALOG_DESCRIPTION": "Choose where to download the image:", + "LIVE_DEV_IMAGE_FOLDER_DIALOG_PLACEHOLDER": "Type folder path (e.g., assets/images/)", + "LIVE_DEV_IMAGE_FOLDER_DIALOG_HELP": "💡 Type folder path or leave empty to download in project root", + "LIVE_DEV_IMAGE_FOLDER_DIALOG_REMEMBER": "Don't ask again for this project", "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", diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index a458dcb80..1c69fab68 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -2514,3 +2514,108 @@ code { } } } + +// image folder selection dialog +.image-folder-dialog { + #folder-path-input { + width: 100%; + height: 30px; + padding: 5px; + box-sizing: border-box; + margin-bottom: 8px; + } + + #folder-suggestions { + max-height: 150px; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid @bc-btn-border; + border-radius: @bc-border-radius; + background-color: @bc-panel-bg-alt; + + .dark & { + border: 1px solid @dark-bc-btn-border; + background-color: @dark-bc-panel-bg-alt; + } + + &:empty { + display: none; + } + + .folder-suggestions-list { + margin: 0; + padding: 0; + list-style: none; + } + + .folder-suggestion-item { + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: @bc-text; + border-left: 3px solid transparent; + + .dark & { + color: @dark-bc-text; + } + + &:hover { + background-color: @bc-panel-bg-hover-alt; + + .dark & { + background-color: @dark-bc-panel-bg-hover-alt; + } + } + + &.selected { + background-color: @bc-bg-highlight; + border-left-color: @bc-primary-btn-bg; + + .dark & { + background-color: @dark-bc-bg-highlight; + border-left-color: @dark-bc-primary-btn-bg; + } + } + } + + .folder-match-highlight { + font-weight: @font-weight-semibold; + color: @bc-primary-btn-bg; + + .dark & { + color: @dark-bc-primary-btn-bg; + } + } + } + + .folder-help-text { + margin-top: 8px; + margin-bottom: 0; + font-size: 11px; + color: @bc-text-quiet; + user-select: none; + + .dark & { + color: @dark-bc-text-quiet; + } + } + + .remember-folder-container { + display: flex; + justify-content: right; + } + + .remember-folder-container label { + font-size: 12px; + letter-spacing: 0.3px; + color: @bc-text-quiet; + + .dark & { + color: @dark-bc-text-quiet; + } + } + + .remember-folder-container input { + margin-top: 2px; + } +}