diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index 0731a1f86e..a959721cd4 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -128,6 +128,8 @@ } }; + global._Brackets_MessageBroker = MessageBroker; + /** * Runtime Domain. Implements remote commands for "Runtime.*" */ @@ -390,27 +392,69 @@ function onDocumentClick(event) { // Get the user's current selection const selection = window.getSelection(); - - // Check if there is a selection - if (selection.toString().length > 0) { - // if there is any selection like text or others, we don't see it as a live selection event - // Eg: user may selects ome text in live preview to copy, in which case we should nt treat it - // as a live select. - return; - } var element = event.target; if (element && element.hasAttribute('data-brackets-id')) { - MessageBroker.send({ - "tagId": element.getAttribute('data-brackets-id'), - "nodeID": element.id, - "nodeClassList": element.classList, - "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), - "contentEditable": element.contentEditable === 'true', - "clicked": true - }); + // Check if it's a double-click for direct editing + if (event.detail === 2 && !['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)) { + // Double-click detected, enable direct editing + // Make the element editable + if (window._LD && window._LD.DOMEditHandler) { + // Use the existing DOMEditHandler to handle the edit + window._LD.startEditing(element); + } else { + MessageBroker.send({ + "tagId": element.getAttribute('data-brackets-id'), + "nodeID": element.id, + "nodeClassList": element.classList, + "nodeName": element.nodeName, + "allSelectors": _getAllInheritedSelectorsInOrder(element), + "contentEditable": element.contentEditable === 'true', + "clicked": true, + "edit": true + }); + } + + // Prevent default behavior and stop propagation + event.preventDefault(); + event.stopPropagation(); + } else { + // Regular click, just send the information + // Check if there is a selection + if (selection.toString().length > 0) { + // if there is any selection like text or others, we don't see it as a live selection event + // Eg: user may selects ome text in live preview to copy, in which case we should nt treat it + // as a live select. + return; + } + MessageBroker.send({ + "tagId": element.getAttribute('data-brackets-id'), + "nodeID": element.id, + "nodeClassList": element.classList, + "nodeName": element.nodeName, + "allSelectors": _getAllInheritedSelectorsInOrder(element), + "contentEditable": element.contentEditable === 'true', + "clicked": true + }); + } } } window.document.addEventListener("click", onDocumentClick); + window.document.addEventListener("keydown", function (e) { + // for undo. refer to LivePreviewEdit.js file 'handleLivePreviewEditOperation' function + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + MessageBroker.send({ + livePreviewEditEnabled: true, + undoLivePreviewOperation: true + }); + } + + // for redo + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { + MessageBroker.send({ + livePreviewEditEnabled: true, + redoLivePreviewOperation: true + }); + } + }); }(this)); diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index bec0f1905e..e83ee6e6b9 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -28,25 +28,21 @@ * modules should define a single function that returns an object of all * exported functions. */ -function RemoteFunctions(config) { +function RemoteFunctions(config = {}) { + // this will store the element that was clicked previously (before the new click) + // we need this so that we can remove click styling from the previous element when a new element is clicked + let previouslyClickedElement = null; - - var experimental; - if (!config) { - experimental = false; - } else { - experimental = config.experimental; - } var req, timeout; var animateHighlight = function (time) { if(req) { - window.cancelAnimationFrame(req); + window.cancelAnimationFrame(req); window.clearTimeout(timeout); } req = window.requestAnimationFrame(redrawHighlights); timeout = setTimeout(function () { - window.cancelAnimationFrame(req); + window.cancelAnimationFrame(req); req = null; }, time * 1000); }; @@ -58,31 +54,79 @@ function RemoteFunctions(config) { var HIGHLIGHT_CLASSNAME = "__brackets-ld-highlight"; + // auto-scroll variables to auto scroll the live preview when an element is dragged to the top/bottom + let _autoScrollTimer = null; + let _isAutoScrolling = false; // to disable highlights when auto scrolling + const AUTO_SCROLL_SPEED = 12; // pixels per scroll + const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom) + + /** + * this function is responsible to auto scroll the live preview when + * dragging an element to the viewport edges + * @param {number} clientY - curr mouse Y position + */ + function _handleAutoScroll(clientY) { + const viewportHeight = window.innerHeight; + const scrollEdgeSize = viewportHeight * AUTO_SCROLL_EDGE_SIZE; + + // Clear existing timer + if (_autoScrollTimer) { + clearInterval(_autoScrollTimer); + _autoScrollTimer = null; + } + + let scrollDirection = 0; + + // check if near top edge (scroll up) + if (clientY <= scrollEdgeSize) { + scrollDirection = -AUTO_SCROLL_SPEED; + } else if (clientY >= viewportHeight - scrollEdgeSize) { + // check if near bottom edge (scroll down) + scrollDirection = AUTO_SCROLL_SPEED; + } + + // Start scrolling if needed + if (scrollDirection !== 0) { + _isAutoScrolling = true; + _autoScrollTimer = setInterval(() => { + window.scrollBy(0, scrollDirection); + }, 16); // 16 is ~60fps + } + } + + // stop autoscrolling + function _stopAutoScroll() { + if (_autoScrollTimer) { + clearInterval(_autoScrollTimer); + _autoScrollTimer = null; + } + _isAutoScrolling = false; + } + // determine whether an event should be processed for Live Development function _validEvent(event) { if (window.navigator.platform.substr(0, 3) === "Mac") { // Mac return event.metaKey; - } else { - // Windows - return event.ctrlKey; } + // Windows + return event.ctrlKey; } - // determine the color for a type - function _typeColor(type, highlight) { - switch (type) { - case "html": - return highlight ? "#eec" : "#ffe"; - case "css": - return highlight ? "#cee" : "#eff"; - case "js": - return highlight ? "#ccf" : "#eef"; - default: - return highlight ? "#ddd" : "#eee"; + // helper function to check if an element is inside the HEAD tag + // we need this because we don't wanna trigger the element highlights on head tag and its children + function _isInsideHeadTag(element) { + let parent = element; + while (parent && parent !== window.document) { + if (parent.tagName === "HEAD") { + return true; + } + parent = parent.parentElement; } + return false; } + // compute the screen offset of an element function _screenOffset(element) { var elemBounds = element.getBoundingClientRect(), @@ -113,7 +157,7 @@ function RemoteFunctions(config) { element.removeAttribute(key); } } - + // Checks if the element is in Viewport in the client browser function isInViewport(element) { var rect = element.getBoundingClientRect(); @@ -125,125 +169,1694 @@ function RemoteFunctions(config) { rect.right <= (window.innerWidth || html.clientWidth) ); } - + + // Checks if an element is actually visible to the user (not hidden, collapsed, or off-screen) + function isElementVisible(element) { + // Check if element has zero dimensions (indicates it's hidden or collapsed) + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + return false; + } + + // Check computed styles for visibility + const computedStyle = window.getComputedStyle(element); + if (computedStyle.display === 'none' || + computedStyle.visibility === 'hidden' || + computedStyle.opacity === '0') { + return false; + } + + // Check if any parent element is hidden + let parent = element.parentElement; + while (parent && parent !== document.body) { + const parentStyle = window.getComputedStyle(parent); + if (parentStyle.display === 'none' || + parentStyle.visibility === 'hidden') { + return false; + } + parent = parent.parentElement; + } + + return true; + } + // returns the distance from the top of the closest relatively positioned parent element function getDocumentOffsetTop(element) { return element.offsetTop + (element.offsetParent ? getDocumentOffsetTop(element.offsetParent) : 0); } - // construct the info menu - function Menu(element) { + /** + * This function gets called when the AI button is clicked + * it shows a AI prompt box to the user + * @param {Event} event + * @param {DOMElement} element - the HTML DOM element that was clicked + */ + function _handleAIOptionClick(event, element) { + // make sure there is no existing AI prompt box, and no other box as well + dismissAllUIBoxes(); + _aiPromptBox = new AIPromptBox(element); // create a new one + } + + /** + * This function gets called when the delete button is clicked + * it sends a message to the editor using postMessage to delete the element from the source code + * @param {Event} event + * @param {DOMElement} element - the HTML DOM element that was clicked. it is to get the data-brackets-id attribute + */ + function _handleDeleteOptionClick(event, element) { + const tagId = element.getAttribute("data-brackets-id"); + + if (tagId && element.tagName !== "BODY" && element.tagName !== "HTML" && !_isInsideHeadTag(element)) { + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + element: element, + event: event, + tagId: Number(tagId), + delete: true + }); + } else { + console.error("The TagID might be unavailable or the element tag is directly body or html"); + } + } + + /** + * this is for duplicate button. Read '_handleDeleteOptionClick' jsdoc to understand more on how this works + * @param {Event} event + * @param {DOMElement} element - the HTML DOM element that was clicked. it is to get the data-brackets-id attribute + */ + function _handleDuplicateOptionClick(event, element) { + const tagId = element.getAttribute("data-brackets-id"); + + if (tagId && element.tagName !== "BODY" && element.tagName !== "HTML" && !_isInsideHeadTag(element)) { + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + element: element, + event: event, + tagId: Number(tagId), + duplicate: true + }); + } else { + console.error("The TagID might be unavailable or the element tag is directly body or html"); + } + } + + /** + * this is for select-parent button + * When user clicks on this option for a particular element, we get its parent element and trigger a click on it + * @param {Event} event + * @param {DOMElement} element - the HTML DOM element that was clicked. it is to get the data-brackets-id attribute + */ + function _handleSelectParentOptionClick(event, element) { + if (!element) { + return; + } + + const parentElement = element.parentElement; + if (!parentElement) { + return; + } + + // we need to make sure that the parent element is not the body tag or the html. + // also we expect it to have the 'data-brackets-id' + if ( + parentElement.tagName !== "BODY" && + parentElement.tagName !== "HTML" && + !_isInsideHeadTag(parentElement) && + parentElement.hasAttribute("data-brackets-id") + ) { + parentElement.click(); + } else { + console.error("The TagID might be unavailable or the parent element tag is directly body or html"); + } + } + + /** + * This function will get triggered when from the multiple advance DOM buttons, one is clicked + * this function just checks which exact button was clicked and call the required function + * @param {Event} e + * @param {String} action - the data-action attribute to differentiate between buttons + * @param {DOMElement} element - the selected DOM element + */ + function handleOptionClick(e, action, element) { + if (action === "select-parent") { + _handleSelectParentOptionClick(e, element); + } else if (action === "edit-text") { + startEditing(element); + } else if (action === "duplicate") { + _handleDuplicateOptionClick(e, element); + } else if (action === "delete") { + _handleDeleteOptionClick(e, element); + } else if (action === "ai") { + _handleAIOptionClick(e, element); + } + } + + function _dragStartChores(element) { + element._originalDragOpacity = element.style.opacity; + element.style.opacity = 0.4; + } + + + function _dragEndChores(element) { + if (element._originalDragOpacity) { + element.style.opacity = element._originalDragOpacity; + } else { + element.style.opacity = 1; + } + delete element._originalDragOpacity; + } + + // CSS class names for drop markers + let DROP_MARKER_CLASSNAME = "__brackets-drop-marker-horizontal"; + let DROP_MARKER_VERTICAL_CLASSNAME = "__brackets-drop-marker-vertical"; + let DROP_MARKER_INSIDE_CLASSNAME = "__brackets-drop-marker-inside"; + + /** + * This function is responsible to determine whether to show vertical/horizontal indicators + * + * @param {DOMElement} element - the target element + * @returns {String} 'vertical' or 'horizontal' + */ + function _getIndicatorType(element) { + // we need to check the parent element's property if its a flex container + const parent = element.parentElement; + if (!parent) { + return 'horizontal'; + } + + const parentStyle = window.getComputedStyle(parent); + const display = parentStyle.display; + const flexDirection = parentStyle.flexDirection; + + if ((display === "flex" || display === "inline-flex") && flexDirection.startsWith("row")) { + return "vertical"; + } + + // default is horizontal + return 'horizontal'; + } + + /** + * this function is to determine if an element can accept children (inside drops) + * + * @param {DOMElement} element - The target element + * @returns {Boolean} true if element can accept children + */ + function _canAcceptChildren(element) { + // self-closing elements, cannot have children + const voidElements = [ + "IMG", + "BR", + "HR", + "INPUT", + "META", + "LINK", + "AREA", + "BASE", + "COL", + "EMBED", + "SOURCE", + "TRACK", + "WBR" + ]; + + // Elements that shouldn't accept visual children + const nonContainerElements = [ + "SCRIPT", "STYLE", "NOSCRIPT", "CANVAS", "SVG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" + ]; + + const tagName = element.tagName.toUpperCase(); + + if (voidElements.includes(tagName) || nonContainerElements.includes(tagName)) { + return false; + } + + return true; + } + + /** + * it is to check if a source element can be placed inside a target element according to HTML rules + * + * @param {DOMElement} sourceElement - The element being dragged + * @param {DOMElement} targetElement - The target container element + * @returns {Boolean} true if the nesting is valid + */ + function _isValidNesting(sourceElement, targetElement) { + const sourceTag = sourceElement.tagName.toUpperCase(); + const targetTag = targetElement.tagName.toUpperCase(); + + // block elements, cannot come inside inline elements + const blockElements = [ + "DIV", + "P", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "SECTION", + "ARTICLE", + "HEADER", + "FOOTER", + "NAV", + "ASIDE", + "MAIN", + "BLOCKQUOTE", + "PRE", + "TABLE", + "UL", + "OL", + "LI", + "DL", + "DT", + "DD", + "FORM", + "FIELDSET", + "ADDRESS", + "FIGURE", + "FIGCAPTION", + "DETAILS", + "SUMMARY" + ]; + + // inline elements that can't contain block elements + const inlineElements = [ + "SPAN", + "A", + "STRONG", + "EM", + "B", + "I", + "U", + "SMALL", + "CODE", + "KBD", + "SAMP", + "VAR", + "SUB", + "SUP", + "MARK", + "DEL", + "INS", + "Q", + "CITE", + "ABBR", + "TIME", + "DATA", + "OUTPUT" + ]; + + // interactive elements that can't be nested inside each other + const interactiveElements = [ + "A", + "BUTTON", + "INPUT", + "SELECT", + "TEXTAREA", + "LABEL", + "DETAILS", + "SUMMARY", + "AUDIO", + "VIDEO", + "EMBED", + "IFRAME", + "OBJECT" + ]; + + // Sectioning content - semantic HTML5 sections + const sectioningContent = ["ARTICLE", "ASIDE", "NAV", "SECTION"]; + + // Elements that can't contain themselves (prevent nesting) + const noSelfNesting = [ + "P", + "A", + "BUTTON", + "LABEL", + "FORM", + "HEADER", + "FOOTER", + "NAV", + "MAIN", + "ASIDE", + "SECTION", + "ARTICLE", + "ADDRESS", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "FIGURE", + "FIGCAPTION", + "DETAILS", + "SUMMARY" + ]; + + // Special cases - elements that have specific content restrictions + const restrictedContainers = { + // List elements + UL: ["LI"], + OL: ["LI"], + DL: ["DT", "DD"], + + // Table elements + TABLE: ["THEAD", "TBODY", "TFOOT", "TR", "CAPTION", "COLGROUP"], + THEAD: ["TR"], + TBODY: ["TR"], + TFOOT: ["TR"], + TR: ["TD", "TH"], + COLGROUP: ["COL"], + + // Form elements + SELECT: ["OPTION", "OPTGROUP"], + OPTGROUP: ["OPTION"], + DATALIST: ["OPTION"], + + // Media elements + PICTURE: ["SOURCE", "IMG"], + AUDIO: ["SOURCE", "TRACK"], + VIDEO: ["SOURCE", "TRACK"], + + // Other specific containers + FIGURE: ["FIGCAPTION", "DIV", "P", "IMG", "CANVAS", "SVG", "TABLE", "PRE", "CODE"], + DETAILS: ["SUMMARY"] // SUMMARY should be the first child + }; + + // 1. Check self-nesting (elements that can't contain themselves) + if (noSelfNesting.includes(sourceTag) && sourceTag === targetTag) { + return false; + } + + // 2. Check block elements inside inline elements + if (blockElements.includes(sourceTag) && inlineElements.includes(targetTag)) { + return false; + } + + // 3. Check restricted containers (strict parent-child relationships) + if (restrictedContainers[targetTag]) { + return restrictedContainers[targetTag].includes(sourceTag); + } + + // 4. Special case: P tags can't contain block elements (phrasing content only) + if (targetTag === "P" && blockElements.includes(sourceTag)) { + return false; + } + + // 5. Interactive elements can't contain other interactive elements + if (interactiveElements.includes(targetTag) && interactiveElements.includes(sourceTag)) { + return false; + } + + // 6. Semantic HTML5 sectioning rules + if (targetTag === "HEADER") { + // Header can't contain other headers, footers, or main + if (["HEADER", "FOOTER", "MAIN"].includes(sourceTag)) { + return false; + } + } + + if (targetTag === "FOOTER") { + // Footer can't contain headers, footers, or main + if (["HEADER", "FOOTER", "MAIN"].includes(sourceTag)) { + return false; + } + } + + if (targetTag === "MAIN") { + // Main can't contain other mains + if (sourceTag === "MAIN") { + return false; + } + } + + if (targetTag === "ADDRESS") { + // Address can't contain sectioning content, headers, footers, or address + if (sectioningContent.includes(sourceTag) || ["HEADER", "FOOTER", "ADDRESS", "MAIN"].includes(sourceTag)) { + return false; + } + } + + // 7. Form-related validation + if (targetTag === "FORM") { + // Form can't contain other forms + if (sourceTag === "FORM") { + return false; + } + } + + if (targetTag === "FIELDSET") { + // Fieldset should have legend as first child (but we'll allow it anywhere for flexibility) + // No specific restrictions beyond normal content + } + + if (targetTag === "LABEL") { + // Label can't contain other labels or form controls (except one input) + if (["LABEL", "BUTTON", "SELECT", "TEXTAREA"].includes(sourceTag)) { + return false; + } + } + + // 8. Heading hierarchy validation (optional - can be strict or flexible) + if (["H1", "H2", "H3", "H4", "H5", "H6"].includes(targetTag)) { + // Headings can't contain block elements (should only contain phrasing content) + if (blockElements.includes(sourceTag)) { + return false; + } + } + + // 9. List item specific rules + if (sourceTag === "LI") { + // LI can only be inside UL, OL, or MENU + if (!["UL", "OL", "MENU"].includes(targetTag)) { + return false; + } + } + + if (["DT", "DD"].includes(sourceTag)) { + // DT and DD can only be inside DL + if (targetTag !== "DL") { + return false; + } + } + + // 10. Table-related validation + if (["THEAD", "TBODY", "TFOOT"].includes(sourceTag)) { + if (targetTag !== "TABLE") { + return false; + } + } + + if (sourceTag === "TR") { + if (!["TABLE", "THEAD", "TBODY", "TFOOT"].includes(targetTag)) { + return false; + } + } + + if (["TD", "TH"].includes(sourceTag)) { + if (targetTag !== "TR") { + return false; + } + } + + if (sourceTag === "CAPTION") { + if (targetTag !== "TABLE") { + return false; + } + } + + // 11. Media and embedded content + if (["SOURCE", "TRACK"].includes(sourceTag)) { + if (!["AUDIO", "VIDEO", "PICTURE"].includes(targetTag)) { + return false; + } + } + + // 12. Ruby annotation elements (if supported) + if (["RP", "RT"].includes(sourceTag)) { + if (targetTag !== "RUBY") { + return false; + } + } + + // 13. Option elements + if (sourceTag === "OPTION") { + if (!["SELECT", "OPTGROUP", "DATALIST"].includes(targetTag)) { + return false; + } + } + + return true; + } + + /** + * this function determines the drop zone based on cursor position relative to element + * + * @param {DOMElement} element - The target element + * @param {Number} clientX - x pos + * @param {Number} clientY - y pos + * @param {String} indicatorType - 'vertical' or 'horizontal' + * @param {DOMElement} sourceElement - The element being dragged (for validation) + * @returns {String} 'before', 'inside', or 'after' + */ + function _getDropZone(element, clientX, clientY, indicatorType, sourceElement) { + const rect = element.getBoundingClientRect(); + const canAcceptChildren = _canAcceptChildren(element); + const isValidNesting = sourceElement ? _isValidNesting(sourceElement, element) : true; + + if (indicatorType === "vertical") { + const leftThird = rect.left + rect.width * 0.3; + const rightThird = rect.right - rect.width * 0.3; + + if (clientX < leftThird) { + return "before"; + } else if (clientX > rightThird) { + return "after"; + } else if (canAcceptChildren && isValidNesting) { + return "inside"; + } + // If can't accept children or invalid nesting, use middle as "after" + return clientX < rect.left + rect.width / 2 ? "before" : "after"; + } + + const topThird = rect.top + rect.height * 0.3; + const bottomThird = rect.bottom - rect.height * 0.3; + + if (clientY < topThird) { + return "before"; + } else if (clientY > bottomThird) { + return "after"; + } else if (canAcceptChildren && isValidNesting) { + return "inside"; + } + // If can't accept children or invalid nesting, use middle as "after" + return clientY < rect.top + rect.height / 2 ? "before" : "after"; + } + + /** + * this is to create a marker to indicate a valid drop position + * + * @param {DOMElement} element - The element where the drop is possible + * @param {String} dropZone - 'before', 'inside', or 'after' + * @param {String} indicatorType - 'vertical' or 'horizontal' + */ + function _createDropMarker(element, dropZone, indicatorType = "horizontal") { + // clean any existing marker from that element + _removeDropMarkerFromElement(element); + + // create the marker element + let marker = window.document.createElement("div"); + + // Set marker class based on drop zone + if (dropZone === "inside") { + marker.className = DROP_MARKER_INSIDE_CLASSNAME; + } else { + marker.className = indicatorType === "vertical" ? DROP_MARKER_VERTICAL_CLASSNAME : DROP_MARKER_CLASSNAME; + } + + let rect = element.getBoundingClientRect(); + marker.style.position = "fixed"; + marker.style.zIndex = "2147483646"; + marker.style.borderRadius = "2px"; + marker.style.pointerEvents = "none"; + + if (dropZone === "inside") { + // inside marker - outline around the element + marker.style.border = "1px dashed #4285F4"; + marker.style.backgroundColor = "rgba(66, 133, 244, 0.05)"; + marker.style.left = rect.left + "px"; + marker.style.top = rect.top + "px"; + marker.style.width = rect.width + "px"; + marker.style.height = rect.height + "px"; + marker.style.animation = "insideMarkerPulse 1s ease-in-out infinite alternate"; + } else { + // Before/After markers - lines + marker.style.background = "linear-gradient(90deg, #4285F4, #1976D2)"; + marker.style.boxShadow = "0 0 8px rgba(66, 133, 244, 0.5)"; + marker.style.animation = "dropMarkerPulse 0.8s ease-in-out infinite alternate"; + + if (indicatorType === "vertical") { + // Vertical marker (for flex row containers) + marker.style.width = "2px"; + marker.style.height = rect.height + "px"; + marker.style.top = rect.top + "px"; + + if (dropZone === "after") { + marker.style.left = rect.right + 3 + "px"; + } else { + marker.style.left = rect.left - 5 + "px"; + } + } else { + // Horizontal marker (for block/grid containers) + marker.style.width = rect.width + "px"; + marker.style.height = "2px"; + marker.style.left = rect.left + "px"; + + if (dropZone === "after") { + marker.style.top = rect.bottom + 3 + "px"; + } else { + marker.style.top = rect.top - 5 + "px"; + } + } + } + + element._dropMarker = marker; // we need this in the _removeDropMarkerFromElement function + window.document.body.appendChild(marker); + } + + /** + * This function removes a drop marker from a specific element + * @param {DOMElement} element - The element to remove the marker from + */ + function _removeDropMarkerFromElement(element) { + if (element._dropMarker && element._dropMarker.parentNode) { + element._dropMarker.parentNode.removeChild(element._dropMarker); + delete element._dropMarker; + } + } + + /** + * this function is to clear all the drop markers from the document + */ + function _clearDropMarkers() { + // Clear all types of markers + let horizontalMarkers = window.document.querySelectorAll("." + DROP_MARKER_CLASSNAME); + let verticalMarkers = window.document.querySelectorAll("." + DROP_MARKER_VERTICAL_CLASSNAME); + let insideMarkers = window.document.querySelectorAll("." + DROP_MARKER_INSIDE_CLASSNAME); + + for (let i = 0; i < horizontalMarkers.length; i++) { + if (horizontalMarkers[i].parentNode) { + horizontalMarkers[i].parentNode.removeChild(horizontalMarkers[i]); + } + } + + for (let i = 0; i < verticalMarkers.length; i++) { + if (verticalMarkers[i].parentNode) { + verticalMarkers[i].parentNode.removeChild(verticalMarkers[i]); + } + } + + for (let i = 0; i < insideMarkers.length; i++) { + if (insideMarkers[i].parentNode) { + insideMarkers[i].parentNode.removeChild(insideMarkers[i]); + } + } + + // Also clear any element references + let elements = window.document.querySelectorAll("[data-brackets-id]"); + for (let j = 0; j < elements.length; j++) { + delete elements[j]._dropMarker; + // only restore the styles that were modified by drag operations + if (elements[j]._originalDragBackgroundColor !== undefined) { + elements[j].style.backgroundColor = elements[j]._originalDragBackgroundColor; + delete elements[j]._originalDragBackgroundColor; + } + if (elements[j]._originalDragTransform !== undefined) { + elements[j].style.transform = elements[j]._originalDragTransform; + delete elements[j]._originalDragTransform; + } + if (elements[j]._originalDragTransition !== undefined) { + elements[j].style.transition = elements[j]._originalDragTransition; + delete elements[j]._originalDragTransition; + } + } + } + + /** + * Handle dragover events on the document (throttled version) + * Shows drop markers on valid drop targets + * @param {Event} event - The dragover event + */ + function onDragOver(event) { + // we set this on dragStart + if (!window._currentDraggedElement) { + return; + } + + event.preventDefault(); + + // get the element under the cursor + let target = document.elementFromPoint(event.clientX, event.clientY); + if (!target || target === window._currentDraggedElement) { + return; + } + + // get the closest element with a data-brackets-id + while (target && !target.hasAttribute("data-brackets-id")) { + target = target.parentElement; + } + + // skip if no valid target found or if it's the dragged element + if (!target || target === window._currentDraggedElement) { + return; + } + + // Skip BODY, HTML tags and elements inside HEAD + if (target.tagName === "BODY" || target.tagName === "HTML" || _isInsideHeadTag(target)) { + return; + } + + // Store original styles before modifying them + if (target._originalDragBackgroundColor === undefined) { + target._originalDragBackgroundColor = target.style.backgroundColor; + } + if (target._originalDragTransition === undefined) { + target._originalDragTransition = target.style.transition; + } + + // Add subtle hover effect to target element + target.style.backgroundColor = "rgba(66, 133, 244, 0.1)"; + target.style.transition = "background-color 0.2s ease"; + + // Determine indicator type and drop zone based on container layout and cursor position + const indicatorType = _getIndicatorType(target); + const dropZone = _getDropZone( + target, event.clientX, event.clientY, indicatorType, window._currentDraggedElement + ); + + // before creating a drop marker, make sure that we clear all the drop markers + _clearDropMarkers(); + _createDropMarker(target, dropZone, indicatorType); + _handleAutoScroll(event.clientY); + } + + /** + * handles drag leave event. mainly to clear the drop markers + * @param {Event} event + */ + function onDragLeave(event) { + if (!event.relatedTarget) { + _clearDropMarkers(); + _stopAutoScroll(); + } + } + + /** + * Handle drop events on the document + * Processes the drop of a dragged element onto a valid target + * @param {Event} event - The drop event + */ + function onDrop(event) { + if (!window._currentDraggedElement) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // get the element under the cursor + let target = document.elementFromPoint(event.clientX, event.clientY); + + // get the closest element with a data-brackets-id + while (target && !target.hasAttribute("data-brackets-id")) { + target = target.parentElement; + } + + // skip if no valid target found or if it's the dragged element + if (!target || target === window._currentDraggedElement) { + _clearDropMarkers(); + _stopAutoScroll(); + _dragEndChores(window._currentDraggedElement); + dismissUIAndCleanupState(); + delete window._currentDraggedElement; + return; + } + + // Skip BODY, HTML tags and elements inside HEAD + if (target.tagName === "BODY" || target.tagName === "HTML" || _isInsideHeadTag(target)) { + _clearDropMarkers(); + _stopAutoScroll(); + _dragEndChores(window._currentDraggedElement); + dismissUIAndCleanupState(); + delete window._currentDraggedElement; + return; + } + + // Determine drop position based on container layout and cursor position + const indicatorType = _getIndicatorType(target); + const dropZone = _getDropZone( + target, event.clientX, event.clientY, indicatorType, window._currentDraggedElement + ); + + // IDs of the source and target elements + const sourceId = window._currentDraggedElement.getAttribute("data-brackets-id"); + const targetId = target.getAttribute("data-brackets-id"); + + // Handle different drop zones + let messageData = { + livePreviewEditEnabled: true, + sourceElement: window._currentDraggedElement, + targetElement: target, + sourceId: Number(sourceId), + targetId: Number(targetId), + move: true + }; + + if (dropZone === "inside") { + // For inside drops, we want to insert as a child of the target element + messageData.insertInside = true; + messageData.insertAfter = false; // Will be handled differently in backend + } else { + // For before/after drops, use the existing logic + messageData.insertAfter = dropZone === "after"; + } + + // send message to the editor + window._Brackets_MessageBroker.send(messageData); + + _clearDropMarkers(); + _stopAutoScroll(); + _dragEndChores(window._currentDraggedElement); + dismissUIAndCleanupState(); + delete window._currentDraggedElement; + } + + /** + * this function is to check if an element should show the edit text option + * it is needed because edit text option doesn't make sense with many elements like images, videos, hr tag etc + * @param {Element} element - DOM element to check + * @returns {boolean} - true if we should show the edit text option otherwise false + */ + function _shouldShowEditTextOption(element) { + if (!element || !element.tagName) { + return false; + } + + const tagName = element.tagName.toLowerCase(); + + // these are self-closing tags and don't allow any text content + const voidElements = [ + "img", + "br", + "hr", + "input", + "meta", + "link", + "area", + "base", + "col", + "embed", + "source", + "track", + "wbr" + ]; + + // these elements are non-editable as they have their own mechanisms + const nonEditableElements = [ + "script", + "style", + "noscript", + "canvas", + "svg", + "video", + "audio", + "iframe", + "object", + "select", + "textarea" + ]; + + if (voidElements.includes(tagName) || nonEditableElements.includes(tagName)) { + return false; + } + + return true; + } + + /** + * this function is to check if an element should show the 'select-parent' option + * because we don't want to show the select parent option when the parent is directly the body/html tag + * or the parent doesn't have the 'data-brackets-id' + * @param {Element} element - DOM element to check + * @returns {boolean} - true if we should show the select parent option otherwise false + */ + function _shouldShowSelectParentOption(element) { + if (!element || !element.parentElement) { + return false; + } + + const parentElement = element.parentElement; + + if (parentElement.tagName === "HTML" || parentElement.tagName === "BODY" || _isInsideHeadTag(parentElement)) { + return false; + } + if (!parentElement.hasAttribute("data-brackets-id")) { + return false; + } + + return true; + } + + /** + * This is for the advanced DOM options that appears when a DOM element is clicked + * advanced options like: 'select parent', 'duplicate', 'delete' + */ + function NodeMoreOptionsBox(element) { + this.element = element; + this.remove = this.remove.bind(this); + this.create(); + } + + NodeMoreOptionsBox.prototype = { + _registerDragDrop: function() { + this.element.setAttribute("draggable", true); + + this.element.addEventListener("dragstart", (event) => { + event.stopPropagation(); + event.dataTransfer.setData("text/plain", this.element.getAttribute("data-brackets-id")); + _dragStartChores(this.element); + _clearDropMarkers(); + window._currentDraggedElement = this.element; + dismissUIAndCleanupState(); + // Add drag image styling + event.dataTransfer.effectAllowed = "move"; + }); + + this.element.addEventListener("dragend", (event) => { + event.preventDefault(); + event.stopPropagation(); + _dragEndChores(this.element); + _clearDropMarkers(); + _stopAutoScroll(); + delete window._currentDraggedElement; + }); + }, + + _getBoxPosition: function(boxWidth, boxHeight) { + const elemBounds = this.element.getBoundingClientRect(); + const offset = _screenOffset(this.element); + + let topPos = offset.top - boxHeight - 6; // 6 for just some little space to breathe + let leftPos = offset.left + elemBounds.width - boxWidth; + + // Check if the box would go off the top of the viewport + if (elemBounds.top - boxHeight < 6) { + topPos = offset.top + elemBounds.height + 6; + } + + // Check if the box would go off the left of the viewport + if (leftPos < 0) { + leftPos = offset.left; + } + + return {topPos: topPos, leftPos: leftPos}; + }, + + _style: function() { + this.body = window.document.createElement("div"); + + // this is shadow DOM. + // we need it because if we add the box directly to the DOM then users style might override it. + // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes + const shadow = this.body.attachShadow({ mode: "open" }); + + // check which options should be shown to determine box width + const showEditTextOption = _shouldShowEditTextOption(this.element); + const showSelectParentOption = _shouldShowSelectParentOption(this.element); + + // the icons that is displayed in the box + const ICONS = { + ai: ` + + + + `, + + arrowUp: ` + + + + `, + + edit: ` + + + + `, + + duplicate: ` + + + + + + `, + + trash: ` + + + + ` + }; + + let content = `
`; + + // not sure if we need to hide/show the AI icon, right now showing always + content += ` + ${ICONS.ai} + `; + + // Only include select parent option if element supports it + if (showSelectParentOption) { + content += ` + ${ICONS.arrowUp} + `; + } + + // Only include edit text option if element supports it + if (showEditTextOption) { + content += ` + ${ICONS.edit} + `; + } + + // Always include duplicate and delete options + content += ` + ${ICONS.duplicate} + + + ${ICONS.trash} + +
`; + + const styles = ` + :host { + all: initial; + } + + .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; + left: -1000px; + top: -1000px; + box-sizing: border-box; + } + + .node-options { + display: flex; + align-items: center; + } + + .node-options span { + padding: 4px 3.9px; + cursor: pointer; + display: flex; + align-items: center; + border-radius: 0; + } + + .node-options span:first-child { + border-radius: 3px 0 0 3px; + } + + .node-options span:last-child { + border-radius: 0 3px 3px 0; + } + + .node-options span:hover { + background-color: rgba(255, 255, 255, 0.15); + } + + .node-options span > svg { + width: 16px; + height: 16px; + display: block; + } + `; + + // add everything to the shadow box + shadow.innerHTML = `
${content}
`; + this._shadow = shadow; + }, + + create: function() { + this.remove(); // remove existing box if already present + + if(!config.isLPEditFeaturesActive) { + return; + } + + // this check because when there is no element visible to the user, we don't want to show the box + // for ex: when user clicks on a 'x' button and the button is responsible to hide a panel + // then clicking on that button shouldn't show the more options box + // also covers cases where elements are inside closed/collapsed menus + if(!isElementVisible(this.element)) { + return; + } + + this._style(); // style the box + + window.document.body.appendChild(this.body); + + // get the actual rendered dimensions of the box and then we reposition it to the actual place + const boxElement = this._shadow.querySelector('.phoenix-more-options-box'); + if (boxElement) { + const boxRect = boxElement.getBoundingClientRect(); + const pos = this._getBoxPosition(boxRect.width, boxRect.height); + + boxElement.style.left = pos.leftPos + 'px'; + boxElement.style.top = pos.topPos + 'px'; + } + + // add click handler to all the buttons + const spans = this._shadow.querySelectorAll('.node-options span'); + spans.forEach(span => { + span.addEventListener('click', (event) => { + event.stopPropagation(); + event.preventDefault(); + // data-action is to differentiate between the buttons (duplicate, delete or select-parent) + const action = event.currentTarget.getAttribute('data-action'); + handleOptionClick(event, action, this.element); + if (action !== 'duplicate') { + this.remove(); + } + }); + }); + + this._registerDragDrop(); + }, + + remove: function() { + if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) { + window.document.body.removeChild(this.body); + this.body = null; + _nodeMoreOptionsBox = null; + } + } + }; + + // Node info box to display DOM node ID and classes on hover + function NodeInfoBox(element) { this.element = element; - _trigger(this.element, "showgoto", 1, true); - window.setTimeout(window.remoteShowGoto); this.remove = this.remove.bind(this); + this.create(); } - Menu.prototype = { - onClick: function (url, event) { - event.preventDefault(); - _trigger(this.element, "goto", url, true); - this.remove(); - }, + NodeInfoBox.prototype = { + _checkOverlap: function(nodeInfoBoxPos, nodeInfoBoxDimensions) { + if (_nodeMoreOptionsBox && _nodeMoreOptionsBox._shadow) { + const moreOptionsBoxElement = _nodeMoreOptionsBox._shadow.querySelector('.phoenix-more-options-box'); + if (moreOptionsBoxElement) { + const moreOptionsBoxOffset = _screenOffset(moreOptionsBoxElement); + const moreOptionsBoxRect = moreOptionsBoxElement.getBoundingClientRect(); + + const infoBox = { + left: nodeInfoBoxPos.leftPos, + top: nodeInfoBoxPos.topPos, + right: nodeInfoBoxPos.leftPos + nodeInfoBoxDimensions.width, + bottom: nodeInfoBoxPos.topPos + nodeInfoBoxDimensions.height + }; + + const moreOptionsBox = { + left: moreOptionsBoxOffset.left, + top: moreOptionsBoxOffset.top, + right: moreOptionsBoxOffset.left + moreOptionsBoxRect.width, + bottom: moreOptionsBoxOffset.top + moreOptionsBoxRect.height + }; + + const isOverlapping = !(infoBox.right < moreOptionsBox.left || + moreOptionsBox.right < infoBox.left || + infoBox.bottom < moreOptionsBox.top || + moreOptionsBox.bottom < infoBox.top); + + return isOverlapping; + } + } + return false; + }, + + _getBoxPosition: function(boxDimensions, overlap = false) { + const elemBounds = this.element.getBoundingClientRect(); + const offset = _screenOffset(this.element); + let topPos = 0; + let leftPos = 0; + + if (overlap) { + topPos = offset.top + 2; + leftPos = offset.left + elemBounds.width + 6; // positioning at the right side + + // Check if overlap position would go off the right of the viewport + if (leftPos + boxDimensions.width > window.innerWidth) { + leftPos = offset.left - boxDimensions.width - 6; // positioning at the left side + + if (leftPos < 0) { // if left positioning not perfect, position at bottom + topPos = offset.top + elemBounds.height + 6; + leftPos = offset.left; + + // if bottom position not perfect, move at top above the more options box + if (elemBounds.bottom + 6 + boxDimensions.height > window.innerHeight) { + topPos = offset.top - boxDimensions.height - 34; // 34 is for moreOptions box height + leftPos = offset.left; + } + } + } + } else { + topPos = offset.top - boxDimensions.height - 6; // 6 for just some little space to breathe + leftPos = offset.left; + + if (elemBounds.top - boxDimensions.height < 6) { + // check if placing the box below would cause viewport height increase + // we need this or else it might cause a flickering issue + // read this to know why flickering occurs: + // when we hover over the bottom part of a tall element, the info box appears below it. + // this increases the live preview height, which makes the cursor position relatively + // higher due to content shift. the cursor then moves out of the element boundary, + // ending the hover state. this makes the info box disappear, decreasing the height + // back, causing the cursor to fall back into the element, restarting the hover cycle. + // this creates a continuous flickering loop. + const bottomPosition = offset.top + elemBounds.height + 6; + const wouldIncreaseViewportHeight = bottomPosition + boxDimensions.height > window.innerHeight; + + // we only need to use floating position during hover mode (not on click mode) + const isHoverMode = shouldShowHighlightOnHover(); + const shouldUseFloatingPosition = wouldIncreaseViewportHeight && isHoverMode; + + if (shouldUseFloatingPosition) { + // float over element at bottom-right to prevent layout shift during hover + topPos = offset.top + elemBounds.height - boxDimensions.height - 6; + leftPos = offset.left + elemBounds.width - boxDimensions.width; + + // make sure it doesn't go off-screen + if (leftPos < 0) { + leftPos = offset.left; // align to left edge of element + } + if (topPos < 0) { + topPos = offset.top + 6; // for the top of element + } + } else { + topPos = bottomPosition; + } + } + + // Check if the box would go off the right of the viewport + if (leftPos + boxDimensions.width > window.innerWidth) { + leftPos = window.innerWidth - boxDimensions.width - 10; + } + } + + return {topPos: topPos, leftPos: leftPos}; + }, + + _style: function() { + this.body = window.document.createElement("div"); + + // this is shadow DOM. + // we need it because if we add the box directly to the DOM then users style might override it. + // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes + const shadow = this.body.attachShadow({ mode: "open" }); + + // get the ID and classes for that element, as we need to display it in the box + const id = this.element.id; + const classes = this.element.className ? this.element.className.split(/\s+/).filter(Boolean) : []; + + let content = ""; // this will hold the main content that will be displayed + content += "
" + this.element.tagName.toLowerCase() + "
"; // add element tag name + + // Add ID if present + if (id) { + content += "
#" + id + "
"; + } + + // Add classes (limit to 3 with dropdown indicator) + if (classes.length > 0) { + content += "
"; + for (var i = 0; i < Math.min(classes.length, 3); i++) { + content += "." + classes[i] + " "; + } + if (classes.length > 3) { + content += "+" + (classes.length - 3) + " more"; + } + content += "
"; + } + + // initially, we place our info box -1000px to the top but at the right left pos. this is done so that + // we can take the text-wrapping inside the info box in account when calculating the height + // after calculating the height of the box, we place it at the exact position above the element + const offset = _screenOffset(this.element); + const leftPos = offset.left; + + const styles = ` + :host { + all: initial; + } + + .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; + left: ${leftPos}px; + top: -1000px; + max-width: 300px; + box-sizing: border-box; + pointer-events: none; + } + + .tag-name { + font-weight: bold; + } + + .id-name, + .class-name { + margin-top: 3px; + } + + .exceeded-classes { + opacity: 0.8; + } + `; + + // add everything to the shadow box + shadow.innerHTML = `
${content}
`; + this._shadow = shadow; + }, + + create: function() { + this.remove(); // remove existing box if already present + + if(!config.isLPEditFeaturesActive) { + return; + } + + // this check because when there is no element visible to the user, we don't want to show the box + // for ex: when user clicks on a 'x' button and the button is responsible to hide a panel + // then clicking on that button shouldn't show the more options box + // also covers cases where elements are inside closed/collapsed menus + if(!isElementVisible(this.element)) { + return; + } + + this._style(); // style the box + + window.document.body.appendChild(this.body); + + // get the actual rendered height of the box and then we reposition it to the actual place + const boxElement = this._shadow.querySelector('.phoenix-node-info-box'); + if (boxElement) { + const nodeInfoBoxDimensions = { + height: boxElement.getBoundingClientRect().height, + width: boxElement.getBoundingClientRect().width + }; + const nodeInfoBoxPos = this._getBoxPosition(nodeInfoBoxDimensions, false); + + boxElement.style.left = nodeInfoBoxPos.leftPos + 'px'; + boxElement.style.top = nodeInfoBoxPos.topPos + 'px'; + + const isBoxOverlapping = this._checkOverlap(nodeInfoBoxPos, nodeInfoBoxDimensions); + if(isBoxOverlapping) { + const newPos = this._getBoxPosition(nodeInfoBoxDimensions, true); + boxElement.style.left = newPos.leftPos + 'px'; + boxElement.style.top = newPos.topPos + 'px'; + } + } + }, + + remove: function() { + if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) { + window.document.body.removeChild(this.body); + this.body = null; + } + } + }; + + // AI prompt box, it is displayed when user clicks on the AI button in the more options box + function AIPromptBox(element) { + this.element = element; + this.selectedModel = 'fast'; + this.remove = this.remove.bind(this); + this.create(); + } + + AIPromptBox.prototype = { + _getBoxPosition: function(boxWidth, boxHeight) { + const elemBounds = this.element.getBoundingClientRect(); + const offset = _screenOffset(this.element); + + let topPos = offset.top - boxHeight - 6; // 6 for just some little space to breathe + let leftPos = offset.left + elemBounds.width - boxWidth; + + // Check if the box would go off the top of the viewport + if (elemBounds.top - boxHeight < 6) { + topPos = offset.top + elemBounds.height + 6; + } + + // Check if the box would go off the left of the viewport + if (leftPos < 0) { + leftPos = offset.left; + } + + return {topPos: topPos, leftPos: leftPos}; + }, + + _style: function() { + this.body = window.document.createElement("div"); + // using shadow dom so that user styles doesn't override it + const shadow = this.body.attachShadow({ mode: "open" }); + + // Calculate responsive dimensions based on viewport width + const viewportWidth = window.innerWidth; + let boxWidth, boxHeight; + + if (viewportWidth >= 400) { + boxWidth = Math.min(310, viewportWidth * 0.85); + boxHeight = 60; + } else if (viewportWidth >= 350) { + boxWidth = Math.min(275, viewportWidth * 0.85); + boxHeight = 70; + } else if (viewportWidth >= 300) { + boxWidth = Math.min(230, viewportWidth * 0.85); + boxHeight = 80; + } else if (viewportWidth >= 250) { + boxWidth = Math.min(180, viewportWidth * 0.85); + boxHeight = 100; + } else if (viewportWidth >= 200) { + boxWidth = Math.min(130, viewportWidth * 0.85); + boxHeight = 120; + } else { + boxWidth = Math.min(100, viewportWidth * 0.85); + boxHeight = 140; + } + + const styles = ` + :host { + all: initial; + } + + .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; + } + + .phoenix-ai-prompt-input-container { + position: relative; + } + + .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; + } + + .phoenix-ai-prompt-textarea:focus { + background: white; + } + + .phoenix-ai-prompt-textarea::placeholder { + color: #999; + } + + .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; + } + + .phoenix-ai-prompt-send-button:hover:not(:disabled) { + background: #4285F4; + } + + .phoenix-ai-prompt-send-button:disabled { + background: #dadce0; + color: #9aa0a6; + cursor: not-allowed; + } - createBody: function () { - if (this.body) { - return; - } + .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; + } - // compute the position on screen - var offset = _screenOffset(this.element), - x = offset.left, - y = offset.top + this.element.offsetHeight; + .phoenix-ai-model-select { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + background: white; + outline: none; + cursor: pointer; + } - // create the container - this.body = window.document.createElement("div"); - this.body.style.setProperty("z-index", 2147483647); - this.body.style.setProperty("position", "absolute"); - this.body.style.setProperty("left", x + "px"); - this.body.style.setProperty("top", y + "px"); - this.body.style.setProperty("font-size", "11pt"); - - // draw the background - this.body.style.setProperty("background", "#fff"); - this.body.style.setProperty("border", "1px solid #888"); - this.body.style.setProperty("-webkit-box-shadow", "2px 2px 6px 0px #ccc"); - this.body.style.setProperty("border-radius", "6px"); - this.body.style.setProperty("padding", "6px"); + .phoenix-ai-model-select:focus { + border-color: #4285F4; + } + `; + + const content = ` +
+
+ +
+
+ + +
+
+ `; + + shadow.innerHTML = `${content}`; + this._shadow = shadow; }, - addItem: function (target) { - var item = window.document.createElement("div"); - item.style.setProperty("padding", "2px 6px"); - if (this.body.childNodes.length > 0) { - item.style.setProperty("border-top", "1px solid #ccc"); - } - item.style.setProperty("cursor", "pointer"); - item.style.setProperty("background", _typeColor(target.type)); - item.innerHTML = target.name; - item.addEventListener("click", this.onClick.bind(this, target.url)); - - if (target.file) { - var file = window.document.createElement("i"); - file.style.setProperty("float", "right"); - file.style.setProperty("margin-left", "12px"); - file.innerHTML = " " + target.file; - item.appendChild(file); - } - this.body.appendChild(item); - }, + create: function() { + this._style(); + window.document.body.appendChild(this.body); - show: function () { - if (!this.body) { - this.createBody(); - } - if (!this.body.parentNode) { - window.document.body.appendChild(this.body); + // Get the actual rendered dimensions of the box and position it + const boxElement = this._shadow.querySelector('.phoenix-ai-prompt-box'); + if (boxElement) { + const boxRect = boxElement.getBoundingClientRect(); + const pos = this._getBoxPosition(boxRect.width, boxRect.height); + + boxElement.style.left = pos.leftPos + 'px'; + boxElement.style.top = pos.topPos + 'px'; } - window.document.addEventListener("click", this.remove); - }, - remove: function () { - if (this.body && this.body.parentNode) { - window.document.body.removeChild(this.body); + // Focus on the textarea + const textarea = this._shadow.querySelector('.phoenix-ai-prompt-textarea'); + if (textarea) { // small timer to make sure that the text area element is fetched + setTimeout(() => textarea.focus(), 50); } - window.document.removeEventListener("click", this.remove); - } - }; + this._attachEventHandlers(); - function Editor(element) { - this.onBlur = this.onBlur.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); + // Prevent clicks inside the AI box from bubbling up and closing it + this.body.addEventListener('click', (event) => { + event.stopPropagation(); + }); + }, - this.element = element; - this.element.setAttribute("contenteditable", "true"); - this.element.focus(); - this.element.addEventListener("blur", this.onBlur); - this.element.addEventListener("keypress", this.onKeyPress); + _attachEventHandlers: function() { + const textarea = this._shadow.querySelector('.phoenix-ai-prompt-textarea'); + const sendButton = this._shadow.querySelector('.phoenix-ai-prompt-send-button'); + const modelSelect = this._shadow.querySelector('.phoenix-ai-model-select'); - this.revertText = this.element.innerHTML; + // Handle textarea input to enable/disable send button + if (textarea && sendButton) { + textarea.addEventListener('input', (event) => { + const hasText = event.target.value.trim().length > 0; + sendButton.disabled = !hasText; + }); - _trigger(this.element, "edit", 1); - } + // enter key + textarea.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (textarea.value.trim()) { + this._handleSend(event, textarea.value.trim()); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + this.remove(); + } + }); + } + + // send button click + if (sendButton) { + sendButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (textarea && textarea.value.trim()) { + this._handleSend(event, textarea.value.trim()); + } + }); + } - Editor.prototype = { - onBlur: function (event) { - this.element.removeAttribute("contenteditable"); - this.element.removeEventListener("blur", this.onBlur); - this.element.removeEventListener("keypress", this.onKeyPress); - _trigger(this.element, "edit", 0, true); + // model selection change + if (modelSelect) { + modelSelect.addEventListener('change', (event) => { + this.selectedModel = event.target.value; + }); + } }, - onKeyPress: function (event) { - switch (event.which) { - case 13: // return - this.element.blur(); - break; - case 27: // esc - this.element.innerHTML = this.revertText; - this.element.blur(); - break; + _handleSend: function(event, prompt) { + const element = this.element; + if(!element) { + return; + } + const tagId = element.getAttribute("data-brackets-id"); + + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + event: event, + element: element, + prompt: prompt, + tagId: Number(tagId), + selectedModel: this.selectedModel, + AISend: true + }); + this.remove(); + }, + + remove: function() { + if (this._handleKeydown) { + document.removeEventListener('keydown', this._handleKeydown); + this._handleKeydown = null; + } + + if (this._handleResize) { + window.removeEventListener('resize', this._handleResize); + this._handleResize = null; + } + + if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) { + window.document.body.removeChild(this.body); + this.body = null; + _aiPromptBox = null; } } }; @@ -273,7 +1886,7 @@ function RemoteFunctions(config) { animationDuration = parseFloat(elementStyling.getPropertyValue('animation-duration')); highlight.trackingElement = element; // save which node are we highlighting - + if (transitionDuration) { animateHighlight(transitionDuration); } @@ -286,21 +1899,21 @@ function RemoteFunctions(config) { if (elementBounds.width === 0 && elementBounds.height === 0) { return; } - + var realElBorder = { right: elementStyling.getPropertyValue('border-right-width'), left: elementStyling.getPropertyValue('border-left-width'), top: elementStyling.getPropertyValue('border-top-width'), bottom: elementStyling.getPropertyValue('border-bottom-width') }; - + var borderBox = elementStyling.boxSizing === 'border-box'; - + var innerWidth = parseFloat(elementStyling.width), innerHeight = parseFloat(elementStyling.height), outerHeight = innerHeight, outerWidth = innerWidth; - + if (!borderBox) { innerWidth += parseFloat(elementStyling.paddingLeft) + parseFloat(elementStyling.paddingRight); innerHeight += parseFloat(elementStyling.paddingTop) + parseFloat(elementStyling.paddingBottom); @@ -309,49 +1922,49 @@ function RemoteFunctions(config) { outerHeight = innerHeight + parseFloat(realElBorder.bottom) + parseFloat(realElBorder.top); } - + var visualisations = { horizontal: "left, right", vertical: "top, bottom" }; - + var drawPaddingRect = function(side) { var elStyling = {}; - + if (visualisations.horizontal.indexOf(side) >= 0) { elStyling['width'] = elementStyling.getPropertyValue('padding-' + side); elStyling['height'] = innerHeight + "px"; elStyling['top'] = 0; - + if (borderBox) { elStyling['height'] = innerHeight - parseFloat(realElBorder.top) - parseFloat(realElBorder.bottom) + "px"; } - + } else { - elStyling['height'] = elementStyling.getPropertyValue('padding-' + side); + elStyling['height'] = elementStyling.getPropertyValue('padding-' + side); elStyling['width'] = innerWidth + "px"; elStyling['left'] = 0; - + if (borderBox) { elStyling['width'] = innerWidth - parseFloat(realElBorder.left) - parseFloat(realElBorder.right) + "px"; } } - + elStyling[side] = 0; elStyling['position'] = 'absolute'; - + return elStyling; }; - + var drawMarginRect = function(side) { var elStyling = {}; - + var margin = []; margin['right'] = parseFloat(elementStyling.getPropertyValue('margin-right')); margin['top'] = parseFloat(elementStyling.getPropertyValue('margin-top')); margin['bottom'] = parseFloat(elementStyling.getPropertyValue('margin-bottom')); margin['left'] = parseFloat(elementStyling.getPropertyValue('margin-left')); - + if(visualisations['horizontal'].indexOf(side) >= 0) { elStyling['width'] = elementStyling.getPropertyValue('margin-' + side); @@ -371,37 +1984,37 @@ function RemoteFunctions(config) { var setVisibility = function (el) { if ( - !config.remoteHighlight.showPaddingMargin || - parseInt(el.height, 10) <= 0 || - parseInt(el.width, 10) <= 0 + !config.remoteHighlight.showPaddingMargin || + parseInt(el.height, 10) <= 0 || + parseInt(el.width, 10) <= 0 ) { el.display = 'none'; } else { el.display = 'block'; } }; - + var mainBoxStyles = config.remoteHighlight.stylesToSet; - + var paddingVisualisations = [ drawPaddingRect('top'), drawPaddingRect('right'), drawPaddingRect('bottom'), - drawPaddingRect('left') + drawPaddingRect('left') ]; - + var marginVisualisations = [ drawMarginRect('top'), drawMarginRect('right'), drawMarginRect('bottom'), - drawMarginRect('left') + drawMarginRect('left') ]; - + var setupVisualisations = function (arr, config) { var i; for (i = 0; i < arr.length; i++) { setVisibility(arr[i]); - + // Applies to every visualisationElement (padding or margin div) arr[i]["transform"] = "none"; var el = window.document.createElement("div"), @@ -416,7 +2029,7 @@ function RemoteFunctions(config) { highlight.appendChild(el); } }; - + setupVisualisations( marginVisualisations, config.remoteHighlight.marginStyling @@ -425,11 +2038,11 @@ function RemoteFunctions(config) { paddingVisualisations, config.remoteHighlight.paddingStyling ); - + highlight.className = HIGHLIGHT_CLASSNAME; var offset = _screenOffset(element); - + // some code to find element left/top was removed here. This seems to be relevant to box model // live highlights. firether reading: https://github.com/adobe/brackets/pull/13357/files // we removed this in phoenix because it was throwing the rendering of live highlight boxes in phonix @@ -448,14 +2061,14 @@ function RemoteFunctions(config) { "position": "absolute", "pointer-events": "none", "box-shadow": "0 0 1px #fff", - "box-sizing": elementStyling.getPropertyValue('box-sizing'), - "border-right": elementStyling.getPropertyValue('border-right'), - "border-left": elementStyling.getPropertyValue('border-left'), - "border-top": elementStyling.getPropertyValue('border-top'), + "box-sizing": elementStyling.getPropertyValue('box-sizing'), + "border-right": elementStyling.getPropertyValue('border-right'), + "border-left": elementStyling.getPropertyValue('border-left'), + "border-top": elementStyling.getPropertyValue('border-top'), "border-bottom": elementStyling.getPropertyValue('border-bottom'), "border-color": config.remoteHighlight.borderColor }; - + var mergedStyles = Object.assign({}, stylesToSet, config.remoteHighlight.stylesToSet); var animateStartValues = config.remoteHighlight.animateStartValue; @@ -493,15 +2106,17 @@ function RemoteFunctions(config) { window.document.body.appendChild(highlight); }, - add: function (element, doAnimation) { + // 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) { if (this._elementExists(element) || element === window.document) { return; } if (this.trigger) { _trigger(element, "highlight", 1); } - - if ((!window.event || window.event instanceof MessageEvent) && !isInViewport(element)) { + + if (shouldAutoScroll && (!window.event || window.event instanceof MessageEvent) && !isInViewport(element)) { var top = getDocumentOffsetTop(element); if (top) { top -= (window.innerHeight / 2); @@ -543,34 +2158,26 @@ function RemoteFunctions(config) { this.clear(); for (i = 0; i < highlighted.length; i++) { - this.add(highlighted[i], false); + this.add(highlighted[i], false, false); // 3rd arg is for auto-scroll } } }; - var _currentEditor; - function _toggleEditor(element) { - _currentEditor = new Editor(element); - } - - var _currentMenu; - function _toggleMenu(element) { - if (_currentMenu) { - _currentMenu.remove(); - } - _currentMenu = new Menu(element); - } - var _localHighlight; - var _remoteHighlight; + var _hoverHighlight; + var _clickHighlight; + var _nodeInfoBox; + var _nodeMoreOptionsBox; + var _aiPromptBox; var _setup = false; - - /** Event Handlers ***********************************************************/ - function onMouseOver(event) { if (_validEvent(event)) { - _localHighlight.add(event.target, true); + // Skip highlighting for HTML, BODY tags and elements inside HEAD + if (event.target && event.target.nodeType === Node.ELEMENT_NODE && + event.target.tagName !== "HTML" && event.target.tagName !== "BODY" && !_isInsideHeadTag(event.target)) { + _localHighlight.add(event.target, true, false); // false means no-auto scroll + } } } @@ -585,14 +2192,182 @@ function RemoteFunctions(config) { window.document.removeEventListener("mousemove", onMouseMove); } + // helper function to get the current elements highlight mode + // this is as per user settings (either click or hover) + function getHighlightMode() { + return config.elemHighlights ? config.elemHighlights.toLowerCase() : "hover"; + } + + // helper function to check if highlights should show on hover + function shouldShowHighlightOnHover() { + return getHighlightMode() !== "click"; + } + + // helper function to clear element background highlighting + function clearElementBackground(element) { + if (element._originalBackgroundColor !== undefined) { + element.style.backgroundColor = element._originalBackgroundColor; + } else { + element.style.backgroundColor = ""; + } + delete element._originalBackgroundColor; + } + + function onElementHover(event) { + // don't want highlighting and stuff when auto scrolling + if (_isAutoScrolling) { + return; + } + + // if _hoverHighlight is uninitialized, initialize it + if (!_hoverHighlight && config.isLPEditFeaturesActive && shouldShowHighlightOnHover()) { + _hoverHighlight = new Highlight("#c8f9c5", true); + } + + // this is to check the user's settings, if they want to show the elements highlights on hover or click + if (_hoverHighlight && config.isLPEditFeaturesActive && shouldShowHighlightOnHover()) { + _hoverHighlight.clear(); + + // Skip highlighting for HTML, BODY tags and elements inside HEAD + // and for DOM elements which doesn't have 'data-brackets-id' + // NOTE: Don't remove 'data-brackets-id' check else hover will also target internal live preview elements + if ( + event.target && + event.target.nodeType === Node.ELEMENT_NODE && + event.target.tagName !== "HTML" && + event.target.tagName !== "BODY" && + !_isInsideHeadTag(event.target) && + event.target.hasAttribute("data-brackets-id") + ) { + // Store original background color to restore on hover out + event.target._originalBackgroundColor = event.target.style.backgroundColor; + event.target.style.backgroundColor = "rgba(0, 162, 255, 0.2)"; + + _hoverHighlight.add(event.target, false, false); // false means no auto-scroll + + // Create info box for the hovered element + dismissNodeInfoBox(); + _nodeInfoBox = new NodeInfoBox(event.target); + } + } + } + + function onElementHoverOut(event) { + // don't want highlighting and stuff when auto scrolling + if (_isAutoScrolling) { + return; + } + + // this is to check the user's settings, if they want to show the elements highlights on hover or click + if (_hoverHighlight && config.isLPEditFeaturesActive && shouldShowHighlightOnHover()) { + _hoverHighlight.clear(); + + // Restore original background color + if ( + event && + event.target && + event.target.nodeType === Node.ELEMENT_NODE && + event.target.hasAttribute("data-brackets-id") + ) { + clearElementBackground(event.target); + } + + // Remove info box when mouse leaves the element + dismissNodeInfoBox(); + } + } + + /** + * this function is responsible to select an element in the live preview + * @param {Element} element - The DOM element to select + */ + function _selectElement(element) { + // dismiss all UI boxes and cleanup previous element state when selecting a different element + dismissUIAndCleanupState(); + + // make sure that the feature is enabled and also the element has the attribute 'data-brackets-id' + if ( + !config.isLPEditFeaturesActive || + !element.hasAttribute("data-brackets-id") || + element.tagName === "BODY" || + element.tagName === "HTML" || + _isInsideHeadTag(element) + ) { + return; + } + + // make sure that the element is actually visible to the user + if (isElementVisible(element)) { + _nodeMoreOptionsBox = new NodeMoreOptionsBox(element); + _nodeInfoBox = new NodeInfoBox(element); + } else { + // Element is hidden, so don't show UI boxes but still apply visual styling + _nodeMoreOptionsBox = null; + } + + element._originalOutline = element.style.outline; + element.style.outline = "1px solid #4285F4"; + + // Add highlight for click mode + if (getHighlightMode() === "click") { + element._originalBackgroundColor = element.style.backgroundColor; + element.style.backgroundColor = "rgba(0, 162, 255, 0.2)"; + + if (_hoverHighlight) { + _hoverHighlight.clear(); + _hoverHighlight.add(element, true, false); // false means no auto-scroll + } + } + + previouslyClickedElement = element; + } + + /** + * 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 + * @param {Event} event + */ function onClick(event) { - if (_validEvent(event)) { + dismissAIPromptBox(); + + // make sure that the feature is enabled and also the clicked element has the attribute 'data-brackets-id' + if ( + config.isLPEditFeaturesActive && + event.target.hasAttribute("data-brackets-id") && + event.target.tagName !== "BODY" && + event.target.tagName !== "HTML" && + !_isInsideHeadTag(event.target) + ) { event.preventDefault(); event.stopPropagation(); - if (event.altKey) { - _toggleEditor(event.target); - } else { - _toggleMenu(event.target); + event.stopImmediatePropagation(); + + _selectElement(event.target); + } else if ( // when user clicks on the HTML, BODY tags or elements inside HEAD, we want to remove the boxes + _nodeMoreOptionsBox && + (event.target.tagName === "HTML" || event.target.tagName === "BODY" || _isInsideHeadTag(event.target)) + ) { + dismissUIAndCleanupState(); + } + } + + /** + * this function handles the double click event + * @param {Event} event + */ + function onDoubleClick(event) { + if ( + config.isLPEditFeaturesActive && + event.target.hasAttribute("data-brackets-id") && + event.target.tagName !== "BODY" && + event.target.tagName !== "HTML" && + !_isInsideHeadTag(event.target) + ) { + // because we only want to allow double click text editing where we show the edit option + if (_shouldShowEditTextOption(event.target)) { + event.preventDefault(); + event.stopPropagation(); + startEditing(event.target); } } } @@ -603,7 +2378,6 @@ function RemoteFunctions(config) { window.document.removeEventListener("mouseover", onMouseOver); window.document.removeEventListener("mouseout", onMouseOut); window.document.removeEventListener("mousemove", onMouseMove); - window.document.removeEventListener("click", onClick); _localHighlight.clear(); _localHighlight = undefined; _setup = false; @@ -611,6 +2385,9 @@ 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); @@ -622,70 +2399,169 @@ function RemoteFunctions(config) { } } - /** Public Commands **********************************************************/ - - // show goto - function showGoto(targets) { - if (!_currentMenu) { - return; - } - _currentMenu.createBody(); - var i; - for (i in targets) { - _currentMenu.addItem(targets[i]); - } - _currentMenu.show(); - } - // remove active highlights function hideHighlight() { - if (_remoteHighlight) { - _remoteHighlight.clear(); - _remoteHighlight = null; + if (_clickHighlight) { + _clickHighlight.clear(); + _clickHighlight = null; + } + if (_hoverHighlight) { + _hoverHighlight.clear(); } } // highlight a node function highlight(node, clear) { - if (!_remoteHighlight) { - _remoteHighlight = new Highlight("#cfc"); + if (!_clickHighlight) { + _clickHighlight = new Highlight("#cfc"); } if (clear) { - _remoteHighlight.clear(); + _clickHighlight.clear(); + } + // Skip highlighting for HTML, BODY tags and elements inside HEAD + if (node && node.nodeType === Node.ELEMENT_NODE && + node.tagName !== "HTML" && node.tagName !== "BODY" && !_isInsideHeadTag(node)) { + _clickHighlight.add(node, true, true); // 3rd arg is for auto-scroll } - _remoteHighlight.add(node, true); } // highlight a rule function highlightRule(rule) { hideHighlight(); var i, nodes = window.document.querySelectorAll(rule); + for (i = 0; i < nodes.length; i++) { highlight(nodes[i]); } - _remoteHighlight.selector = rule; + if (_clickHighlight) { + _clickHighlight.selector = rule; + } + + // select the first valid highlighted element + var foundValidElement = false; + for (i = 0; i < nodes.length; i++) { + if (nodes[i].hasAttribute("data-brackets-id") && + nodes[i].tagName !== "HTML" && + nodes[i].tagName !== "BODY" && + !_isInsideHeadTag(nodes[i]) && + nodes[i].tagName !== "BR" + ) { + _selectElement(nodes[i]); + foundValidElement = true; + break; + } + } + + // if no valid element present we dismiss the boxes + if (!foundValidElement) { + dismissUIAndCleanupState(); + } + } + + // recreate UI boxes (info box and more options box) + function redrawUIBoxes() { + if (_nodeMoreOptionsBox) { + const element = _nodeMoreOptionsBox.element; + _nodeMoreOptionsBox.remove(); + _nodeMoreOptionsBox = new NodeMoreOptionsBox(element); + + if (_nodeInfoBox) { + dismissNodeInfoBox(); + _nodeInfoBox = new NodeInfoBox(element); + } + } + + if (_aiPromptBox) { + const element = _aiPromptBox.element; + _aiPromptBox.remove(); + _aiPromptBox = new AIPromptBox(element); + } } // redraw active highlights function redrawHighlights() { - if (_remoteHighlight) { - _remoteHighlight.redraw(); + if (_clickHighlight) { + _clickHighlight.redraw(); + } + if (_hoverHighlight) { + _hoverHighlight.redraw(); } } - window.addEventListener("resize", redrawHighlights); - // Add a capture-phase scroll listener to update highlights when - // any element scrolls. + // just a wrapper function when we need to redraw highlights as well as UI boxes + function redrawEverything() { + redrawHighlights(); + redrawUIBoxes(); + } + + window.addEventListener("resize", redrawEverything); + + // Helper function to dismiss boxes only for elements that don't move with scroll + // this is needed for fixed positioned elements because otherwise the boxes will move along with scroll, + // 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) { + const moreOptionsBoxElement = _nodeMoreOptionsBox._shadow.querySelector('.phoenix-more-options-box'); + if(moreOptionsBoxElement) { + + // get the position of both the moreOptionsBox as well as the element + const moreOptionsBoxBounds = moreOptionsBoxElement.getBoundingClientRect(); + const elementBounds = _nodeMoreOptionsBox.element.getBoundingClientRect(); + + // this is to store the prev value, so that we can compare it the second time + if(!_nodeMoreOptionsBox._possDifference) { + _nodeMoreOptionsBox._possDifference = moreOptionsBoxBounds.top - elementBounds.top; + } else { + const calcNewDifference = moreOptionsBoxBounds.top - elementBounds.top; + const prevDifference = _nodeMoreOptionsBox._possDifference; + + // 4 is just for pixelated differences + if (Math.abs(calcNewDifference - prevDifference) > 4) { + dismissUIAndCleanupState(); + } + } + } + } else if (_nodeInfoBox && _nodeInfoBox.element) { + // 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) { + // here just we make sure that the element is same + if(!_nodeInfoBox._prevElement) { + _nodeInfoBox._prevElement = _nodeInfoBox.element; + } else if(_nodeInfoBox._prevElement !== _nodeInfoBox.element) { + return; + } else { + const infoBoxBounds = infoBoxElement.getBoundingClientRect(); + const elementBounds = _nodeInfoBox.element.getBoundingClientRect(); + + if(!_nodeInfoBox._possDifference) { + _nodeInfoBox._possDifference = infoBoxBounds.top - elementBounds.top; + } else { + const calcNewDifference = infoBoxBounds.top - elementBounds.top; + const prevDifference = _nodeInfoBox._possDifference; + + if (Math.abs(calcNewDifference - prevDifference) > 4) { + dismissUIAndCleanupState(); + } + } + } + } + } + } 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. if (e.target === window.document) { redrawHighlights(); + // need to dismiss the box if the elements are fixed, otherwise they drift at times + _dismissBoxesForFixedElements(); } else { - if (_remoteHighlight || _localHighlight) { + if (_localHighlight || _clickHighlight || _hoverHighlight) { window.setTimeout(redrawHighlights, 0); } + _dismissBoxesForFixedElements(); } } @@ -942,71 +2818,316 @@ function RemoteFunctions(config) { this.rememberedNodes = {}; // update highlight after applying diffs - redrawHighlights(); + redrawEverything(); }; function applyDOMEdits(edits) { _editHandler.apply(edits); } + function updateConfig(newConfig) { + var oldConfig = config; + config = JSON.parse(newConfig); + + if (config.highlight || (config.isLPEditFeaturesActive && shouldShowHighlightOnHover())) { + // Add hover event listeners if highlight is enabled OR editHighlights is set to hover + window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mouseout", onElementHoverOut); + window.document.addEventListener("mouseover", onElementHover); + window.document.addEventListener("mouseout", onElementHoverOut); + } else { + // Remove hover event listeners only if both highlight is disabled AND editHighlights is not set to hover + window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mouseout", onElementHoverOut); + + // Remove info box and more options box if highlight is disabled + dismissNodeInfoBox(); + dismissNodeMoreOptionsBox(); + } + + // Handle element highlight mode changes for instant switching + const oldHighlightMode = oldConfig.elemHighlights ? oldConfig.elemHighlights.toLowerCase() : "hover"; + const newHighlightMode = getHighlightMode(); + + if (oldHighlightMode !== newHighlightMode) { + // Clear any existing highlights when mode changes + if (_hoverHighlight) { + _hoverHighlight.clear(); + } + + // Clean up any previously highlighted elements + if (previouslyClickedElement) { + clearElementBackground(previouslyClickedElement); + } + + // Clear all elements that might have hover background styling applied + const allElements = window.document.querySelectorAll("[data-brackets-id]"); + for (let i = 0; i < allElements.length; i++) { + if (allElements[i]._originalBackgroundColor !== undefined) { + clearElementBackground(allElements[i]); + } + } + + // Remove info box when switching modes to avoid confusion + if (_nodeInfoBox && !_nodeMoreOptionsBox) { + dismissNodeInfoBox(); + } + + // Re-setup event listeners based on new mode to ensure proper behavior + if (config.highlight && config.isLPEditFeaturesActive) { + window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mouseout", onElementHoverOut); + window.document.addEventListener("mouseover", onElementHover); + window.document.addEventListener("mouseout", onElementHoverOut); + } + } + + return JSON.stringify(config); + } + /** - * - * @param {Element} elem + * This function checks if there are any live preview boxes currently visible + * @return {boolean} true if any boxes are visible, false otherwise + */ + function hasVisibleLivePreviewBoxes() { + return _nodeMoreOptionsBox !== null || _nodeInfoBox !== null || _aiPromptBox !== null || previouslyClickedElement !== null; + } + + /** + * 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 _domElementToJSON(elem) { - var json = { tag: elem.tagName.toLowerCase(), attributes: {}, children: [] }, - i, - len, - node, - value; + function dismissNodeInfoBox() { + if (_nodeInfoBox) { + _nodeInfoBox.remove(); + _nodeInfoBox = null; + return true; + } + return false; + } - len = elem.attributes.length; - for (i = 0; i < len; i++) { - node = elem.attributes.item(i); - value = (node.name === "data-brackets-id") ? parseInt(node.value, 10) : node.value; - json.attributes[node.name] = value; + /** + * 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; + } - len = elem.childNodes.length; - for (i = 0; i < len; i++) { - node = elem.childNodes.item(i); + /** + * 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; + } + + /** + * 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) { + if (previouslyClickedElement._originalOutline !== undefined) { + previouslyClickedElement.style.outline = previouslyClickedElement._originalOutline; + } else { + previouslyClickedElement.style.outline = ""; + } + delete previouslyClickedElement._originalOutline; - // ignores comment nodes and visuals generated by live preview - if (node.nodeType === Node.ELEMENT_NODE && node.className !== HIGHLIGHT_CLASSNAME) { - json.children.push(_domElementToJSON(node)); - } else if (node.nodeType === Node.TEXT_NODE) { - json.children.push({ content: node.nodeValue }); + clearElementBackground(previouslyClickedElement); + if (_hoverHighlight) { + _hoverHighlight.clear(); } + + 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; + } + - return json; + /** + * This function is responsible to move the cursor to the end of the text content when we start editing + * @param {DOMElement} element + */ + function moveCursorToEnd(selection, element) { + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); } - function getSimpleDOM() { - return JSON.stringify(_domElementToJSON(window.document.documentElement)); + // Function to handle direct editing of elements in the live preview + function startEditing(element) { + if (!config.isLPEditFeaturesActive + || !element + || element.tagName === "BODY" + || element.tagName === "HTML" + || _isInsideHeadTag(element) + || !element.hasAttribute("data-brackets-id")) { + return; + } + + // Make the element editable + element.setAttribute("contenteditable", "true"); + element.focus(); + + // Move cursor to end if no existing selection + const selection = window.getSelection(); + if (selection.rangeCount === 0 || selection.isCollapsed) { + moveCursorToEnd(selection, element); + } + + dismissUIAndCleanupState(); + + function onBlur() { + finishEditing(element); + } + + function onKeyDown(event) { + if (event.key === "Escape") { + // Cancel editing + event.preventDefault(); + finishEditing(element, false); // false means that the edit operation was cancelled + } else if (event.key === "Enter" && !event.shiftKey) { + // Finish editing on Enter (unless Shift is held) + event.preventDefault(); + finishEditing(element); + } + } + + element.addEventListener("blur", onBlur); + element.addEventListener("keydown", onKeyDown); + + // Store the event listeners for later removal + element._editListeners = { + blur: onBlur, + keydown: onKeyDown + }; } - - function updateConfig(newConfig) { - config = JSON.parse(newConfig); - return JSON.stringify(config); + + // Function to finish editing and apply changes + // isEditSuccessful: this is a boolean value, defaults to true. false only when the edit operation is cancelled + function finishEditing(element, isEditSuccessful = true) { + if (!config.isLPEditFeaturesActive || !element || !element.hasAttribute("contenteditable")) { + return; + } + + // Remove contenteditable attribute + element.removeAttribute("contenteditable"); + dismissUIAndCleanupState(); + + // Remove event listeners + if (element._editListeners) { + element.removeEventListener("blur", element._editListeners.blur); + element.removeEventListener("keydown", element._editListeners.keydown); + delete element._editListeners; + } + + if (element.hasAttribute("data-brackets-id")) { + const tagId = element.getAttribute("data-brackets-id"); + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + livePreviewTextEdit: true, + element: element, + newContent: element.outerHTML, + tagId: Number(tagId), + isEditSuccessful: isEditSuccessful + }); + } } // init _editHandler = new DOMEditHandler(window.document); - if (experimental) { - window.document.addEventListener("keydown", onKeyDown); + function registerHandlers() { + // Always remove existing listeners first to avoid duplicates + window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mouseout", onElementHoverOut); + window.document.removeEventListener("click", onClick); + window.document.removeEventListener("dblclick", onDoubleClick); + window.document.removeEventListener("dragover", onDragOver); + window.document.removeEventListener("drop", onDrop); + window.document.removeEventListener("dragleave", onDragLeave); + window.document.removeEventListener("keydown", onKeyDown); + + if (config.isLPEditFeaturesActive) { + // Initialize hover highlight with Chrome-like colors + _hoverHighlight = new Highlight("#c8f9c5", true); // Green similar to Chrome's padding color + + // Initialize click highlight with animation + _clickHighlight = new Highlight("#cfc", true); // Light green for click highlight + + window.document.addEventListener("mouseover", onElementHover); + window.document.addEventListener("mouseout", onElementHoverOut); + window.document.addEventListener("click", onClick); + window.document.addEventListener("dblclick", onDoubleClick); + window.document.addEventListener("dragover", onDragOver); + window.document.addEventListener("drop", onDrop); + window.document.addEventListener("dragleave", onDragLeave); + window.document.addEventListener("keydown", onKeyDown); + } else { + // Clean up any existing UI when edit features are disabled + dismissUIAndCleanupState(); + } } + registerHandlers(); + return { "DOMEditHandler" : DOMEditHandler, - "showGoto" : showGoto, "hideHighlight" : hideHighlight, "highlight" : highlight, "highlightRule" : highlightRule, "redrawHighlights" : redrawHighlights, + "redrawEverything" : redrawEverything, "applyDOMEdits" : applyDOMEdits, - "getSimpleDOM" : getSimpleDOM, - "updateConfig" : updateConfig + "updateConfig" : updateConfig, + "startEditing" : startEditing, + "finishEditing" : finishEditing, + "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes, + "dismissUIAndCleanupState" : dismissUIAndCleanupState, + "registerHandlers" : registerHandlers }; } diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index baeba8cbe3..3b2381dc09 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -699,6 +699,43 @@ define(function (require, exports, module) { } } + /** + * Check if live preview boxes are currently visible + */ + function hasVisibleLivePreviewBoxes() { + if (_protocol) { + return _protocol.evaluate("_LD.hasVisibleLivePreviewBoxes()"); + } + return false; + } + + /** + * Dismiss live preview boxes like info box, options box, AI box + */ + function dismissLivePreviewBoxes() { + if (_protocol) { + _protocol.evaluate("_LD.dismissUIAndCleanupState()"); + } + } + + /** + * Register event handlers in the remote browser for live preview functionality + */ + function registerHandlers() { + if (_protocol) { + _protocol.evaluate("_LD.registerHandlers()"); + } + } + + /** + * Update configuration in the remote browser + */ + function updateConfig(configJSON) { + if (_protocol) { + _protocol.evaluate("_LD.updateConfig('" + configJSON + "')"); + } + } + /** * Originally unload and reload agents. It doesn't apply for this new implementation. * @return {jQuery.Promise} Already resolved promise. @@ -765,6 +802,10 @@ define(function (require, exports, module) { exports.showHighlight = showHighlight; exports.hideHighlight = hideHighlight; exports.redrawHighlight = redrawHighlight; + exports.hasVisibleLivePreviewBoxes = hasVisibleLivePreviewBoxes; + exports.dismissLivePreviewBoxes = dismissLivePreviewBoxes; + exports.registerHandlers = registerHandlers; + exports.updateConfig = updateConfig; exports.init = init; exports.isActive = isActive; exports.setLivePreviewPinned= setLivePreviewPinned; diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/LiveDevelopment/LivePreviewEdit.js new file mode 100644 index 0000000000..9092362f8d --- /dev/null +++ b/src/LiveDevelopment/LivePreviewEdit.js @@ -0,0 +1,617 @@ +define(function (require, exports, module) { + const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"); + const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser"); + const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"); + + /** + * This function syncs text content changes between the original source code + * and the live preview DOM after a text edit in the browser + * + * @private + * @param {String} oldContent - the original source code from the editor + * @param {String} newContent - the outerHTML after editing in live preview + * @returns {String} - the updated content that should replace the original editor code + * + * NOTE: We don’t touch tag names or attributes — + * we only care about text changes or things like newlines,
, or formatting like , , etc. + * + * Here's the basic idea: + * - Parse both old and new HTML strings into DOM trees + * - Then walk both DOMs side by side and sync changes + * + * What we handle: + * - if both are text nodes → update the text if changed + * - if both are elements with same tag → go deeper and sync their children + * - if one is text and one is an element → replace (like when user adds/removes
or adds bold/italic) + * - if a node got added or removed → do that in the old DOM + * + * We don’t recreate or touch existing elements unless absolutely needed, + * so all original user-written attributes and tag structure stay exactly the same. + * + * This avoids the browser trying to “fix” broken HTML (which we don’t want) + */ + function _syncTextContentChanges(oldContent, newContent) { + const parser = new DOMParser(); + const oldDoc = parser.parseFromString(oldContent, "text/html"); + const newDoc = parser.parseFromString(newContent, "text/html"); + + const oldRoot = oldDoc.body; + const newRoot = newDoc.body; + + // this function is to remove the phoenix internal attributes from leaking into the user's source code + function cleanClonedElement(clonedElement) { + if (clonedElement.nodeType === Node.ELEMENT_NODE) { + // this are phoenix's internal attributes + const attrs = ["data-brackets-id", "data-ld-highlight"]; + + // remove from the cloned element + attrs.forEach(attr => clonedElement.removeAttribute(attr)); + + // also remove from its childrens + clonedElement.querySelectorAll(attrs.map(a => `[${a}]`).join(",")) + .forEach(el => attrs.forEach(attr => el.removeAttribute(attr))); + } + return clonedElement; + } + + function syncText(oldNode, newNode) { + if (!oldNode || !newNode) { + return; + } + + // when both are text nodes, we just need to replace the old text with the new one + if (oldNode.nodeType === Node.TEXT_NODE && newNode.nodeType === Node.TEXT_NODE) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + return; + } + + // when both are elements + if (oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) { + const oldChildren = Array.from(oldNode.childNodes); + const newChildren = Array.from(newNode.childNodes); + + const maxLen = Math.max(oldChildren.length, newChildren.length); + + for (let i = 0; i < maxLen; i++) { + const oldChild = oldChildren[i]; + const newChild = newChildren[i]; + + if (!oldChild && newChild) { + // if new child added → clone and insert + const cloned = newChild.cloneNode(true); + oldNode.appendChild(cleanClonedElement(cloned)); + } else if (oldChild && !newChild) { + // if child removed → delete + oldNode.removeChild(oldChild); + } else if ( + oldChild.nodeType === newChild.nodeType && + oldChild.nodeType === Node.ELEMENT_NODE && + oldChild.tagName === newChild.tagName + ) { + // same element tag → sync recursively + syncText(oldChild, newChild); + } else if ( + oldChild.nodeType === Node.TEXT_NODE && + newChild.nodeType === Node.TEXT_NODE + ) { + if (oldChild.nodeValue !== newChild.nodeValue) { + oldChild.nodeValue = newChild.nodeValue; + } + } else { + // different node types or tags → replace + const cloned = newChild.cloneNode(true); + oldNode.replaceChild(cleanClonedElement(cloned), oldChild); + } + } + } + } + + const oldEls = Array.from(oldRoot.children); + const newEls = Array.from(newRoot.children); + + for (let i = 0; i < Math.min(oldEls.length, newEls.length); i++) { + syncText(oldEls[i], newEls[i]); + } + + return oldRoot.innerHTML; + } + + /** + * helper function to get editor and validate basic requirements + * @param {Number} tagId - the data-brackets-id of the element + */ + function _getEditorAndValidate(tagId) { + const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); + if (!currLiveDoc || !currLiveDoc.editor) { + return null; + } + // for undo/redo operations, tagId might not be needed, so we only check it if provided + if (tagId !== undefined && !tagId) { + return null; + } + return currLiveDoc.editor; + } + + /** + * helper function to get element range from tagId + * + * @param {Object} editor - the editor instance + * @param {Number} tagId - the data-brackets-id of the element + * @returns {Object|null} - object with startPos and endPos, or null if not found + */ + function _getElementRange(editor, tagId) { + // get the start range from the getPositionFromTagId function + // and we get the end range from the findMatchingTag function + // NOTE: we cannot get the end range from getPositionFromTagId + // because on non-beautified code getPositionFromTagId may not provide correct end position + const startRange = HTMLInstrumentation.getPositionFromTagId(editor, tagId); + if(!startRange) { + return null; + } + + const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from); + if (!endRange) { + return null; + } + + const startPos = startRange.from; + // for empty tags endRange.close might not exist, for ex: img tag + const endPos = endRange.close ? endRange.close.to : endRange.open.to; + + return { startPos, endPos }; + } + + /** + * this function handles the text edit in the source code when user updates the text in the live preview + * + * @param {Object} message - the message object + * - livePreviewEditEnabled: true + * - livePreviewTextEdit: true + * - element: element + * - newContent: element.outerHTML (the edited content from live preview) + * - tagId: Number (data-brackets-id of the edited element) + * - isEditSuccessful: boolean (false when user pressed Escape to cancel, otherwise true always) + */ + function _editTextInSource(message) { + const editor = _getEditorAndValidate(message.tagId); + if (!editor) { + return; + } + + const range = _getElementRange(editor, message.tagId); + if (!range) { + return; + } + + const { startPos, endPos } = range; + + const text = editor.getTextBetween(startPos, endPos); + + // if the edit was cancelled (mainly by pressing Escape key) + // we just replace the same text with itself + // this is a quick trick because as the code is changed for that element in the file, + // the live preview for that element gets refreshed and the changes are discarded in the live preview + if(!message.isEditSuccessful) { + editor.replaceRange(text, startPos, endPos); + editor.document._markClean(); + } else { + + // if the edit operation was successful, we call a helper function that + // is responsible to provide the actual content that needs to be written in the editor + // + // text: the actual current source code in the editor + // message.newContent: the new content in the live preview after the edit operation + const finalText = _syncTextContentChanges(text, message.newContent); + editor.replaceRange(finalText, startPos, endPos); + } + } + + /** + * This function is responsible to duplicate an element from the source code + * @param {Number} tagId - the data-brackets-id of the DOM element + */ + function _duplicateElementInSourceByTagId(tagId) { + // this is to get the currently live document that is being served in the live preview + const editor = _getEditorAndValidate(tagId); + if (!editor) { + return; + } + + const range = _getElementRange(editor, tagId); + if (!range) { + return; + } + + const { startPos, endPos } = range; + + // this is the actual source code for the element that we need to duplicate + const text = editor.getTextBetween(startPos, endPos); + // this is the indentation on the line + const indent = editor.getTextBetween({ line: startPos.line, ch: 0 }, startPos); + + editor.document.batchOperation(function () { + // make sure there is only indentation and no text before it + if (indent.trim() === "") { + // this is the position where we need to insert + // we're giving the char as 0 because since we insert a new line using '\n' + // that's why writing any char value will not work, as the line is emptys + // and codemirror doesn't allow to insert at a column (ch) greater than the length of the line + // So, the logic is to just append the indent before the text at this insertPos + const insertPos = { + line: startPos.line + (endPos.line - startPos.line + 1), + ch: 0 + }; + + editor.replaceRange("\n", endPos); + editor.replaceRange(indent + text, insertPos); + } else { + // if there is some text, we just add the duplicated text right next to it + editor.replaceRange(text, startPos); + } + }); + } + + /** + * This function is responsible to delete an element from the source code + * @param {Number} tagId - the data-brackets-id of the DOM element + */ + function _deleteElementInSourceByTagId(tagId) { + // this is to get the currently live document that is being served in the live preview + const editor = _getEditorAndValidate(tagId); + if (!editor) { + return; + } + + const range = _getElementRange(editor, tagId); + if (!range) { + return; + } + + const { startPos, endPos } = range; + + editor.document.batchOperation(function () { + editor.replaceRange("", startPos, endPos); + + // since we remove content from the source, we want to clear the extra line + if(startPos.line !== 0 && !(editor.getLine(startPos.line).trim())) { + const prevLineText = editor.getLine(startPos.line - 1); + const chPrevLine = prevLineText ? prevLineText.length : 0; + editor.replaceRange("", {line: startPos.line - 1, ch: chPrevLine}, startPos); + } + }); + } + + /** + * this function is to clean up the empty lines after an element is removed + * @param {Object} editor - the editor instance + * @param {Object} range - the range where element was removed + */ + function _cleanupAfterRemoval(editor, range) { + const lineToCheck = range.from.line; + + // check if the line where element was removed is now empty + if (lineToCheck < editor.lineCount()) { + const currentLineText = editor.getLine(lineToCheck); + if (currentLineText && currentLineText.trim() === "") { + // remove the empty line + const lineStart = { line: lineToCheck, ch: 0 }; + const lineEnd = { line: lineToCheck + 1, ch: 0 }; + editor.replaceRange("", lineStart, lineEnd); + } + } + + // also we need to check the previous line if it became empty + if (lineToCheck > 0) { + const prevLineText = editor.getLine(lineToCheck - 1); + if (prevLineText && prevLineText.trim() === "") { + const lineStart = { line: lineToCheck - 1, ch: 0 }; + const lineEnd = { line: lineToCheck, ch: 0 }; + editor.replaceRange("", lineStart, lineEnd); + } + } + } + + /** + * this function is to make sure that we insert elements with proper indentation + * + * @param {Object} editor - the editor instance + * @param {Object} insertPos - position where to insert + * @param {Boolean} insertAfterMode - whether to insert after the position + * @param {String} targetIndent - the indentation to use + * @param {String} sourceText - the text to insert + */ + function _insertElementWithIndentation(editor, insertPos, insertAfterMode, targetIndent, sourceText) { + if (insertAfterMode) { + // Insert after the target element + editor.replaceRange("\n" + targetIndent + sourceText, insertPos); + } else { + // Insert before the target element + const insertLine = insertPos.line; + const lineStart = { line: insertLine, ch: 0 }; + + // Get current line content to preserve any existing indentation structure + const currentLine = editor.getLine(insertLine); + + if (currentLine && currentLine.trim() === "") { + // the line is empty, replace it entirely + editor.replaceRange(targetIndent + sourceText, lineStart, { line: insertLine, ch: currentLine.length }); + } else { + // the line has content, insert before it + editor.replaceRange(targetIndent + sourceText + "\n", lineStart); + } + } + } + + /** + * This function is to make sure that the target element doesn't lie completely within the source element + * because if that is the case then it means that the drag-drop was not performed correctly + * + * @param {Object} source - start/end pos of the source element + * @param {Object} target - start/end pos of the target element + * @returns {Boolean} true if target is fully inside source, false otherwise + */ + function _targetInsideSource(source, target) { + if ( + (source.from.line < target.from.line || + (source.from.line === target.from.line && source.from.ch <= target.from.ch)) && + (source.to.line > target.to.line || + (source.to.line === target.to.line && source.to.ch >= target.to.ch)) + ) { + return true; + } + + return false; + } + + /** + * This function is responsible for moving an element from one position to another in the source code + * it is called when there is drag-drop in the live preview + * @param {Number} sourceId - the data-brackets-id of the element being moved + * @param {Number} targetId - the data-brackets-id of the target element where to move + * @param {Boolean} insertAfter - whether to insert the source element after the target element + * @param {Boolean} insertInside - whether to insert the source element as a child of the target element + */ + function _moveElementInSource(sourceId, targetId, insertAfter, insertInside = false) { + // this is to get the currently live document that is being served in the live preview + const editor = _getEditorAndValidate(sourceId); + if (!editor || !targetId) { + return; + } + + const sourceRange = _getElementRange(editor, sourceId); + if (!sourceRange) { + return; + } + + const targetRange = _getElementRange(editor, targetId); + if (!targetRange) { + return; + } + + // convert to the format expected by the rest of the function + const sourceRangeObj = { + from: sourceRange.startPos, + to: sourceRange.endPos + }; + + const targetRangeObj = { + from: targetRange.startPos, + to: targetRange.endPos + }; + + // make sure that the target is not within the source + // this would otherwise remove both source and target, breaking the document + if (_targetInsideSource(sourceRangeObj, targetRangeObj)) { + return; + } + + const sourceText = editor.getTextBetween(sourceRangeObj.from, sourceRangeObj.to); + let targetIndent = editor.getTextBetween({ line: targetRangeObj.from.line, ch: 0 }, targetRangeObj.from); + if(targetIndent && targetIndent.trim() !== "") { // because indentation should hold no text + let indentLength = targetIndent.search(/\S/); + if (indentLength === -1) { + indentLength = targetIndent.length; + } + targetIndent = ' '.repeat(indentLength); + } + + // Check if source is before target to determine order of operations + // check if the source is before target or after the target + // we need this because + // If source is before target → we need to insert first, then remove + // If target is before source → remove first, then insert + const sourceBeforeTarget = + sourceRangeObj.from.line < targetRangeObj.from.line || + (sourceRangeObj.from.line === targetRangeObj.from.line && sourceRangeObj.from.ch < targetRangeObj.from.ch); + + // creating a batch operation so that undo in live preview works fine + editor.document.batchOperation(function () { + if (sourceBeforeTarget) { + // this handles the case when source is before target: insert first, then remove + if (insertInside) { + const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, targetRangeObj.from); + if (matchingTagInfo && matchingTagInfo.open) { + const insertPos = { + line: matchingTagInfo.open.to.line, + ch: matchingTagInfo.open.to.ch + }; + + const indentInfo = editor._detectIndent(); + const childIndent = targetIndent + indentInfo.indent; + _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText); + } + } else if (insertAfter) { + const insertPos = { + line: targetRangeObj.to.line, + ch: targetRangeObj.to.ch + }; + _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText); + } else { + // insert before target + _insertElementWithIndentation(editor, targetRangeObj.from, false, targetIndent, sourceText); + } + + // Now remove the source element (NOTE: the positions have shifted) + const updatedSourceRange = _getElementRange(editor, sourceId); + if (updatedSourceRange) { + const updatedSourceRangeObj = { + from: updatedSourceRange.startPos, + to: updatedSourceRange.endPos + }; + editor.replaceRange("", updatedSourceRangeObj.from, updatedSourceRangeObj.to); + _cleanupAfterRemoval(editor, updatedSourceRangeObj); + } + } else { + // This handles the case when target is before source: remove first, then insert + // Store source range before removal + const originalSourceRange = { ...sourceRangeObj }; + + // Remove the source element first + editor.replaceRange("", sourceRangeObj.from, sourceRangeObj.to); + _cleanupAfterRemoval(editor, originalSourceRange); + + // Recalculate target range after source removal as the positions have shifted + const updatedTargetRange = _getElementRange(editor, targetId); + if (!updatedTargetRange) { + return; + } + + const updatedTargetRangeObj = { + from: updatedTargetRange.startPos, + to: updatedTargetRange.endPos + }; + + if (insertInside) { + const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, updatedTargetRangeObj.from); + if (matchingTagInfo && matchingTagInfo.open) { + const insertPos = { + line: matchingTagInfo.open.to.line, + ch: matchingTagInfo.open.to.ch + }; + + const indentInfo = editor._detectIndent(); + const childIndent = targetIndent + indentInfo.indent; + _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText); + } + } else if (insertAfter) { + const insertPos = { + line: updatedTargetRangeObj.to.line, + ch: updatedTargetRangeObj.to.ch + }; + _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText); + } else { + // Insert before target + _insertElementWithIndentation(editor, updatedTargetRangeObj.from, false, targetIndent, sourceText); + } + } + }); + } + + /** + * This function is to handle the undo redo operation in the live preview + * @param {String} undoOrRedo - "undo" when to undo, and "redo" for redo + */ + function handleUndoRedoOperation(undoOrRedo) { + const editor = _getEditorAndValidate(); // no tagId needed for undo/redo + if (!editor) { + return; + } + + if (undoOrRedo === "undo") { + editor.undo(); + } else if (undoOrRedo === "redo") { + editor.redo(); + } + } + + function _getRequiredDataForAI(message) { + // this is to get the currently live document that is being served in the live preview + const editor = _getEditorAndValidate(message.tagId); + if (!editor) { + return; + } + + const range = _getElementRange(editor, message.tagId); + if (!range) { + return; + } + + const { startPos, endPos } = range; + // this is the actual source code for the element that we need to duplicate + const text = editor.getTextBetween(startPos, endPos); + const fileName = editor.document.file.name; + const filePath = editor.document.file.fullPath; + + const AIData = { + editor: editor, // the editor instance that is being served in the live preview + fileName: fileName, + filePath: filePath, // the complete absolute path + tagId: message.tagId, // the data-brackets-id of the element which was selected for AI edit + range: {startPos, endPos}, // the start and end position text in the source code for that element + text: text, // the actual source code in between the start and the end pos + prompt: message.prompt, // the prompt that user typed + model: message.selectedModel // the selected model (fast, slow or moderate) + }; + + return AIData; + } + + function _editWithAI(message) { + const AIData = _getRequiredDataForAI(message); + // write the AI implementation here...@abose + } + + /** + * This is the main function that is exported. + * it will be called by LiveDevProtocol when it receives a message from RemoteFunctions.js + * or LiveDevProtocolRemote.js (for undo) using MessageBroker + * Refer to: `handleOptionClick` function in the RemoteFunctions.js and `_receive` function in LiveDevProtocol.js + * + * @param {Object} message - this is the object that is passed by RemoteFunctions.js using MessageBroker + * this object will be in the format + * { + livePreviewEditEnabled: true, + tagId: tagId, + delete || duplicate || livePreviewTextEdit || AISend: true + undoLivePreviewOperation: true (this property is available only for undo operation) + + prompt: prompt (only for AI) + + sourceId: sourceId, (these are for move (drag & drop)) + targetId: targetId, + insertAfter: boolean, (whether to insert after the target element) + move: true + } + * these are the main properties that are passed through the message + */ + function handleLivePreviewEditOperation(message) { + // handle move(drag & drop) + if (message.move && message.sourceId && message.targetId) { + _moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside); + return; + } + + if (!message.element || !message.tagId) { + // check for undo + if (message.undoLivePreviewOperation || message.redoLivePreviewOperation) { + message.undoLivePreviewOperation ? handleUndoRedoOperation("undo") : handleUndoRedoOperation("redo"); + } + return; + } + + // just call the required functions + if (message.delete) { + _deleteElementInSourceByTagId(message.tagId); + } else if (message.duplicate) { + _duplicateElementInSourceByTagId(message.tagId); + } else if (message.livePreviewTextEdit) { + _editTextInSource(message); + } else if (message.AISend) { + _editWithAI(message); + } + } + + exports.handleLivePreviewEditOperation = handleLivePreviewEditOperation; +}); diff --git a/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js b/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js index f467cd6ad0..13934ac7ee 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js +++ b/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js @@ -851,10 +851,12 @@ define(function (require, exports, module) { return (mark.tagID === tagId); }); if (markFound) { - return markFound.find().from; + return { + from: markFound.find().from, + to: markFound.find().to + }; } return null; - } // private methods diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index f6b9c39108..293dbcc4f5 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -52,7 +52,8 @@ define(function (require, exports, module) { HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"), StringUtils = require("utils/StringUtils"), FileViewController = require("project/FileViewController"), - MainViewManager = require("view/MainViewManager"); + MainViewManager = require("view/MainViewManager"), + LivePreviewEdit = require("LiveDevelopment/LivePreviewEdit"); const LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = `phoenix_live_preview_scripts_instrumented_${StringUtils.randomString(8)}.js`; const LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = `pageLoaderWorker_${StringUtils.randomString(8)}.js`; @@ -165,8 +166,9 @@ define(function (require, exports, module) { } const allOpenFileCount = MainViewManager.getWorkingSetSize(MainViewManager.ALL_PANES); function selectInHTMLEditor(fullHtmlEditor) { - const position = HTMLInstrumentation.getPositionFromTagId(fullHtmlEditor, parseInt(tagId, 10)); - if(position && fullHtmlEditor) { + const positionResult = HTMLInstrumentation.getPositionFromTagId(fullHtmlEditor, parseInt(tagId, 10)); + if(positionResult && positionResult.from && fullHtmlEditor) { + const position = positionResult.from; const masterEditor = fullHtmlEditor.document._masterEditor || fullHtmlEditor; masterEditor.setCursorPos(position.line, position.ch, true); _focusEditorIfNeeded(masterEditor, nodeName, contentEditable); @@ -207,6 +209,10 @@ define(function (require, exports, module) { var msg = JSON.parse(msgStr), event = msg.method || "event", deferred; + if (msg.livePreviewEditEnabled) { + LivePreviewEdit.handleLivePreviewEditOperation(msg); + } + if (msg.id) { deferred = _responseDeferreds[msg.id]; if (deferred) { diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index 7d85eeab5f..a22de13ddf 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -42,7 +42,13 @@ define(function main(require, exports, module) { Strings = require("strings"), ExtensionUtils = require("utils/ExtensionUtils"), StringUtils = require("utils/StringUtils"), - EventDispatcher = require("utils/EventDispatcher"); + EventDispatcher = require("utils/EventDispatcher"), + WorkspaceManager = require("view/WorkspaceManager"); + + + // this is responsible to make the advanced live preview features active or inactive + // @abose (make this variable false when not a paid user, everything rest is handled automatically) + let isLPEditFeaturesActive = false; const EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = "liveHighlightPrefChange"; @@ -57,6 +63,19 @@ define(function main(require, exports, module) { marginColor: {r: 246, g: 178, b: 107, a: 0.66}, paddingColor: {r: 147, g: 196, b: 125, a: 0.66}, showInfo: true + }, + isLPEditFeaturesActive: isLPEditFeaturesActive, + elemHighlights: "hover", // 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 + strings: { + selectParent: Strings.LIVE_DEV_MORE_OPTIONS_SELECT_PARENT, + editText: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_TEXT, + 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 } }; // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. @@ -79,14 +98,12 @@ define(function main(require, exports, module) { "opacity": 0.6 }, "paddingStyling": { - "border-width": "1px", - "border-style": "dashed", - "border-color": "rgba(0, 162, 255, 0.5)" + "background-color": "rgba(200, 249, 197, 0.7)" }, "marginStyling": { - "background-color": "rgba(21, 165, 255, 0.58)" + "background-color": "rgba(249, 204, 157, 0.7)" }, - "borderColor": "rgba(21, 165, 255, 0.85)", + "borderColor": "rgba(200, 249, 197, 0.85)", "showPaddingMargin": true }, { description: Strings.DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS @@ -239,6 +256,19 @@ define(function main(require, exports, module) { } } + /** + * this function handles escape key for live preview to hide boxes if they are visible + * @param {Event} event + */ + function _handleLivePreviewEscapeKey(event) { + // we only handle the escape keypress for live preview when its active + if (MultiBrowserLiveDev.status === MultiBrowserLiveDev.STATUS_ACTIVE) { + MultiBrowserLiveDev.dismissLivePreviewBoxes(); + } + // returning false to let the editor also handle the escape key + return false; + } + /** Initialize LiveDevelopment */ AppInit.appReady(function () { params.parse(); @@ -276,7 +306,7 @@ define(function main(require, exports, module) { .on("change", function () { config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT); if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.agents.remote.call("updateConfig",JSON.stringify(config)); + MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); } }); @@ -293,6 +323,9 @@ define(function main(require, exports, module) { exports.trigger(exports.EVENT_LIVE_PREVIEW_RELOAD, clientDetails); }); + // allow live preview to handle escape key event + // Escape is mainly to hide boxes if they are visible + WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey); }); // init prefs @@ -300,10 +333,33 @@ define(function main(require, exports, module) { .on("change", function () { config.highlight = PreferencesManager.getViewState("livedevHighlight"); _updateHighlightCheckmark(); + if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { + MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); + } }); config.highlight = PreferencesManager.getViewState("livedevHighlight"); + function setLivePreviewEditFeaturesActive(enabled) { + isLPEditFeaturesActive = enabled; + config.isLPEditFeaturesActive = enabled; + if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { + MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); + MultiBrowserLiveDev.registerHandlers(); + } + } + + // this function is responsible to update element highlight config + // called from live preview extension when preference changes + function updateElementHighlightConfig() { + const prefValue = PreferencesManager.get("livePreviewElementHighlights"); + config.elemHighlights = prefValue || "hover"; + if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { + 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); @@ -312,6 +368,8 @@ define(function main(require, exports, module) { EventDispatcher.makeEventDispatcher(exports); + exports.isLPEditFeaturesActive = isLPEditFeaturesActive; + // public events exports.EVENT_OPEN_PREVIEW_URL = MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL; exports.EVENT_CONNECTION_CLOSE = MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE; @@ -327,6 +385,10 @@ define(function main(require, exports, module) { exports.setLivePreviewPinned = setLivePreviewPinned; exports.setLivePreviewTransportBridge = setLivePreviewTransportBridge; exports.togglePreviewHighlight = togglePreviewHighlight; + exports.setLivePreviewEditFeaturesActive = setLivePreviewEditFeaturesActive; + exports.updateElementHighlightConfig = updateElementHighlightConfig; exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds; exports.getLivePreviewDetails = MultiBrowserLiveDev.getLivePreviewDetails; + exports.hideHighlight = MultiBrowserLiveDev.hideHighlight; + exports.dismissLivePreviewBoxes = MultiBrowserLiveDev.dismissLivePreviewBoxes; }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js index d301b340d6..8fa32929fd 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js @@ -87,7 +87,7 @@ define(function (require, exports, module) { description: Strings.LIVE_DEV_SETTINGS_FRAMEWORK_PREFERENCES, values: Object.keys(SUPPORTED_FRAMEWORKS) }); - + async function detectFramework($frameworkSelect, $hotReloadChk) { for(let framework of Object.keys(SUPPORTED_FRAMEWORKS)){ const configFile = SUPPORTED_FRAMEWORKS[framework].configFile, @@ -131,10 +131,11 @@ define(function (require, exports, module) { $hotReloadLabel = $template.find("#hotReloadLabel"), $frameworkLabel = $template.find("#frameworkLabel"), $frameworkSelect = $template.find("#frameworkSelect"); + + // Initialize form values from preferences $enableCustomServerChk.prop('checked', PreferencesManager.get(PREFERENCE_PROJECT_SERVER_ENABLED)); $showLivePreviewAtStartup.prop('checked', PreferencesManager.get(PREFERENCE_SHOW_LIVE_PREVIEW_PANEL)); $hotReloadChk.prop('checked', !!PreferencesManager.get(PREFERENCE_PROJECT_SERVER_HOT_RELOAD_SUPPORTED)); - // figure out the framework if(PreferencesManager.get(PREFERENCE_PROJECT_PREVIEW_FRAMEWORK) === null) { detectFramework($frameworkSelect, $hotReloadChk); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css index 3d217f27a6..f5a8e661bd 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css +++ b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css @@ -69,10 +69,6 @@ background: url("./images/sprites.svg#reload-icon") center no-repeat; } -.pointer-fill-icon { - background: url("./images/sprites.svg#pointer-fill-icon") center no-repeat; -} - #live-preview-plugin-toolbar:hover .lp-settings-icon { display: flex; align-items: center; @@ -97,6 +93,41 @@ transition: opacity 1s, visibility 0s linear 1s; /* Fade-out effect */ } -.pointer-icon { - background: url("./images/sprites.svg#pointer-icon") center no-repeat; +#livePreviewModeBtn { + min-width: fit-content; + display: flex; + align-items: center; + margin-right: 4px; + max-width: 80%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + cursor: pointer; + background: #3C3F41; + box-shadow: none; + border: 1px solid #3C3F41; + box-sizing: border-box; + color: #a0a0a0; + font-size: 14px; + font-weight: normal; + padding: 0 0.35em; +} + +#livePreviewModeBtn:hover { + border: 1px solid rgba(0, 0, 0, 0.24) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important; +} + +#livePreviewModeBtn.btn-dropdown::after { + position: static; + margin-top: 2px; + margin-left: 5px; +} + +#reloadLivePreviewButton { + margin-left: 3px; + margin-top: 3.5px; + width: 22px; + height: 22px; + flex-shrink: 0; } diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index ddf80c12aa..139b924120 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -62,6 +62,7 @@ define(function (require, exports, module) { NativeApp = require("utils/NativeApp"), StringUtils = require("utils/StringUtils"), FileSystem = require("filesystem/FileSystem"), + DropdownButton = require("widgets/DropdownButton"), BrowserStaticServer = require("./BrowserStaticServer"), NodeStaticServer = require("./NodeStaticServer"), LivePreviewSettings = require("./LivePreviewSettings"), @@ -85,6 +86,29 @@ define(function (require, exports, module) { const PREVIEW_TRUSTED_PROJECT_KEY = "preview_trusted"; const PREVIEW_PROJECT_README_KEY = "preview_readme"; + // live preview mode pref + const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; + + /** + * Get the appropriate default mode based on whether edit features are active + * @returns {string} "highlight" if edit features inactive, "edit" if active + */ + function _getDefaultMode() { + return LiveDevelopment.isLPEditFeaturesActive ? "edit" : "highlight"; + } + + // define the live preview mode preference + PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", _getDefaultMode(), { + description: StringUtils.format(Strings.LIVE_PREVIEW_MODE_PREFERENCE, "'preview'", "'highlight'", "'edit'"), + values: ["preview", "highlight", "edit"] + }); + + // live preview element highlights preference (whether on hover or click) + const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights"; + PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", "hover", { + description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE + }); + const LIVE_PREVIEW_PANEL_ID = "live-preview-panel"; const LIVE_PREVIEW_IFRAME_ID = "panel-live-preview-frame"; const LIVE_PREVIEW_IFRAME_HTML = ` @@ -109,7 +133,6 @@ define(function (require, exports, module) { $iframe, $panel, $pinUrlBtn, - $highlightBtn, $livePreviewPopBtn, $reloadBtn, $chromeButton, @@ -120,7 +143,8 @@ define(function (require, exports, module) { $safariButtonBallast, $edgeButtonBallast, $firefoxButtonBallast, - $panelTitle; + $panelTitle, + $modeBtn; let customLivePreviewBannerShown = false; @@ -139,10 +163,214 @@ define(function (require, exports, module) { editor.focus(); }); + function _showProFeatureDialog() { + const dialog = Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + Strings.LIVE_PREVIEW_PRO_FEATURE_TITLE, + Strings.LIVE_PREVIEW_PRO_FEATURE_MESSAGE, + [ + { + className: Dialogs.DIALOG_BTN_CLASS_NORMAL, + id: Dialogs.DIALOG_BTN_CANCEL, + text: Strings.CANCEL + }, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: "subscribe", + text: Strings.LIVE_PREVIEW_PRO_SUBSCRIBE + } + ] + ); + + dialog.done(function (buttonId) { + if (buttonId === "subscribe") { + // TODO: write the implementation here...@abose + console.log("the subscribe button got clicked"); + } + }); + + return dialog; + } + + // this function is to check if the live highlight feature is enabled or not function _isLiveHighlightEnabled() { return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); } + /** + * Live Preview 'Preview Mode'. in this mode no live preview highlight or any such features are active + * Just the plain website + */ + function _LPPreviewMode() { + LiveDevelopment.setLivePreviewEditFeaturesActive(false); + if(_isLiveHighlightEnabled()) { + LiveDevelopment.togglePreviewHighlight(); + } + } + + /** + * Live Preview 'Highlight Mode'. in this mode only the live preview matching with the source code is active + * Meaning that if user clicks on some element that element's source code will be highlighted and vice versa + */ + function _LPHighlightMode() { + LiveDevelopment.setLivePreviewEditFeaturesActive(false); + if(!_isLiveHighlightEnabled()) { + LiveDevelopment.togglePreviewHighlight(); + } + } + + /** + * Live Preview 'Edit Mode'. this is the most interactive mode, in here the highlight features are available + * along with that we also show element's highlighted boxes and such + */ + function _LPEditMode() { + LiveDevelopment.setLivePreviewEditFeaturesActive(true); + if(!_isLiveHighlightEnabled()) { + LiveDevelopment.togglePreviewHighlight(); + } + } + + /** + * update the mode button text in the live preview toolbar UI based on the current mode + * @param {String} mode - The current mode ("preview", "highlight", or "edit") + */ + function _updateModeButton(mode) { + if ($modeBtn) { + if (mode === "highlight") { + $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_HIGHLIGHT; + } else if (mode === "edit") { + $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_EDIT; + } else { + $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_PREVIEW; + } + } + } + + /** + * init live preview mode from saved preferences + */ + function _initializeMode() { + const savedMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); + const isEditFeaturesActive = LiveDevelopment.isLPEditFeaturesActive; + + // If user has edit mode saved but edit features are not active, default to highlight + let effectiveMode = savedMode; + if (savedMode === "edit" && !isEditFeaturesActive) { + effectiveMode = "highlight"; + // Update the preference to reflect the actual mode being used + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); + } + + // apply the effective mode + if (effectiveMode === "highlight") { + _LPHighlightMode(); + } else if (effectiveMode === "edit" && isEditFeaturesActive) { + _LPEditMode(); + } else { + _LPPreviewMode(); + } + + _updateModeButton(effectiveMode); + } + + function _showModeSelectionDropdown(event) { + const isEditFeaturesActive = LiveDevelopment.isLPEditFeaturesActive; + const items = [ + Strings.LIVE_PREVIEW_MODE_PREVIEW, + Strings.LIVE_PREVIEW_MODE_HIGHLIGHT, + Strings.LIVE_PREVIEW_MODE_EDIT + ]; + + // Only add edit highlight option if edit features are active + if (isEditFeaturesActive) { + items.push("---"); + items.push(Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON); + } + + const rawMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); + // this is to take care of invalid values in the pref file + const currentMode = ["preview", "highlight", "edit"].includes(rawMode) ? rawMode : _getDefaultMode(); + + const dropdown = new DropdownButton.DropdownButton("", items, function(item, index) { + if (item === Strings.LIVE_PREVIEW_MODE_PREVIEW) { + // using empty spaces to keep content aligned + return currentMode === "preview" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + } else if (item === Strings.LIVE_PREVIEW_MODE_HIGHLIGHT) { + return currentMode === "highlight" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + } else if (item === Strings.LIVE_PREVIEW_MODE_EDIT) { + const checkmark = currentMode === "edit" ? "✓ " : `${'\u00A0'.repeat(4)}`; + const crownIcon = !isEditFeaturesActive ? ' Pro' : ''; + return { + html: `${checkmark}${item}${crownIcon}`, + enabled: true + }; + } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { + const isHoverMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) !== "click"; + if(isHoverMode) { + return `✓ ${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; + } + return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; + } + return item; + }); + + // Append to document body for absolute positioning + $("body").append(dropdown.$button); + + // Position the dropdown at the mouse coordinates + dropdown.$button.css({ + position: "absolute", + left: event.pageX + "px", + top: event.pageY + "px", + zIndex: 1000 + }); + + // Add a custom class to override the max-height + dropdown.dropdownExtraClasses = "mode-context-menu"; + + dropdown.showDropdown(); + + $(".mode-context-menu").css("max-height", "300px"); + + // handle the option selection + dropdown.on("select", function (e, item, index) { + // here we just set the preference + // as the preferences listener will automatically handle the required changes + if (index === 0) { + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "preview"); + } else if (index === 1) { + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); + } else if (index === 2) { + if (!isEditFeaturesActive) { + // when the feature is not active we need to show a dialog to the user asking + // them to subscribe to pro + _showProFeatureDialog(); + } else { + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit"); + } + } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { + // Don't allow edit highlight toggle if edit features are not active + if (!isEditFeaturesActive) { + return; + } + // Toggle between hover and click + const currentMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT); + const newMode = currentMode !== "click" ? "click" : "hover"; + PreferencesManager.set(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, newMode); + return; // Don't dismiss highlights for this option + } + + // need to dismiss the previous highlighting and stuff + LiveDevelopment.hideHighlight(); + LiveDevelopment.dismissLivePreviewBoxes(); + }); + + // Remove the button after the dropdown is hidden + dropdown.$button.css({ + display: "none" + }); + } + function _getTrustProjectPage() { const trustProjectMessage = StringUtils.format(Strings.TRUST_PROJECT, path.basename(ProjectManager.getProjectRoot().fullPath)); @@ -287,20 +515,6 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "pinURLBtn", "click"); } - function _updateLiveHighlightToggleStatus() { - let isHighlightEnabled = _isLiveHighlightEnabled(); - if(isHighlightEnabled){ - $highlightBtn.removeClass('pointer-icon').addClass('pointer-fill-icon'); - } else { - $highlightBtn.removeClass('pointer-fill-icon').addClass('pointer-icon'); - } - } - - function _toggleLiveHighlights() { - LiveDevelopment.togglePreviewHighlight(); - Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "HighlightBtn", "click"); - } - const ALLOWED_BROWSERS_NAMES = [`chrome`, `firefox`, `safari`, `edge`, `browser`, `browserPrivate`]; function _popoutLivePreview(browserName) { // We cannot use $iframe.src here if panel is hidden @@ -372,8 +586,8 @@ define(function (require, exports, module) { Strings: Strings, livePreview: Strings.LIVE_DEV_STATUS_TIP_OUT_OF_SYNC, clickToReload: Strings.LIVE_DEV_CLICK_TO_RELOAD_PAGE, - toggleLiveHighlight: Strings.LIVE_DEV_TOGGLE_LIVE_HIGHLIGHT, livePreviewSettings: Strings.LIVE_DEV_SETTINGS, + livePreviewConfigureModes: Strings.LIVE_PREVIEW_CONFIGURE_MODES, clickToPopout: Strings.LIVE_DEV_CLICK_POPOUT, openInChrome: Strings.LIVE_DEV_OPEN_CHROME, openInSafari: Strings.LIVE_DEV_OPEN_SAFARI, @@ -388,7 +602,6 @@ define(function (require, exports, module) { $panel = $(Mustache.render(panelHTML, templateVars)); $iframe = $panel.find("#panel-live-preview-frame"); $pinUrlBtn = $panel.find("#pinURLButton"); - $highlightBtn = $panel.find("#highlightLPButton"); $reloadBtn = $panel.find("#reloadLivePreviewButton"); $livePreviewPopBtn = $panel.find("#livePreviewPopoutButton"); $chromeButton = $panel.find("#chromeButton"); @@ -402,6 +615,7 @@ define(function (require, exports, module) { $firefoxButtonBallast = $panel.find("#firefoxButtonBallast"); $panelTitle = $panel.find("#panel-live-preview-title"); $settingsIcon = $panel.find("#livePreviewSettingsBtn"); + $modeBtn = $panel.find("#livePreviewModeBtn"); $panel.find(".live-preview-settings-banner-btn").on("click", ()=>{ CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS); @@ -434,6 +648,9 @@ define(function (require, exports, module) { $firefoxButton.on("click", ()=>{ _popoutLivePreview("firefox"); }); + + $modeBtn.on("click", _showModeSelectionDropdown); + _showOpenBrowserIcons(); $settingsIcon.click(()=>{ CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS); @@ -456,9 +673,7 @@ define(function (require, exports, module) { PANEL_MIN_SIZE, $icon, INITIAL_PANEL_SIZE); WorkspaceManager.recomputeLayout(false); - _updateLiveHighlightToggleStatus(); $pinUrlBtn.click(_togglePinUrl); - $highlightBtn.click(_toggleLiveHighlights); $livePreviewPopBtn.click(_popoutLivePreview); $reloadBtn.click(()=>{ _loadPreview(true, true); @@ -828,9 +1043,45 @@ define(function (require, exports, module) { fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "", Menus.AFTER, Commands.FILE_LIVE_FILE_PREVIEW); fileMenu.addMenuDivider(Menus.BEFORE, Commands.FILE_LIVE_FILE_PREVIEW); + + // init live preview mode from saved preferences + _initializeMode(); + // listen for pref changes + PreferencesManager.on("change", PREFERENCE_LIVE_PREVIEW_MODE, function () { + // Get the current preference value directly + const newMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE); + const isEditFeaturesActive = LiveDevelopment.isLPEditFeaturesActive; + + // If user tries to set edit mode but edit features are not active, default to highlight + let effectiveMode = newMode; + if (newMode === "edit" && !isEditFeaturesActive) { + effectiveMode = "highlight"; + // Update the preference to reflect the actual mode being used + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); + return; // Return to avoid infinite loop + } + + if (effectiveMode === "highlight") { + _LPHighlightMode(); + } else if (effectiveMode === "edit" && isEditFeaturesActive) { + _LPEditMode(); + } else { + _LPPreviewMode(); + } + + _updateModeButton(effectiveMode); + }); + + // Handle element highlight preference changes from this extension + PreferencesManager.on("change", PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, function() { + LiveDevelopment.updateElementHighlightConfig(); + }); + + // Initialize element highlight config on startup + LiveDevelopment.updateElementHighlightConfig(); + LiveDevelopment.openLivePreview(); LiveDevelopment.on(LiveDevelopment.EVENT_OPEN_PREVIEW_URL, _openLivePreviewURL); - LiveDevelopment.on(LiveDevelopment.EVENT_LIVE_HIGHLIGHT_PREF_CHANGED, _updateLiveHighlightToggleStatus); LiveDevelopment.on(LiveDevelopment.EVENT_LIVE_PREVIEW_RELOAD, ()=>{ // Usually, this event is listened by live preview iframes/tabs and they initiate a location.reload. // But in firefox, the embedded iframe will throw a 404 when we try to reload from within the iframe as diff --git a/src/extensionsIntegrated/Phoenix-live-preview/panel.html b/src/extensionsIntegrated/Phoenix-live-preview/panel.html index 9ecf95e8fa..39f63c672f 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/panel.html +++ b/src/extensionsIntegrated/Phoenix-live-preview/panel.html @@ -2,7 +2,7 @@
- +
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 1155d661b3..1bfb324427 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -179,7 +179,26 @@ define({ "LIVE_DEV_SETTINGS_FRAMEWORK": "Server Framework", "LIVE_DEV_SETTINGS_FRAMEWORK_CUSTOM": "Custom", "LIVE_DEV_SETTINGS_FRAMEWORK_PREFERENCES": "Server Framework, currently supports only docusaurus", + "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT": "Show Live Preview Element Highlights on:", + "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_HOVER": "hover", + "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_CLICK": "click", + "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE": "show live preview element highlights on 'hover' or 'click'. Defaults to 'hover'", + "LIVE_DEV_MORE_OPTIONS_SELECT_PARENT": "Select Parent", + "LIVE_DEV_MORE_OPTIONS_EDIT_TEXT": "Edit Text", + "LIVE_DEV_MORE_OPTIONS_DUPLICATE": "Duplicate", + "LIVE_DEV_MORE_OPTIONS_DELETE": "Delete", + "LIVE_DEV_MORE_OPTIONS_AI": "Edit with AI", + "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_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", + "LIVE_PREVIEW_PRO_FEATURE_MESSAGE": "This is a Pro feature. Subscribe to Phoenix Pro to keep using this feature.", + "LIVE_PREVIEW_PRO_SUBSCRIBE": "Subscribe", "LIVE_DEV_DETACHED_REPLACED_WITH_DEVTOOLS": "Live Preview was canceled because the browser's developer tools were opened", "LIVE_DEV_DETACHED_TARGET_CLOSED": "Live Preview was canceled because the page was closed in the browser", @@ -328,7 +347,7 @@ define({ "SPLITVIEW_MENU_TOOLTIP": "Split the editor vertically or horizontally", "GEAR_MENU_TOOLTIP": "Configure Working Set", - "CMD_TOGGLE_SHOW_WORKING_SET": "Show Working Set", + "CMD_TOGGLE_SHOW_WORKING_SET": "Show Working Files", "CMD_TOGGLE_SHOW_FILE_TABS": "Show File Tab Bar", "SPLITVIEW_INFO_TITLE": "Already Open",