diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index 72b95696fb..9b62b6b669 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -363,7 +363,7 @@ ProtocolManager.enable(); }); - function _getAllInheritedSelectorsInOrder(element) { + function getAllInheritedSelectorsInOrder(element) { let selectorsFound= new Map(); const selectorsList = []; while (element) { @@ -383,6 +383,7 @@ return selectorsList; } + global.getAllInheritedSelectorsInOrder = getAllInheritedSelectorsInOrder; /** * Sends the message containing tagID which is being clicked @@ -407,7 +408,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true, "edit": true @@ -431,7 +432,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true }); diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 0b7beceaed..93beb53f35 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -33,6 +33,9 @@ function RemoteFunctions(config = {}) { // we need this so that we can remove click styling from the previous element when a new element is clicked let previouslyClickedElement = null; + // we store references to interaction blocker event handlers so we can remove them when switching modes + let _interactionBlockerHandlers = null; + var req, timeout; var animateHighlight = function (time) { if(req) { @@ -312,6 +315,16 @@ function RemoteFunctions(config = {}) { }); } + /** + * This function gets called when the edit hyperlink button is clicked + * @param {Event} event + * @param {DOMElement} element - the HTML link element + */ + function _handleEditHyperlinkOptionClick(event, element) { + dismissHyperlinkEditor(); + _hyperlinkEditor = new HyperlinkEditor(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 @@ -355,6 +368,70 @@ function RemoteFunctions(config = {}) { } } + /** + * this is for cut button, when user clicks on cut button we copy the element's source code + * into the clipboard and remove it from the src code. read `_cutElementToClipboard` in `LivePreviewEdit.js` + * @param {Event} event + * @param {DOMElement} element - the element we need to cut + */ + function _handleCutOptionClick(event, element) { + if (isElementEditable(element)) { + const tagId = element.getAttribute("data-brackets-id"); + + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + element: element, + event: event, + tagId: Number(tagId), + cut: true + }); + } else { + console.error("The TagID might be unavailable or the element tag is directly body or html"); + } + } + + /** + * this is for copy button, similar to cut just we don't remove the elements source code + * @param {Event} event + * @param {DOMElement} element + */ + function _handleCopyOptionClick(event, element) { + if (isElementEditable(element)) { + const tagId = element.getAttribute("data-brackets-id"); + + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + element: element, + event: event, + tagId: Number(tagId), + copy: true + }); + } else { + console.error("The TagID might be unavailable or the element tag is directly body or html"); + } + } + + /** + * this is for paste button, this inserts the saved content from clipboard just above this element + * @param {Event} event + * @param {DOMElement} targetElement + */ + function _handlePasteOptionClick(event, targetElement) { + if (isElementEditable(targetElement)) { + const targetTagId = targetElement.getAttribute("data-brackets-id"); + + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + element: targetElement, + event: event, + tagId: Number(targetTagId), + paste: 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 @@ -393,10 +470,18 @@ function RemoteFunctions(config = {}) { _handleSelectParentOptionClick(e, element); } else if (action === "edit-text") { startEditing(element); + } else if (action === "edit-hyperlink") { + _handleEditHyperlinkOptionClick(e, element); } else if (action === "duplicate") { _handleDuplicateOptionClick(e, element); } else if (action === "delete") { _handleDeleteOptionClick(e, element); + } else if (action === "cut") { + _handleCutOptionClick(e, element); + } else if (action === "copy") { + _handleCopyOptionClick(e, element); + } else if (action === "paste") { + _handlePasteOptionClick(e, element); } else if (action === "ai") { _handleAIOptionClick(e, element); } else if (action === "image-gallery") { @@ -1357,6 +1442,30 @@ function RemoteFunctions(config = {}) { `, + cut: ` + + + + `, + + copy: ` + + + + `, + + paste: ` + + + + `, + + ruler: ` + + + + `, + imageGallery: ` @@ -1398,6 +1507,18 @@ function RemoteFunctions(config = {}) { + `, + + verticalEllipsis: ` + + + + `, + + link: ` + + + ` }; @@ -1495,6 +1616,13 @@ function RemoteFunctions(config = {}) { `; } + // if its a link element, we show the edit hyperlink icon + if (this.element && this.element.tagName.toLowerCase() === 'a') { + content += ` + ${ICONS.link} + `; + } + // if its an image element, we show the image gallery icon if (this.element && this.element.tagName.toLowerCase() === 'img') { content += ` @@ -1509,6 +1637,9 @@ function RemoteFunctions(config = {}) { ${ICONS.trash} + + ${ICONS.verticalEllipsis} + `; let styles = ` @@ -1566,7 +1697,11 @@ function RemoteFunctions(config = {}) { if (imageGallerySelected) { styles += ` .node-options span[data-action="image-gallery"] { - background-color: rgba(255, 255, 255, 0.25) !important; + background-color: rgba(50, 50, 220, 0.5) !important; + } + + .node-options span[data-action="image-gallery"]:hover { + background-color: rgba(100, 100, 230, 0.6) !important; } `; } @@ -1607,11 +1742,22 @@ function RemoteFunctions(config = {}) { span.addEventListener('click', (event) => { event.stopPropagation(); event.preventDefault(); - // data-action is to differentiate between the buttons (duplicate, delete or select-parent) + // data-action is to differentiate between the buttons (duplicate, delete, select-parent etc) const action = event.currentTarget.getAttribute('data-action'); - handleOptionClick(event, action, this.element); - if (action !== 'duplicate') { - this.remove(); + + if (action === 'more-options') { + // to toggle the dropdown on more options button click + if (_moreOptionsDropdown) { + _moreOptionsDropdown.remove(); + } else { + _moreOptionsDropdown = new MoreOptionsDropdown(this.element, event.currentTarget); + } + } else { + handleOptionClick(event, action, this.element); + // as we don't want to remove the options box on duplicate button click + if (action !== 'duplicate') { + this.remove(); + } } }); }); @@ -1628,6 +1774,445 @@ function RemoteFunctions(config = {}) { } }; + /** + * This shows a floating input box above the element which allows you to edit the link of the 'a' tag + */ + function HyperlinkEditor(element) { + this.element = element; + this.remove = this.remove.bind(this); + this.create(); + } + + HyperlinkEditor.prototype = { + create: function() { + const currentHref = this.element.getAttribute('href') || ''; + + // Create shadow DOM container + this.body = document.createElement('div'); + this.body.setAttribute('data-phcode-internal-c15r5a9', '1'); + document.body.appendChild(this.body); + + const shadow = this.body.attachShadow({ mode: 'open' }); + + // Create input HTML + styles + const html = ` + + + `; + + shadow.innerHTML = html; + this._shadow = shadow; + + this._positionInput(); + + // setup the event listeners + const input = shadow.querySelector('input'); + input.focus(); + input.select(); + + input.addEventListener('keydown', (e) => this._handleKeydown(e)); + input.addEventListener('blur', () => this._handleBlur()); + }, + + _positionInput: function() { + const inputBoxElement = this._shadow.querySelector('.hyperlink-input-box'); + if (!inputBoxElement) { + return; + } + + const boxRect = inputBoxElement.getBoundingClientRect(); + const elemBounds = this.element.getBoundingClientRect(); + const offset = _screenOffset(this.element); + + let topPos = offset.top - boxRect.height - 6; + let leftPos = offset.left + elemBounds.width - boxRect.width; + + // If would go off top, position below + if (elemBounds.top - boxRect.height < 6) { + topPos = offset.top + elemBounds.height + 6; + } + + // If would go off left, align left + if (leftPos < 0) { + leftPos = offset.left; + } + + inputBoxElement.style.left = leftPos + 'px'; + inputBoxElement.style.top = topPos + 'px'; + }, + + _handleKeydown: function(event) { + if (event.key === 'Enter') { + event.preventDefault(); + this._save(); + } else if (event.key === 'Escape') { + event.preventDefault(); + dismissHyperlinkEditor(); + } + }, + + _handleBlur: function() { + setTimeout(() => this._save(), 100); + }, + + _save: function() { + const input = this._shadow.querySelector('input'); + const newHref = input.value.trim(); + const oldHref = this.element.getAttribute('href') || ''; + + if (newHref !== oldHref) { + this.element.setAttribute('href', newHref); + + const tagId = this.element.getAttribute('data-brackets-id'); + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + livePreviewHyperlinkEdit: true, + element: this.element, + tagId: Number(tagId), + newHref: newHref + }); + } + + this.remove(); + }, + + remove: function() { + if (this.body && this.body.parentNode) { + this.body.parentNode.removeChild(this.body); + this.body = null; + } + } + }; + + /** + * this is called when user clicks on the Show Ruler lines option in the more options dropdown + * @param {Event} event - click event + * @param {MoreOptionsDropdown} dropdown - the dropdown instance + */ + function _handleToggleRulerLines(event, dropdown) { + config.showRulerLines = !config.showRulerLines; + + window._Brackets_MessageBroker.send({ + livePreviewEditEnabled: true, + type: "toggleRulerLines", + enabled: config.showRulerLines + }); + + // toggle checkmark visibility in the dropdown + const checkmark = dropdown._shadow.querySelector('[data-action="toggle-ruler-lines"] .item-checkmark'); + if (checkmark) { + checkmark.style.visibility = config.showRulerLines ? 'visible' : 'hidden'; + } + + // to apply the ruler lines or remove it when option is toggled + if (config.showRulerLines && previouslyClickedElement) { + if (!_currentRulerLines) { + _currentRulerLines = new RulerLines(previouslyClickedElement); + } + } else { + if (_currentRulerLines) { + _currentRulerLines.remove(); + _currentRulerLines = null; + } + } + } + + /** + * the more options dropdown which appears when user clicks on the ellipsis button in the options box + */ + function MoreOptionsDropdown(targetElement, ellipsisButton) { + this.targetElement = targetElement; + this.ellipsisButton = ellipsisButton; + this.remove = this.remove.bind(this); + this.create(); + } + + MoreOptionsDropdown.prototype = { + _getDropdownPosition: function(dropdownWidth, dropdownHeight) { + const buttonBounds = this.ellipsisButton.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + + const optionsBox = _nodeMoreOptionsBox._shadow.querySelector(".phoenix-more-options-box"); + const optionsBoxBounds = optionsBox.getBoundingClientRect(); + const targetElementBounds = this.targetElement.getBoundingClientRect(); + + let topPos, leftPos; + + const checkOverlap = function (dTop, dLeft, dWidth, dHeight) { + const dropdownRight = dLeft + dWidth; + const dropdownBottom = dTop + dHeight; + const elemRight = targetElementBounds.left + targetElementBounds.width; + const elemBottom = targetElementBounds.top + targetElementBounds.height; + + return !( + dLeft > elemRight || + dropdownRight < targetElementBounds.left || + dTop > elemBottom || + dropdownBottom < targetElementBounds.top + ); + }; + + const isOptionsBoxAboveElement = optionsBoxBounds.bottom < targetElementBounds.top; + + if (isOptionsBoxAboveElement) { + const spaceAbove = optionsBoxBounds.top; + + if (spaceAbove >= dropdownHeight + 6) { + topPos = optionsBoxBounds.top + window.pageYOffset - dropdownHeight - 6; + } else { + topPos = optionsBoxBounds.bottom + window.pageYOffset + 6; + + const tempTop = optionsBoxBounds.bottom; + const tempLeft = buttonBounds.right - dropdownWidth; + + if (checkOverlap(tempTop, tempLeft, dropdownWidth, dropdownHeight)) { + let shiftedLeft = targetElementBounds.right + 6; + if (shiftedLeft + dropdownWidth <= viewportWidth - 6) { + leftPos = shiftedLeft + window.pageXOffset; + return { topPos: topPos, leftPos: leftPos }; + } + + shiftedLeft = targetElementBounds.left - dropdownWidth - 6; + if (shiftedLeft >= 6) { + leftPos = shiftedLeft + window.pageXOffset; + return { topPos: topPos, leftPos: leftPos }; + } + } + } + } else { + const spaceBelow = viewportHeight - optionsBoxBounds.bottom; + + if (spaceBelow >= dropdownHeight + 6) { + topPos = optionsBoxBounds.bottom + window.pageYOffset + 6; + } else { + topPos = optionsBoxBounds.top + window.pageYOffset - dropdownHeight - 6; + } + } + + leftPos = buttonBounds.right + window.pageXOffset - dropdownWidth; + + if (leftPos < 6) { + leftPos = 6; + } + + if (leftPos + dropdownWidth > viewportWidth - 6) { + leftPos = viewportWidth - dropdownWidth - 6; + } + + return { topPos: topPos, leftPos: leftPos }; + }, + + _style: function() { + this.body = window.document.createElement("div"); + this.body.setAttribute("data-phcode-internal-c15r5a9", "true"); + + const shadow = this.body.attachShadow({ mode: "open" }); + + let content = ` +
+ + + + + +
+ `; + + let styles = ` + :host { + all: initial !important; + } + + .phoenix-dropdown { + background-color: #2c2c2c !important; + color: #cdcdcd !important; + border: 1px solid #4a4a4a !important; + border-radius: 6px !important; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important; + font-size: 13px !important; + font-family: Arial, sans-serif !important; + z-index: 2147483647 !important; + position: absolute !important; + left: -1000px; + top: -1000px; + box-sizing: border-box !important; + min-width: 130px !important; + padding: 3px 0 !important; + overflow: hidden !important; + } + + .more-options-dropdown { + display: flex !important; + flex-direction: column !important; + } + + .dropdown-item { + padding: 7px 14px !important; + cursor: pointer !important; + white-space: nowrap !important; + user-select: none !important; + display: flex !important; + align-items: center !important; + gap: 6px !important; + } + + .dropdown-item:hover { + background-color: #3c3f41 !important; + } + + .item-icon { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 16px !important; + height: 16px !important; + flex-shrink: 0 !important; + } + + .item-icon svg { + width: 16px !important; + height: 16px !important; + display: block !important; + } + + .item-label { + flex: 1 !important; + } + + .dropdown-separator { + height: 1px !important; + background-color: #4a4a4a !important; + margin: 2px 0 !important; + } + + .item-checkmark { + margin-left: auto !important; + padding-left: 4px !important; + font-size: 14px !important; + } + `; + + shadow.innerHTML = `
${content}
`; + this._shadow = shadow; + }, + + create: function() { + this.remove(); + this._style(); + window.document.body.appendChild(this.body); + + // to position the dropdown element at the right position + const dropdownElement = this._shadow.querySelector('.phoenix-dropdown'); + if (dropdownElement) { + const dropdownRect = dropdownElement.getBoundingClientRect(); + const pos = this._getDropdownPosition(dropdownRect.width, dropdownRect.height); + + dropdownElement.style.left = pos.leftPos + 'px'; + dropdownElement.style.top = pos.topPos + 'px'; + } + + // click handlers for the dropdown items + const items = this._shadow.querySelectorAll('.dropdown-item'); + items.forEach(item => { + item.addEventListener('click', (event) => { + event.stopPropagation(); + event.preventDefault(); + const action = event.currentTarget.getAttribute('data-action'); + + if (action === 'toggle-ruler-lines') { + // when ruler lines option is clicked we need to keep the dropdown open + _handleToggleRulerLines(event, this); + } else { + // for other options, we close both the dropdown as well as the options box + handleOptionClick(event, action, this.targetElement); + this.remove(); + if (_nodeMoreOptionsBox) { + _nodeMoreOptionsBox.remove(); + } + } + }); + }); + }, + + remove: function() { + if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) { + window.document.body.removeChild(this.body); + this.body = null; + _moreOptionsDropdown = null; + } + }, + + refresh: function() { + // update the checkmark visibility when config changes + const checkmark = this._shadow.querySelector('[data-action="toggle-ruler-lines"] .item-checkmark'); + if (checkmark) { + checkmark.style.visibility = config.showRulerLines ? 'visible' : 'hidden'; + } + } + }; + // Node info box to display DOM node ID and classes on hover function NodeInfoBox(element) { this.element = element; @@ -1784,6 +2369,20 @@ function RemoteFunctions(config = {}) { content += ""; } + // for 'a' tags we also show the href + if (this.element.tagName.toLowerCase() === 'a') { + let href = this.element.getAttribute('href'); + if (href && href.trim()) { + let displayHref = href.trim(); + + // this is just to safeguard from very large URLs. 35 char limit should be fine + if (displayHref.length > 35) { + displayHref = displayHref.substring(0, 35) + '...'; + } + content += `
${ICONS.link} ${displayHref}
`; + } + } + // 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 @@ -1834,10 +2433,25 @@ function RemoteFunctions(config = {}) { } .id-name, - .class-name { + .class-name, + .href-info { margin-top: 3px !important; } + .href-info { + display: flex !important; + align-items: center !important; + gap: 6px !important; + opacity: 0.9 !important; + letter-spacing: 0.6px !important; + } + + .href-info svg { + width: 13px !important; + height: 13px !important; + flex-shrink: 0 !important; + } + .exceeded-classes { opacity: 0.8 !important; } @@ -3562,7 +4176,7 @@ function RemoteFunctions(config = {}) { "top": offset.top + "px", "width": elementBounds.width + "px", "height": elementBounds.height + "px", - "z-index": 2147483646, + "z-index": 2147483645, "margin": 0, "padding": 0, "position": "absolute", @@ -3661,17 +4275,142 @@ function RemoteFunctions(config = {}) { } }; + /** + * Ruler lines class, this creates the rulers across the edges of the element (hori as well as vert) + */ + function RulerLines(element) { + this.element = element; + this.lineElements = { + left: null, + right: null, + top: null, + bottom: null + }; + // gray color for non-editable elements, blue for editable + this.color = element.hasAttribute("data-brackets-id") + ? "rgba(66, 133, 244, 0.4)" + : "rgba(60, 63, 65, 0.8)"; + this.create(); + this.update(); + } + + RulerLines.prototype = { + create: function() { + let body = window.document.body; + + this.lineElements.left = window.document.createElement("div"); + this.lineElements.right = window.document.createElement("div"); + this.lineElements.top = window.document.createElement("div"); + this.lineElements.bottom = window.document.createElement("div"); + + this.lineElements.left.setAttribute("data-phcode-internal-c15r5a9", "true"); + this.lineElements.right.setAttribute("data-phcode-internal-c15r5a9", "true"); + this.lineElements.top.setAttribute("data-phcode-internal-c15r5a9", "true"); + this.lineElements.bottom.setAttribute("data-phcode-internal-c15r5a9", "true"); + + let applyStyles = function (element, color) { + element.style.position = "absolute"; + element.style.backgroundColor = color; + element.style.pointerEvents = "none"; + element.style.zIndex = "2147483645"; + }; + + applyStyles(this.lineElements.left, this.color); + applyStyles(this.lineElements.right, this.color); + applyStyles(this.lineElements.top, this.color); + applyStyles(this.lineElements.bottom, this.color); + + body.appendChild(this.lineElements.left); + body.appendChild(this.lineElements.right); + body.appendChild(this.lineElements.top); + body.appendChild(this.lineElements.bottom); + }, + + update: function() { + if (!this.element) { + return; + } + + let rect = this.element.getBoundingClientRect(); + let scrollTop = window.pageYOffset; + let scrollLeft = window.pageXOffset; + + var edges = { + // 0.8 is to fix pixel diff between ruler and outline (ruler is part of box model but outline is not) + left: rect.left + scrollLeft - 0.8, + right: rect.right + scrollLeft, + top: rect.top + scrollTop - 0.8, + bottom: rect.bottom + scrollTop + }; + + // get the doc dimensions as we need to put the ruler lines in the whole document + var docHeight = window.document.documentElement.scrollHeight; + var docWidth = window.document.documentElement.scrollWidth; + + // for vertical lines + this.lineElements.left.style.width = '1px'; + this.lineElements.left.style.height = docHeight + 'px'; + this.lineElements.left.style.left = edges.left + 'px'; + this.lineElements.left.style.top = '0px'; + + this.lineElements.right.style.width = '1px'; + this.lineElements.right.style.height = docHeight + 'px'; + this.lineElements.right.style.left = edges.right + 'px'; + this.lineElements.right.style.top = '0px'; + + // for horizontal lines + this.lineElements.top.style.height = '1px'; + this.lineElements.top.style.width = docWidth + 'px'; + this.lineElements.top.style.top = edges.top + 'px'; + this.lineElements.top.style.left = '0px'; + + this.lineElements.bottom.style.height = '1px'; + this.lineElements.bottom.style.width = docWidth + 'px'; + this.lineElements.bottom.style.top = edges.bottom + 'px'; + this.lineElements.bottom.style.left = '0px'; + }, + + remove: function() { + var body = window.document.body; + + if (this.lineElements.left && this.lineElements.left.parentNode) { + body.removeChild(this.lineElements.left); + } + if (this.lineElements.right && this.lineElements.right.parentNode) { + body.removeChild(this.lineElements.right); + } + if (this.lineElements.top && this.lineElements.top.parentNode) { + body.removeChild(this.lineElements.top); + } + if (this.lineElements.bottom && this.lineElements.bottom.parentNode) { + body.removeChild(this.lineElements.bottom); + } + + this.lineElements = { + left: null, + right: null, + top: null, + bottom: null + }; + } + }; + var _localHighlight; var _hoverHighlight; var _clickHighlight; var _nodeInfoBox; var _nodeMoreOptionsBox; + var _moreOptionsDropdown; var _aiPromptBox; var _imageRibbonGallery; + var _hyperlinkEditor; + var _currentRulerLines; var _setup = false; var _hoverLockTimer = null; const DOWNLOAD_EVENTS = { + DIALOG_OPENED: 'dialogOpened', + DIALOG_CLOSED: 'dialogClosed', STARTED: 'downloadStarted', COMPLETED: 'downloadCompleted', CANCELLED: 'downloadCancelled', @@ -3679,6 +4418,65 @@ function RemoteFunctions(config = {}) { }; let _activeDownloads = new Map(); + let _dialogOverlay = null; + + function _showDialogOverlay() { + // don't create multiple overlays + if (_dialogOverlay) { + return; + } + + // create overlay container + const overlay = window.document.createElement('div'); + overlay.setAttribute('data-phcode-internal-c15r5a9', 'true'); + + const styles = ` + + `; + + const content = ` +
+
${config.strings.imageGalleryDialogOverlayMessage}
+
+ `; + + overlay.innerHTML = styles + content; + window.document.body.appendChild(overlay); + _dialogOverlay = overlay; + } + + function _hideDialogOverlay() { + if (_dialogOverlay && _dialogOverlay.parentNode) { + _dialogOverlay.parentNode.removeChild(_dialogOverlay); + _dialogOverlay = null; + } + } function handleDownloadEvent(eventType, data) { const downloadId = data && data.downloadId; @@ -3686,6 +4484,18 @@ function RemoteFunctions(config = {}) { return; } + // handle dialog events (these don't require download to exist) + if (eventType === DOWNLOAD_EVENTS.DIALOG_OPENED) { + _showDialogOverlay(); + return; + } + + if (eventType === DOWNLOAD_EVENTS.DIALOG_CLOSED) { + _hideDialogOverlay(); + return; + } + + // handle download events (these require download to exist) const download = _activeDownloads.get(downloadId); if (!download) { return; @@ -3822,6 +4632,7 @@ function RemoteFunctions(config = {}) { */ function _selectElement(element) { dismissNodeMoreOptionsBox(); + dismissMoreOptionsDropdown(); dismissAIPromptBox(); dismissNodeInfoBox(); dismissToastMessage(); @@ -3866,7 +4677,8 @@ function RemoteFunctions(config = {}) { } element._originalOutline = element.style.outline; - element.style.outline = "1px solid #4285F4"; + const outlineColor = element.hasAttribute("data-brackets-id") ? "#4285F4" : "#3C3F41"; + element.style.outline = `1px solid ${outlineColor}`; // Only apply background tint for editable elements (not for dynamic/read-only) if (element.hasAttribute("data-brackets-id")) { @@ -3881,6 +4693,11 @@ function RemoteFunctions(config = {}) { _hoverHighlight.add(element, true); } + // to show ruler lines (only when its enabled) + if (config.showRulerLines) { + _currentRulerLines = new RulerLines(element); + } + previouslyClickedElement = element; } @@ -3916,36 +4733,98 @@ function RemoteFunctions(config = {}) { } /** - * This function handles the click event on the live preview DOM element - * this just stops the propagation because otherwise users might not be able to edit buttons or hyperlinks etc - * @param {Event} event + * this function is called when user clicks on an element in the LP when in edit mode + * + * @param {HTMLElement} element - The clicked element + * @param {Event} event - The click event */ - function onClick(event) { - const element = event.target; + function handleElementClick(element, event) { + if (!isElementInspectable(element)) { + dismissUIAndCleanupState(); + return; + } - if(isElementInspectable(element)) { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + // if anything is currently selected, we need to clear that + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + selection.removeAllRanges(); + } - _selectElement(element); - activateHoverLock(); + // send cursor movement message to editor so cursor jumps to clicked element + if (element.hasAttribute("data-brackets-id")) { + window._Brackets_MessageBroker.send({ + "tagId": element.getAttribute("data-brackets-id"), + "nodeID": element.id, + "nodeClassList": element.classList, + "nodeName": element.nodeName, + "allSelectors": window.getAllInheritedSelectorsInOrder(element), + "contentEditable": element.contentEditable === "true", + "clicked": true + }); } + + // call the selectElement as selectElement handles all the highlighting/boxes and all UI related stuff + _selectElement(element); + activateHoverLock(); } /** - * this function handles the double click event - * @param {Event} event + * this acts as the "KILL SWITCH", it blocks all the click related events from user elements + * but we exclude all the phoenix interal elements + * we call this function from inside registerHandlers */ - function onDoubleClick(event) { - const element = event.target; - if (isElementEditable(element)) { - // because we only want to allow double click text editing where we show the edit option - if (_shouldShowEditTextOption(element)) { + function registerInteractionBlocker() { + // Create an object to store handler references + _interactionBlockerHandlers = {}; + + const eventsToBlock = ["click", "dblclick"]; + + eventsToBlock.forEach(eventType => { + // Create a named handler function so we can remove it later + const handler = function(event) { + const element = event.target; + + // WHITELIST: Allow Phoenix internal UI elements to work normally + if (element.closest("[data-phcode-internal-c15r5a9]")) { + return; + } + + // BLOCK: Kill all user page interactions in edit mode event.preventDefault(); - event.stopPropagation(); - startEditing(element); - } + event.stopImmediatePropagation(); + + // HANDLE: Process clicks and double-clicks for element selection/editing + if (eventType === "click") { + // Skip click handling on the second click of a double-click + // event.detail = 1 for first click, 2 for second click (during double-click) + if (event.detail !== 2) { + handleElementClick(element, event); + } + } else if (eventType === "dblclick") { + if (isElementEditable(element) && _shouldShowEditTextOption(element)) { + startEditing(element); + } + } + }; + + // Store the handler reference + _interactionBlockerHandlers[eventType] = handler; + + // Register the handler in capture phase + window.document.addEventListener(eventType, handler, true); + }); + } + + /** + * this function is to remove all the interaction blocker + * this is needed when user is in preview/highlight mode + */ + function unregisterInteractionBlocker() { + if (_interactionBlockerHandlers) { + Object.keys(_interactionBlockerHandlers).forEach(eventType => { + window.document.removeEventListener(eventType, _interactionBlockerHandlers[eventType], true); + }); + _interactionBlockerHandlers = null; } } @@ -3967,7 +4846,6 @@ function RemoteFunctions(config = {}) { window.document.addEventListener("mouseover", onMouseOver); window.document.addEventListener("mouseout", onMouseOut); window.document.addEventListener("mousemove", onMouseMove); - window.document.addEventListener("click", onClick); _localHighlight = new Highlight("#ecc", true); _setup = true; } @@ -4060,10 +4938,18 @@ function RemoteFunctions(config = {}) { } } + // redraw ruler lines when element is selected + function redrawRulerLines() { + if (_currentRulerLines) { + _currentRulerLines.update(); + } + } + // just a wrapper function when we need to redraw highlights as well as UI boxes function redrawEverything() { redrawHighlights(); redrawUIBoxes(); + redrawRulerLines(); } window.addEventListener("resize", redrawEverything); @@ -4162,6 +5048,7 @@ function RemoteFunctions(config = {}) { // need to be updated on a timer to ensure the layout is correct. if (e.target === window.document) { redrawHighlights(); + redrawRulerLines(); // need to dismiss the box if the elements are fixed, otherwise they drift at times _dismissBoxesForFixedElements(); _repositionAIBox(); // and reposition the AI box @@ -4169,6 +5056,9 @@ function RemoteFunctions(config = {}) { if (_localHighlight || _clickHighlight || _hoverHighlight) { window.setTimeout(redrawHighlights, 0); } + if (_currentRulerLines) { + window.setTimeout(redrawRulerLines, 0); + } _dismissBoxesForFixedElements(); _repositionAIBox(); } @@ -4432,6 +5322,28 @@ function RemoteFunctions(config = {}) { _editHandler.apply(edits); } + /** + * Handle ruler lines visibility toggle when config changes + * @param {Object} oldConfig - the prev config state + */ + function _handleRulerLinesConfigChange(oldConfig) { + const rulerLinesChanged = oldConfig.showRulerLines !== config.showRulerLines; + if (rulerLinesChanged && previouslyClickedElement) { + if (config.showRulerLines) { + // if user turned it on: create ruler lines for the element + if (!_currentRulerLines) { + _currentRulerLines = new RulerLines(previouslyClickedElement); + } + } else { + // if user turned it off: remove the lines + if (_currentRulerLines) { + _currentRulerLines.remove(); + _currentRulerLines = null; + } + } + } + } + function updateConfig(newConfig) { const oldConfig = config; config = JSON.parse(newConfig); @@ -4441,6 +5353,12 @@ function RemoteFunctions(config = {}) { imageGallerySelected = config.imageGalleryState; } + // handle ruler lines visibility toggle and refresh the more options dropdown if its open + _handleRulerLinesConfigChange(oldConfig); + if (_moreOptionsDropdown) { + _moreOptionsDropdown.refresh(); + } + // Determine if configuration has changed significantly const oldHighlightMode = oldConfig.elemHighlights ? oldConfig.elemHighlights.toLowerCase() : "hover"; const newHighlightMode = getHighlightMode(); @@ -4506,6 +5424,16 @@ function RemoteFunctions(config = {}) { } } + /** + * Helper function to dismiss MoreOptionsDropdown if it exists + */ + function dismissMoreOptionsDropdown() { + if (_moreOptionsDropdown) { + _moreOptionsDropdown.remove(); + _moreOptionsDropdown = null; + } + } + /** * Helper function to dismiss NodeInfoBox if it exists */ @@ -4536,14 +5464,23 @@ function RemoteFunctions(config = {}) { } } + function dismissHyperlinkEditor() { + if (_hyperlinkEditor) { + _hyperlinkEditor.remove(); + _hyperlinkEditor = null; + } + } + /** * Helper function to dismiss all UI boxes at once */ function dismissAllUIBoxes() { dismissNodeMoreOptionsBox(); + dismissMoreOptionsDropdown(); dismissAIPromptBox(); dismissNodeInfoBox(); dismissImageRibbonGallery(); + dismissHyperlinkEditor(); dismissToastMessage(); } @@ -4553,8 +5490,9 @@ function RemoteFunctions(config = {}) { * this function is to show a toast notification at the bottom center of the screen * this toast message is used when user tries to edit a non-editable element * @param {String} message - the message to display in the toast + * @param {Number} duration - optional duration in milliseconds (default: 3000) */ - function showToastMessage(message) { + function showToastMessage(message, duration = 3000) { // clear any existing toast & timer, if there are any dismissToastMessage(); @@ -4610,13 +5548,13 @@ function RemoteFunctions(config = {}) { shadow.innerHTML = `${content}`; window.document.body.appendChild(toast); - // Auto-dismiss after 3 seconds + // Auto-dismiss after the given time _toastTimeout = setTimeout(() => { if (toast && toast.parentNode) { toast.remove(); } _toastTimeout = null; - }, 3000); + }, duration); } /** @@ -4651,6 +5589,11 @@ function RemoteFunctions(config = {}) { _hoverHighlight.clear(); } + if (_currentRulerLines) { + _currentRulerLines.remove(); + _currentRulerLines = null; + } + previouslyClickedElement = null; } } @@ -4831,12 +5774,11 @@ function RemoteFunctions(config = {}) { // Always remove existing listeners first to avoid duplicates window.document.removeEventListener("mouseover", onElementHover); window.document.removeEventListener("mouseout", onElementHoverOut); - window.document.removeEventListener("click", onClick); - window.document.removeEventListener("dblclick", onDoubleClick); window.document.removeEventListener("dragover", onDragOver); window.document.removeEventListener("drop", onDrop); window.document.removeEventListener("dragleave", onDragLeave); window.document.removeEventListener("keydown", onKeyDown); + unregisterInteractionBlocker(); if (config.isProUser) { // Initialize hover highlight with Chrome-like colors @@ -4845,14 +5787,17 @@ function RemoteFunctions(config = {}) { // Initialize click highlight with animation _clickHighlight = new Highlight("#cfc", true); // Light green for click highlight + // register the event handlers window.document.addEventListener("mouseover", onElementHover); window.document.addEventListener("mouseout", onElementHoverOut); - window.document.addEventListener("click", onClick); - window.document.addEventListener("dblclick", onDoubleClick); window.document.addEventListener("dragover", onDragOver); window.document.addEventListener("drop", onDrop); window.document.addEventListener("dragleave", onDragLeave); window.document.addEventListener("keydown", onKeyDown); + + // this is to block all the interactions of the user created elements + // so that lets say user created link doesn't redirect in edit mode + registerInteractionBlocker(); } else { // Clean up any existing UI when edit features are disabled dismissUIAndCleanupState(); @@ -4877,6 +5822,7 @@ function RemoteFunctions(config = {}) { "resetState" : resetState, "enableHoverListeners" : enableHoverListeners, "registerHandlers" : registerHandlers, - "handleDownloadEvent" : handleDownloadEvent + "handleDownloadEvent" : handleDownloadEvent, + "showToastMessage" : showToastMessage }; } diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/LiveDevelopment/LivePreviewEdit.js index 851e84d094..092be492cd 100644 --- a/src/LiveDevelopment/LivePreviewEdit.js +++ b/src/LiveDevelopment/LivePreviewEdit.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { const LiveDevelopment = require("LiveDevelopment/main"); const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"); const ProjectManager = require("project/ProjectManager"); + const PreferencesManager = require("preferences/PreferencesManager"); const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); const FileSystem = require("filesystem/FileSystem"); @@ -46,7 +47,12 @@ define(function (require, exports, module) { const IMAGE_DOWNLOAD_FOLDER_KEY = "imageGallery.downloadFolder"; const IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY = "imageGallery.persistFolder"; + // state manager key for tracking if copy/cut toast has been shown + const COPY_CUT_TOAST_SHOWN_KEY = "livePreviewEdit.copyToastShown"; + const DOWNLOAD_EVENTS = { + DIALOG_OPENED: 'dialogOpened', + DIALOG_CLOSED: 'dialogClosed', STARTED: 'downloadStarted', COMPLETED: 'downloadCompleted', CANCELLED: 'downloadCancelled', @@ -72,7 +78,7 @@ define(function (require, exports, module) { * 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 + * - Parse both old and new HTML strings into document fragments using