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}
+
+
`;
+ 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 += "
";
+ }
+
+ // 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",