diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000..7e257db92b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": []
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..2cec770133
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+ "github.copilot.chat.virtualTools.threshold": 0,
+ "githubPullRequests.ignoredPullRequestBranches": [
+ "main"
+ ]
+}
\ No newline at end of file
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000000..5db72dd6a9
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:recommended"
+ ]
+}
diff --git a/src/LiveDevelopment/BrowserScripts/.vscode/extensions.json b/src/LiveDevelopment/BrowserScripts/.vscode/extensions.json
new file mode 100644
index 0000000000..7e257db92b
--- /dev/null
+++ b/src/LiveDevelopment/BrowserScripts/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": []
+}
\ No newline at end of file
diff --git a/src/LiveDevelopment/BrowserScripts/.vscode/settings.json b/src/LiveDevelopment/BrowserScripts/.vscode/settings.json
new file mode 100644
index 0000000000..824d987ddf
--- /dev/null
+++ b/src/LiveDevelopment/BrowserScripts/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "C_Cpp.autocompleteAddParentheses": true
+}
\ No newline at end of file
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..0e8c0750b6 100644
--- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
+++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
@@ -30,23 +30,20 @@
*/
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);
};
@@ -63,26 +60,25 @@ function RemoteFunctions(config) {
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 +109,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 +121,796 @@ 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 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);
+ }
+ }
+
+ function _dragStartChores(element) {
+ element._originalDragOpacity = element.style.opacity;
+ element.style.opacity = 0.3;
+ }
+
+
+ function _dragEndChores(element) {
+ if (element._originalDragOpacity) {
+ element.style.opacity = element._originalDragOpacity;
+ } else {
+ element.style.opacity = 1;
+ }
+ delete element._originalDragOpacity;
+ }
+
+ // CSS class name for drop markers
+ let DROP_MARKER_CLASSNAME = "__brackets-drop-marker";
+
+ /**
+ * This function creates a marker to indicate a valid drop position
+ * @param {DOMElement} element - The element where the drop is possible
+ * @param {Boolean} showAtBottom - Whether to show the marker at the bottom of the element
+ */
+ function _createDropMarker(element, showAtBottom) {
+ // clean any existing marker from that element
+ _removeDropMarkerFromElement(element);
+
+ // create the marker element
+ let marker = window.document.createElement("div");
+ marker.className = DROP_MARKER_CLASSNAME;
+
+ // position the marker at the top or bottom of the element
+ let rect = element.getBoundingClientRect();
+ let scrollTop = window.pageYOffset || document.documentElement.scrollTop;
+ let scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
+
+ // marker styling
+ marker.style.position = "absolute";
+ marker.style.left = (rect.left + scrollLeft) + "px";
+ marker.style.width = rect.width + "px";
+ marker.style.height = "2px";
+ marker.style.backgroundColor = "#4285F4";
+ marker.style.zIndex = "2147483646";
+
+ // position the marker at the top or at the bottom of the element
+ if (showAtBottom) {
+ marker.style.top = (rect.bottom + scrollTop + 3) + "px";
+ } else {
+ marker.style.top = (rect.top + scrollTop - 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() {
+ let markers = window.document.querySelectorAll("." + DROP_MARKER_CLASSNAME);
+ for (let i = 0; i < markers.length; i++) {
+ if (markers[i].parentNode) {
+ markers[i].parentNode.removeChild(markers[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;
+ }
+ }
+
+ /**
+ * Handle dragover events on the document
+ * 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;
+ }
+
+ // check if the cursor is in the top half or bottom half of the target element
+ const rect = target.getBoundingClientRect();
+ const middleY = rect.top + (rect.height / 2);
+ const showAtBottom = event.clientY > middleY;
+
+ // before creating a drop marker, make sure that we clear all the drop markers
+ _clearDropMarkers();
+ _createDropMarker(target, showAtBottom);
+ }
+
+ /**
+ * 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) {
+ return;
+ }
+
+ // Skip BODY, HTML tags and elements inside HEAD
+ if (target.tagName === "BODY" || target.tagName === "HTML" || _isInsideHeadTag(target)) {
+ return;
+ }
+
+ // check if the cursor is in the top half or bottom half of the target element
+ const rect = target.getBoundingClientRect();
+ const middleY = rect.top + (rect.height / 2);
+ const insertAfter = event.clientY > middleY;
+
+ // IDs of the source and target elements
+ const sourceId = window._currentDraggedElement.getAttribute("data-brackets-id");
+ const targetId = target.getAttribute("data-brackets-id");
+
+ // send message to the editor
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ sourceElement: window._currentDraggedElement,
+ targetElement: target,
+ sourceId: Number(sourceId),
+ targetId: Number(targetId),
+ insertAfter: insertAfter,
+ move: true
+ });
+
+ _clearDropMarkers();
+ _dragEndChores(window._currentDraggedElement);
+ dismissMoreOptionsBox();
+ delete window._currentDraggedElement;
+ }
+
+ // calc estimate width based on the char count
+ const infoBoxWidth = basePadding + (charCount * avgCharWidth);
+
+ // 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;
- _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();
+ 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;
+ });
+
+ this.element.addEventListener("dragend", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ _dragEndChores(this.element);
+ _clearDropMarkers();
+ delete window._currentDraggedElement;
+ });
},
- createBody: function () {
- if (this.body) {
- return;
+ _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 (offset.top - boxHeight < 0) {
+ topPos = offset.top + elemBounds.height + 6;
+ }
+
+ // Check if the box would go off the left of the viewport
+ if (leftPos < 0) {
+ leftPos = offset.left;
}
- // compute the position on screen
- var offset = _screenOffset(this.element),
- x = offset.left,
- y = offset.top + this.element.offsetHeight;
+ return {topPos: topPos, leftPos: leftPos};
+ },
- // create the container
+ _style: function() {
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");
- },
- 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);
+ // 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 = {
+ arrowUp: `
+
+ `,
+
+ edit: `
+
+ `,
+
+ duplicate: `
+
+ `,
+
+ trash: `
+
+ `
+ };
+
+ let content = `
`;
+
+ // 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}
+
+
";
+ }
+
+ // 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 = `
+ .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';
+
+ if(this.isFromClick) {
+ 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;
}
}
};
@@ -273,7 +940,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 +953,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 +976,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 +1038,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 +1083,7 @@ function RemoteFunctions(config) {
highlight.appendChild(el);
}
};
-
+
setupVisualisations(
marginVisualisations,
config.remoteHighlight.marginStyling
@@ -425,11 +1092,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 +1115,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 +1160,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,26 +1212,16 @@ 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 _setup = false;
@@ -570,7 +1229,11 @@ function RemoteFunctions(config) {
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 +1248,201 @@ 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) {
+ // 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
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ }
+ // check if this element is already clicked (has more options box)
+ // this is needed so that we can check for overlapping issue among the boxes
+ const isAlreadyClicked = previouslyClickedElement === event.target && _nodeMoreOptionsBox !== null;
+ _nodeInfoBox = new NodeInfoBox(event.target, isAlreadyClicked);
+ }
+ }
+ }
+
+ function onElementHoverOut(event) {
+ // 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
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = null;
+ }
+ }
+ }
+
+ /**
+ * this function is responsible to select an element in the live preview
+ * @param {Element} element - The DOM element to select
+ */
+ function _selectElement(element) {
+ // 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;
+ }
+
+ if (_nodeMoreOptionsBox) {
+ _nodeMoreOptionsBox.remove();
+ _nodeMoreOptionsBox = null;
+ }
+
+ // to remove the outline styling from the previously clicked element
+ if (previouslyClickedElement) {
+ if (previouslyClickedElement._originalOutline !== undefined) {
+ previouslyClickedElement.style.outline = previouslyClickedElement._originalOutline;
+ } else {
+ previouslyClickedElement.style.outline = "";
+ }
+ delete previouslyClickedElement._originalOutline;
+
+ // Remove highlighting from previously clicked element
+ if (getHighlightMode() === "click") {
+ clearElementBackground(previouslyClickedElement);
+ }
+ }
+
+ // make sure that the element is actually visible to the user
+ if (isElementVisible(element)) {
+ _nodeMoreOptionsBox = new NodeMoreOptionsBox(element);
+
+ // show the info box when a DOM element is selected
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ }
+ _nodeInfoBox = new NodeInfoBox(element, true); // true means that the element was selected
+ } else {
+ // Element is hidden, so don't show UI boxes but still apply visual styling
+ _nodeMoreOptionsBox = null;
+
+ // Remove any existing info box since the element is not visible
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = 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)) {
+ // 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))
+ ) {
+ dismissMoreOptionsBox();
+ }
+ }
+
+ /**
+ * 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 +1453,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 +1460,9 @@ function RemoteFunctions(config) {
}
function onKeyDown(event) {
+ if ((event.key === "Escape" || event.key === "Esc")) {
+ dismissMoreOptionsBox();
+ }
if (!_setup && _validEvent(event)) {
window.document.addEventListener("keyup", onKeyUp);
window.document.addEventListener("mouseover", onMouseOver);
@@ -624,56 +1476,95 @@ 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;
+ _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) {
+ dismissMoreOptionsBox();
+ }
+ }
+
+ // 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) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = new NodeInfoBox(element, true); // true means it came from a click
+ }
+ }
}
// redraw active highlights
function redrawHighlights() {
- if (_remoteHighlight) {
- _remoteHighlight.redraw();
+ if (_clickHighlight) {
+ _clickHighlight.redraw();
+ }
+ if (_hoverHighlight) {
+ _hoverHighlight.redraw();
}
}
- window.addEventListener("resize", redrawHighlights);
+ // just a wrapper function when we need to redraw highlights as well as UI boxes
+ function redrawEverything() {
+ redrawHighlights();
+ redrawUIBoxes();
+ }
+
+ window.addEventListener("resize", redrawEverything);
// Add a capture-phase scroll listener to update highlights when
// any element scrolls.
@@ -683,7 +1574,7 @@ function RemoteFunctions(config) {
if (e.target === window.document) {
redrawHighlights();
} else {
- if (_remoteHighlight || _localHighlight) {
+ if (_localHighlight || _clickHighlight || _hoverHighlight) {
window.setTimeout(redrawHighlights, 0);
}
}
@@ -942,71 +1833,252 @@ 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) {
+ // Add hover event listeners if highlight is enabled
+ 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 if highlight is disabled
+ window.document.removeEventListener("mouseover", onElementHover);
+ window.document.removeEventListener("mouseout", onElementHoverOut);
+
+ // Remove info box and more options box if highlight is disabled
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = null;
+ }
+ if (_nodeMoreOptionsBox) {
+ _nodeMoreOptionsBox.remove();
+ _nodeMoreOptionsBox = null;
+ }
+ }
+
+ // 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);
+ }
+
+ // Remove info box when switching modes to avoid confusion
+ if (_nodeInfoBox && !_nodeMoreOptionsBox) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = null;
+ }
+
+ // 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 _domElementToJSON(elem) {
- var json = { tag: elem.tagName.toLowerCase(), attributes: {}, children: [] },
- i,
- len,
- node,
- value;
+ function hasVisibleLivePreviewBoxes() {
+ return _nodeMoreOptionsBox !== null || _nodeInfoBox !== null || previouslyClickedElement !== null;
+ }
+
+ /**
+ * This function is responsible to remove the more options box
+ * we do this either when user presses the Esc key or clicks on the HTML or Body tags
+ * @return {boolean} true if any boxes were dismissed, false otherwise
+ */
+ function dismissMoreOptionsBox() {
+ let dismissed = false;
+
+ if (_nodeMoreOptionsBox) {
+ _nodeMoreOptionsBox.remove();
+ _nodeMoreOptionsBox = null;
+ dismissed = true;
+ }
- 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;
+ if (_nodeInfoBox) {
+ _nodeInfoBox.remove();
+ _nodeInfoBox = null;
+ dismissed = true;
}
- len = elem.childNodes.length;
- for (i = 0; i < len; i++) {
- node = elem.childNodes.item(i);
+ 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 });
+ // Clear click-mode highlighting
+ if (getHighlightMode() === "click") {
+ clearElementBackground(previouslyClickedElement);
+
+ if (_hoverHighlight) {
+ _hoverHighlight.clear();
+ }
}
+
+ previouslyClickedElement = null;
+ dismissed = true;
}
- return json;
+ return dismissed;
}
- function getSimpleDOM() {
- return JSON.stringify(_domElementToJSON(window.document.documentElement));
+ /**
+ * 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 updateConfig(newConfig) {
- config = JSON.parse(newConfig);
- return JSON.stringify(config);
+
+ // 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);
+ }
+
+ dismissMoreOptionsBox();
+
+ 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 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");
+ dismissMoreOptionsBox();
+
+ // 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() {
+ 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("keydown", onKeyDown);
+ }
}
+ 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,
+ "dismissMoreOptionsBox" : dismissMoreOptionsBox,
+ "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes,
+ "registerHandlers" : registerHandlers
};
}
diff --git a/src/LiveDevelopment/BrowserScripts/jsconfig.json b/src/LiveDevelopment/BrowserScripts/jsconfig.json
new file mode 100644
index 0000000000..c2ee42f13b
--- /dev/null
+++ b/src/LiveDevelopment/BrowserScripts/jsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "target": "ES2022",
+ "jsx": "react-jsx",
+ "allowImportingTsExtensions": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true
+ },
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/src/LiveDevelopment/BrowserScripts/utsavm9.c-cpp-flag-debugging-0.0.1.vsix b/src/LiveDevelopment/BrowserScripts/utsavm9.c-cpp-flag-debugging-0.0.1.vsix
new file mode 100644
index 0000000000..8d9396164f
Binary files /dev/null and b/src/LiveDevelopment/BrowserScripts/utsavm9.c-cpp-flag-debugging-0.0.1.vsix differ
diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js
index baeba8cbe3..976acda42b 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 more options box and info box
+ */
+ function dismissLivePreviewBoxes() {
+ if (_protocol) {
+ _protocol.evaluate("_LD.dismissMoreOptionsBox()");
+ }
+ }
+
+ /**
+ * 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..380c97efc2
--- /dev/null
+++ b/src/LiveDevelopment/LivePreviewEdit.js
@@ -0,0 +1,547 @@
+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) {
+ clonedElement.removeAttribute("data-brackets-id");
+
+ const children = clonedElement.querySelectorAll("[data-brackets-id]");
+ children.forEach(child => child.removeAttribute("data-brackets-id"));
+ }
+ 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;
+ }
+
+ /**
+ * 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 currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (!currLiveDoc || !currLiveDoc.editor || !message.tagId) {
+ return;
+ }
+
+ const editor = currLiveDoc.editor;
+
+ // 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, message.tagId);
+ if(!startRange) {
+ return;
+ }
+
+ const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from);
+ if (!endRange) {
+ return;
+ }
+
+ 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;
+
+ 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);
+ } 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 currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (!currLiveDoc) {
+ return;
+ }
+
+ const editor = currLiveDoc.editor;
+ if (!editor || !tagId) {
+ return;
+ }
+
+ // 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;
+ }
+
+ const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from);
+ if (!endRange) {
+ return;
+ }
+
+ 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;
+
+ // 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 currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (!currLiveDoc) {
+ return;
+ }
+
+ const editor = currLiveDoc.editor;
+ if (!editor || !tagId) {
+ return;
+ }
+
+ // 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;
+ }
+
+ const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from);
+ if (!endRange) {
+ return;
+ }
+
+ 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;
+
+ 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 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
+ */
+ function _moveElementInSource(sourceId, targetId, insertAfter) {
+ // this is to get the currently live document that is being served in the live preview
+ const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (!currLiveDoc) {
+ return;
+ }
+
+ const editor = currLiveDoc.editor;
+ if (!editor || !sourceId || !targetId) {
+ return;
+ }
+
+ // 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 sourceStartRange = HTMLInstrumentation.getPositionFromTagId(editor, sourceId);
+ if(!sourceStartRange) {
+ return;
+ }
+
+ const sourceEndRange = CodeMirror.findMatchingTag(editor._codeMirror, sourceStartRange.from);
+ if (!sourceEndRange) {
+ return;
+ }
+
+ const targetStartRange = HTMLInstrumentation.getPositionFromTagId(editor, targetId);
+ if(!targetStartRange) {
+ return;
+ }
+
+ const targetEndRange = CodeMirror.findMatchingTag(editor._codeMirror, targetStartRange.from);
+ if (!targetEndRange) {
+ return;
+ }
+
+ const sourceRange = {
+ from: sourceStartRange.from,
+ to: sourceEndRange.close ? sourceEndRange.close.to : sourceEndRange.open.to
+ };
+
+ const targetRange = {
+ from: targetStartRange.from,
+ to: targetEndRange.close ? targetEndRange.close.to : targetEndRange.open.to
+ };
+
+ const sourceText = editor.getTextBetween(sourceRange.from, sourceRange.to);
+ const targetIndent = editor.getTextBetween({ line: targetRange.from.line, ch: 0 }, targetRange.from);
+
+ // 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 =
+ sourceRange.from.line < targetRange.from.line ||
+ (sourceRange.from.line === targetRange.from.line && sourceRange.from.ch < targetRange.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 (insertAfter) {
+ const insertPos = {
+ line: targetRange.to.line,
+ ch: targetRange.to.ch
+ };
+ _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText);
+ } else {
+ // insert before target
+ _insertElementWithIndentation(editor, targetRange.from, false, targetIndent, sourceText);
+ }
+
+ // Now remove the source element (NOTE: the positions have shifted)
+ const updatedSourceStartRange = HTMLInstrumentation.getPositionFromTagId(editor, sourceId);
+ if (updatedSourceStartRange) {
+ const updatedSourceEndRange = CodeMirror.findMatchingTag(
+ editor._codeMirror, updatedSourceStartRange.from
+ );
+
+ if (updatedSourceEndRange) {
+ const updatedSourceRange = {
+ from: updatedSourceStartRange.from,
+ to: updatedSourceEndRange.close
+ ? updatedSourceEndRange.close.to
+ : updatedSourceEndRange.open.to
+ };
+ editor.replaceRange("", updatedSourceRange.from, updatedSourceRange.to);
+ _cleanupAfterRemoval(editor, updatedSourceRange);
+ }
+ }
+ } else {
+ // This handles the case when target is before source: remove first, then insert
+ // Store source range before removal
+ const originalSourceRange = { ...sourceRange };
+
+ // Remove the source element first
+ editor.replaceRange("", sourceRange.from, sourceRange.to);
+ _cleanupAfterRemoval(editor, originalSourceRange);
+
+ // Recalculate target range after source removal as the positions have shifted
+ const updatedTargetStartRange = HTMLInstrumentation.getPositionFromTagId(editor, targetId);
+ if (!updatedTargetStartRange) {
+ return;
+ }
+
+ const updatedTargetEndRange = CodeMirror.findMatchingTag(
+ editor._codeMirror, updatedTargetStartRange.from
+ );
+
+ if (!updatedTargetEndRange) {
+ return;
+ }
+
+ const updatedTargetRange = {
+ from: updatedTargetStartRange.from,
+ to: updatedTargetEndRange.close ? updatedTargetEndRange.close.to : updatedTargetEndRange.open.to
+ };
+
+ if (insertAfter) {
+ const insertPos = {
+ line: updatedTargetRange.to.line,
+ ch: updatedTargetRange.to.ch
+ };
+ _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText);
+ } else {
+ // Insert before target
+ _insertElementWithIndentation(editor, updatedTargetRange.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 currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (!currLiveDoc || !currLiveDoc.editor) {
+ return;
+ }
+
+ const editor = currLiveDoc.editor;
+
+ if (undoOrRedo === "undo") {
+ editor.undo();
+ } else if (undoOrRedo === "redo") {
+ editor.redo();
+ }
+ }
+
+ /**
+ * 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: true
+ undoLivePreviewOperation: true (this property is available only for undo operation)
+
+ 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);
+ 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);
+ }
+ }
+
+ 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..b3206978ca 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,7 +166,7 @@ define(function (require, exports, module) {
}
const allOpenFileCount = MainViewManager.getWorkingSetSize(MainViewManager.ALL_PANES);
function selectInHTMLEditor(fullHtmlEditor) {
- const position = HTMLInstrumentation.getPositionFromTagId(fullHtmlEditor, parseInt(tagId, 10));
+ const position = HTMLInstrumentation.getPositionFromTagId(fullHtmlEditor, parseInt(tagId, 10)).from;
if(position && fullHtmlEditor) {
const masterEditor = fullHtmlEditor.document._masterEditor || fullHtmlEditor;
masterEditor.setCursorPos(position.line, position.ch, true);
@@ -207,6 +208,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..4d2c095850 100644
--- a/src/LiveDevelopment/main.js
+++ b/src/LiveDevelopment/main.js
@@ -42,10 +42,17 @@ 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
+ let isLPEditFeaturesActive = true;
const EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = "liveHighlightPrefChange";
+ const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights";
+
var params = new UrlParams();
var config = {
experimental: false, // enable experimental features
@@ -57,6 +64,17 @@ 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
}
};
// Status labels/styles are ordered: error, not connected, progress1, progress2, connected.
@@ -79,14 +97,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 +255,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,11 +305,26 @@ 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));
}
});
+ // this function is responsible to update element highlight config
+ function updateElementHighlightConfig() {
+ const prefValue = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT);
+ config.elemHighlights = prefValue || "hover";
+ if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
+ MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
+ MultiBrowserLiveDev.registerHandlers();
+ }
+ }
+
+ PreferencesManager.on("change", PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, function() {
+ updateElementHighlightConfig();
+ });
+
MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL, function (event, previewDetails) {
+ updateElementHighlightConfig();
exports.trigger(exports.EVENT_OPEN_PREVIEW_URL, previewDetails);
});
MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE, function (event, {clientId}) {
@@ -293,6 +337,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,6 +347,9 @@ 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");
@@ -312,6 +362,9 @@ define(function main(require, exports, module) {
EventDispatcher.makeEventDispatcher(exports);
+ exports.isLPEditFeaturesActive = isLPEditFeaturesActive;
+ exports.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT;
+
// public events
exports.EVENT_OPEN_PREVIEW_URL = MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL;
exports.EVENT_CONNECTION_CLOSE = MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE;
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js
index d301b340d6..d5807c40de 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewSettings.js
@@ -41,6 +41,7 @@
define(function (require, exports, module) {
const livePreviewSettings = require("text!./livePreviewSettings.html"),
+ LiveDevelopmentMain = require("LiveDevelopment/main"),
Dialogs = require("widgets/Dialogs"),
ProjectManager = require("project/ProjectManager"),
Strings = require("strings"),
@@ -87,7 +88,10 @@ define(function (require, exports, module) {
description: Strings.LIVE_DEV_SETTINGS_FRAMEWORK_PREFERENCES,
values: Object.keys(SUPPORTED_FRAMEWORKS)
});
-
+ PreferencesManager.definePreference(LiveDevelopmentMain.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", "hover", {
+ description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE
+ });
+
async function detectFramework($frameworkSelect, $hotReloadChk) {
for(let framework of Object.keys(SUPPORTED_FRAMEWORKS)){
const configFile = SUPPORTED_FRAMEWORKS[framework].configFile,
@@ -130,11 +134,15 @@ define(function (require, exports, module) {
$hotReloadChk = $template.find("#hotReloadChk"),
$hotReloadLabel = $template.find("#hotReloadLabel"),
$frameworkLabel = $template.find("#frameworkLabel"),
- $frameworkSelect = $template.find("#frameworkSelect");
+ $frameworkSelect = $template.find("#frameworkSelect"),
+ $elementHighlights = $template.find("#elementHighlightWrapper"), // to show/hide this setting
+ $elementHighlight = $template.find("#elementHighlight"); // dropdown for highlight mode selection
+
+ // 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
+ $elementHighlight.val(PreferencesManager.get(LiveDevelopmentMain.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT));
if(PreferencesManager.get(PREFERENCE_PROJECT_PREVIEW_FRAMEWORK) === null) {
detectFramework($frameworkSelect, $hotReloadChk);
@@ -163,6 +171,12 @@ define(function (require, exports, module) {
$frameworkSelect.addClass("forced-hidden");
$frameworkLabel.addClass("forced-hidden");
}
+
+ if(LiveDevelopmentMain.isLPEditFeaturesActive) {
+ $elementHighlights.removeClass("forced-hidden");
+ } else {
+ $elementHighlights.addClass("forced-hidden");
+ }
}
$livePreviewServerURL.on("input", refreshValues);
@@ -176,6 +190,12 @@ define(function (require, exports, module) {
PreferencesManager.set(PREFERENCE_SHOW_LIVE_PREVIEW_PANEL, $showLivePreviewAtStartup.is(":checked"));
_saveProjectPreferences($enableCustomServerChk.is(":checked"), $livePreviewServerURL.val(),
$serveRoot.val(), $hotReloadChk.is(":checked"), $frameworkSelect.val());
+
+ // Save element highlight preference
+ PreferencesManager.set(
+ LiveDevelopmentMain.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT,
+ $elementHighlight.val()
+ );
}
resolve();
});
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/livePreviewSettings.html b/src/extensionsIntegrated/Phoenix-live-preview/livePreviewSettings.html
index ad3cd9aa89..75e708f340 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/livePreviewSettings.html
+++ b/src/extensionsIntegrated/Phoenix-live-preview/livePreviewSettings.html
@@ -1,33 +1,49 @@
-