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} + +
`; + + const styles = ` + .phoenix-more-options-box { + background-color: #4285F4; + color: white; + border-radius: 3px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + font-size: 12px; + font-family: Arial, sans-serif; + z-index: 2147483647; + position: absolute; + left: -1000px; + top: -1000px; + box-sizing: border-box; + } + + .node-options { + display: flex; + align-items: center; + } + + .node-options span { + padding: 4px 3.9px; + cursor: pointer; + display: flex; + align-items: center; + border-radius: 0; + } + + .node-options span:first-child { + border-radius: 3px 0 0 3px; + } + + .node-options span:last-child { + border-radius: 0 3px 3px 0; + } + + .node-options span:hover { + background-color: rgba(255, 255, 255, 0.15); + } + + .node-options span > svg { + width: 16px; + height: 16px; + display: block; + } + `; + + // add everything to the shadow box + shadow.innerHTML = `
${content}
`; + this._shadow = shadow; }, - show: function () { - if (!this.body) { - this.createBody(); + 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; } - if (!this.body.parentNode) { - window.document.body.appendChild(this.body); + + 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'; } - window.document.addEventListener("click", this.remove); + + // 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); + this.remove(); + }); + }); + + this._registerDragDrop(); }, - remove: function () { - if (this.body && this.body.parentNode) { + 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; } - window.document.removeEventListener("click", this.remove); } - }; - function Editor(element) { - this.onBlur = this.onBlur.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); - + // Node info box to display DOM node ID and classes on hover + function NodeInfoBox(element, isFromClick) { this.element = element; - this.element.setAttribute("contenteditable", "true"); - this.element.focus(); - this.element.addEventListener("blur", this.onBlur); - this.element.addEventListener("keypress", this.onKeyPress); + this.isFromClick = isFromClick || false; + this.remove = this.remove.bind(this); + this.create(); + } - this.revertText = this.element.innerHTML; + 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; + }, - _trigger(this.element, "edit", 1); - } + _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; - 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); + if (offset.top - boxDimensions.height < 0) { + topPos = offset.top + elemBounds.height + 6; + } + + // 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}; }, - 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; + _style: function() { + this.body = window.document.createElement("div"); + + // this is shadow DOM. + // we need it because if we add the box directly to the DOM then users style might override it. + // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes + const shadow = this.body.attachShadow({ mode: "open" }); + + // get the ID and classes for that element, as we need to display it in the box + const id = this.element.id; + const classes = this.element.className ? this.element.className.split(/\s+/).filter(Boolean) : []; + + let content = ""; // this will hold the main content that will be displayed + content += "
" + this.element.tagName.toLowerCase() + "
"; // add element tag name + + // Add ID if present + if (id) { + content += "
#" + id + "
"; + } + + // Add classes (limit to 3 with dropdown indicator) + if (classes.length > 0) { + content += "
"; + for (var i = 0; i < Math.min(classes.length, 3); i++) { + content += "." + classes[i] + " "; + } + if (classes.length > 3) { + content += "+" + (classes.length - 3) + " more"; + } + content += "
"; + } + + // initially, we place our info box -1000px to the top but at the right left pos. this is done so that + // we can take the text-wrapping inside the info box in account when calculating the height + // after calculating the height of the box, we place it at the exact position above the element + const offset = _screenOffset(this.element); + const leftPos = offset.left; + + const styles = ` + .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 @@ -