// this is a single file sent to browser preview. keep this light. add features as extensions // Please do not add any license header in this file as it will end up in distribution bin as is. /** * RemoteFunctions define the functions to be executed in the browser. This * modules should define a single function that returns an object of all * exported functions. */ // eslint-disable-next-line no-unused-vars function RemoteFunctions(config = {}) { const GLOBALS = { // given to internal elements like info box, tool box, image gallery and all other phcode internal elements // to distinguish between phoenix internal vs user created elements PHCODE_INTERNAL_ATTR: "data-phcode-internal-c15r5a9", DATA_BRACKETS_ID_ATTR: "data-brackets-id", // data attribute used to track elements for live preview operations LP_REF_ATTR: "data-phcode-lp-ref", // identity of a script-added element, stamped only when it is selected HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview }; // this is for bidirectional communication between phoenix and live preview const PhoenixComm = window._Brackets_LiveDev_PhoenixComm; PhoenixComm && PhoenixComm.registerLpFn("PH_Hello", function(param) { // this is just a test function here to check if live preview. fn call is working correctly. console.log("Hello World", param); }); const MessageBroker = window._Brackets_MessageBroker; // to be used by plugins. const SHARED_STATE = { __description: "Use this to keep shared state for Live Preview Edit instead of window.*", _suppressDOMEditDismissal: false, _suppressDOMEditDismissalTimeout: null, _boxModelHighlightHidden: false }; let _hoverHighlight; let _clickHighlight; let _cssSelectorHighlight; // temporary highlight for CSS selector matches in edit mode let _hoverLockTimer = null; let _cssSelectorHighlightTimer = null; let _lastHoverTarget = null; // tracks the element currently under the mouse (for same-element skip) let _pendingHoverRAF = null; // pending requestAnimationFrame ID for hover updates // 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 previouslySelectedElement = null; let _sourcelessObserver = null; let _sourcelessCheckTimer = null; let _sourcelessPath = null; let _sourcelessTag = null; let _sourcelessClass = null; const SOURCELESS_RECOVER_DELAY_MS = 60; let _selectedFromEditor = false; // the element selected by name (layers panel row), not by pointer let _namedSelection = null; // Expose the currently selected element globally for external access window.__current_ph_lp_selected = null; const COLORS = { highlightPadding: "rgba(147, 196, 125, 0.55)", highlightMargin: "rgba(246, 178, 107, 0.66)", outlineEditable: "#4285F4", outlineNonEditable: "#3C3F41" }; // the following fucntions can be in the handler and live preview will call those functions when the below // events happen const allowedHandlerFns = [ "dismiss", // when handler gets this event, it should dismiss all ui it renders in the live preview "createToolBox", "createInfoBox", "showHoverBox", "createMoreOptionsDropdown", // render an icon or html when the selected element toolbox appears in edit mode. "renderToolBoxItem", "redraw", "onElementSelected", // an item is selected in live preview "onElementCleanup", "onNonEditableElementClick", // called when user clicks on a non-editable element "handleConfigChange", // below function gets called to render the dropdown when user clicks on the ... menu in the tool box, // the handler should retrun html tor ender the dropdown item. "renderDropdownItems", // called when an item is selected from the more options dropdown "handleDropdownClick", "updateContent", // in-place content refresh for control box etc. after drag // a DOM edit changed the selected element's attributes or rebuilt its node — // refresh shown UI in place; must never resurrect dismissed UI "onSelectedElementMutated", "reRegisterEventHandlers", "handleClick", // handle click on an icon in the tool box. // when escape key is presses in the editor, we may need to dismiss the live edit boxes. "handleEscapePress", // interaction blocks acts as 'kill switch' to block all kinds of click handlers // this is done so that links or buttons doesn't perform their natural operation in edit mode "registerInteractionBlocker", // to block "unregisterInteractionBlocker", // to unblock "udpateHotCornerState" // to update the hot corner button when state changes ]; const _toolHandlers = new Map(); function registerToolHandler(handlerName, handler) { if(_toolHandlers.get(handlerName)) { console.error(`lp: Tool handler '${handlerName}' already registered. Ignoring new registration`); return; } if (!handler || typeof handler !== "object") { console.error(`lp: Tool handler '${handlerName}' value is invalid ${JSON.stringify(handler)}.`); return; } handler.handlerName = handlerName; for (const key of Object.keys(handler)) { if (key !== "handlerName" && !allowedHandlerFns.includes(key)) { console.warn(`lp: Tool handler '${handlerName}' has unknown property '${key}'`, `should be one of ${allowedHandlerFns.join(",")}`); } } _toolHandlers.set(handlerName, handler); } function getToolHandler(handlerName) { return _toolHandlers.get(handlerName); } function getAllToolHandlers() { return Array.from(_toolHandlers.values()); } /** * check if an element is inspectable. * inspectable elements are those which doesn't have GLOBALS.DATA_BRACKETS_ID_ATTR ('data-brackets-id'), * this normally happens when content is DOM content is inserted by some scripting language * * Elements opted out via `phcode-no-lp-edit` (cascades to descendants) or * `phcode-no-lp-edit-this` (this element only) are also non-inspectable so * every downstream tool inherits the opt-out automatically. * * @param {DOMElement} element * @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check */ function isElementInspectable(element, onlyHighlight = false) { if(config.mode !== 'edit' && !onlyHighlight) { return false; } if(element && // element should exist (!isBodyElement(element) || _isNamedSelection(element)) && // body only when selected by name element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag // this attribute is used by phoenix internal elements !element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`) && !_isInsideHeadTag(element) && // shouldn't be inside the head tag like meta tags and all !_isEditOptedOut(element)) { return true; } return false; } // The body is never selected by pointer (blank clicks deselect, hover stays quiet), // only by name from the layers panel or the caret, and then without the structural tools. function isBodyElement(element) { return !!(element && element.tagName && element.tagName.toLowerCase() === "body"); } // a named selection lifts the `phcode-no-lp-edit` opt-out and the body block, both pointer-only guards function _isNamedSelection(element) { return !!element && element === _namedSelection; } /** * `phcode-no-lp-edit` cascades to descendants, `phcode-no-lp-edit-this` covers * the one element. */ function _isEditOptedOut(element) { if (_isNamedSelection(element)) { return false; } return !!(element.closest('.phcode-no-lp-edit') || (element.classList && element.classList.contains('phcode-no-lp-edit-this'))); } /** * This is a checker function for editable elements, it makes sure that the element satisfies all the required check * - When onlyHighlight is false → config.mode must be 'edit' * - When onlyHighlight is true → config.mode can be any mode (doesn't matter) * @param {DOMElement} element * @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check * @returns {boolean} - True if the element is editable else false */ function isElementEditable(element, onlyHighlight = false) { // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id return isElementInspectable(element, onlyHighlight) && element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); } // no data-brackets-id means a script added the element, so there is no HTML source for it function isSourceless(element) { return !!element && !element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); } let _lpRefCounter = 0; const LP_REF_PREFIX = "j"; const RE_NUMERIC_ID = /^\d+$/; function getElementRef(element) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return null; } const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); if (tagId) { return tagId; } let ref = element.getAttribute(GLOBALS.LP_REF_ATTR); if (!ref) { ref = LP_REF_PREFIX + (++_lpRefCounter); element.setAttribute(GLOBALS.LP_REF_ATTR, ref); } return ref; } function getElementByRef(ref) { if (ref === null || ref === undefined || ref === "") { return null; } const text = String(ref); const attr = RE_NUMERIC_ID.test(text) ? GLOBALS.DATA_BRACKETS_ID_ATTR : GLOBALS.LP_REF_ATTR; return window.document.querySelector("[" + attr + '="' + text + '"]'); } /** * this function calc the screen offset of an element * * @param {DOMElement} element * @returns {{left: number, top: number}} */ function screenOffset(element) { const elemBounds = element.getBoundingClientRect(); const body = window.document.body; let offsetTop; let offsetLeft; if (window.getComputedStyle(body).position === "static") { offsetLeft = elemBounds.left + window.pageXOffset; offsetTop = elemBounds.top + window.pageYOffset; } else { const bodyBounds = body.getBoundingClientRect(); offsetLeft = elemBounds.left - bodyBounds.left; offsetTop = elemBounds.top - bodyBounds.top; } return { left: offsetLeft, top: offsetTop }; } const LivePreviewView = { registerToolHandler: registerToolHandler, getToolHandler: getToolHandler, getAllToolHandlers: getAllToolHandlers, isElementEditable: isElementEditable, isElementInspectable: isElementInspectable, isBodyElement: isBodyElement, isSourceless: isSourceless, getElementRef: getElementRef, getElementByRef: getElementByRef, isElementVisible: isElementVisible, screenOffset: screenOffset, selectElement: selectElement, isSelectedFromEditor: function () { return _selectedFromEditor; }, sendSelectionToEditor: sendSelectionToEditor, brieflyDisableHoverListeners: brieflyDisableHoverListeners, handleElementClick: handleElementClick, cleanupPreviousElementState: cleanupPreviousElementState, disableHoverListeners: disableHoverListeners, enableHoverListeners: enableHoverListeners, redrawHighlights: redrawHighlights, redrawEverything: redrawEverything, getTreePath: _getTreePath, getElementByTreePath: _getElementByTreePath, getSourceChildren: _instrumentedChildren }; /** * @type {DOMEditHandler} */ var _editHandler; // the below code comment is replaced by added scripts for extensibility // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS // 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, // except for `; window.document.body.appendChild(_highlightShadowHost); return _highlightShadowRoot; } // Overlay pool — overlays are created once and reused across highlights. // When released, they stay in the shadow DOM (hidden) ready for instant reuse. // This eliminates all DOM creation/destruction from the highlight hot paths. const _overlayPool = []; function _createOverlayStructure() { const div = window.document.createElement("div"); div.className = "overlay-container hidden"; function createRect() { const r = window.document.createElement("div"); r.className = "rect"; return r; } const padTop = createRect(), padBottom = createRect(), padLeft = createRect(), padRight = createRect(); const marTop = createRect(), marBottom = createRect(), marLeft = createRect(), marRight = createRect(); const outline = window.document.createElement("div"); outline.className = "outline"; div.appendChild(padTop); div.appendChild(padBottom); div.appendChild(padLeft); div.appendChild(padRight); div.appendChild(marTop); div.appendChild(marBottom); div.appendChild(marLeft); div.appendChild(marRight); div.appendChild(outline); // Cache child references for O(1) access during updates div._refs = { padTop, padBottom, padLeft, padRight, marTop, marBottom, marLeft, marRight, outline }; _ensureHighlightShadowRoot().appendChild(div); return div; } function _getOverlay() { return _overlayPool.length > 0 ? _overlayPool.pop() : _createOverlayStructure(); } function _releaseOverlay(overlay) { overlay.classList.add('hidden'); overlay.trackingElement = null; _overlayPool.push(overlay); } // Everything an overlay needs read off the page. Split from the painting // below so a batch of overlays can read first and write after: interleaving // the two forces a layout per element. // What screenOffset() needs off the body, read once for a whole batch // instead of once per element. function _bodyOffsetContext() { const body = window.document.body; if (window.getComputedStyle(body).position === "static") { return { isStatic: true, x: window.pageXOffset, y: window.pageYOffset }; } const bodyBounds = body.getBoundingClientRect(); return { isStatic: false, x: bodyBounds.left, y: bodyBounds.top }; } function _offsetFromBounds(bounds, bodyOffset) { if (bodyOffset.isStatic) { return { left: bounds.left + bodyOffset.x, top: bounds.top + bodyOffset.y }; } return { left: bounds.left - bodyOffset.x, top: bounds.top - bodyOffset.y }; } function _measureOverlay(element, bodyOffset) { const bounds = element.getBoundingClientRect(); if (bounds.width === 0 && bounds.height === 0) { return null; } const cs = window.getComputedStyle(element); return { bounds: bounds, scroll: _offsetFromBounds(bounds, bodyOffset || _bodyOffsetContext()), bt: parseFloat(cs.borderTopWidth) || 0, br: parseFloat(cs.borderRightWidth) || 0, bb: parseFloat(cs.borderBottomWidth) || 0, bl: parseFloat(cs.borderLeftWidth) || 0, pt: parseFloat(cs.paddingTop) || 0, pr: parseFloat(cs.paddingRight) || 0, pb: parseFloat(cs.paddingBottom) || 0, pl: parseFloat(cs.paddingLeft) || 0, mt: parseFloat(cs.marginTop) || 0, mr: parseFloat(cs.marginRight) || 0, mb: parseFloat(cs.marginBottom) || 0, ml: parseFloat(cs.marginLeft) || 0 }; } function _measureAll(elements) { const bodyOffset = _bodyOffsetContext(); const measured = []; for (let i = 0; i < elements.length; i++) { measured.push(_measureOverlay(elements[i], bodyOffset)); } return measured; } // Update an existing overlay's position, dimensions, and colors to match the target element. // No DOM elements are created or destroyed — only style properties are updated. function _paintOverlay(overlay, element, measured) { if (!measured) { overlay.classList.add('hidden'); return; } const bounds = measured.bounds; // Parse box model values (getComputedStyle always resolves to px) const bt = measured.bt, br = measured.br, bb = measured.bb, bl = measured.bl; const pt = measured.pt, pr = measured.pr, pb = measured.pb, pl = measured.pl; const mt = measured.mt, mr = measured.mr, mb = measured.mb, ml = measured.ml; // Compute the 4 absolute boxes exactly like dev tools: // getBoundingClientRect() always returns the border box regardless of box-sizing. const scroll = measured.scroll; const borderBox = { left: scroll.left, top: scroll.top, width: bounds.width, height: bounds.height }; const paddingBox = { left: borderBox.left + bl, top: borderBox.top + bt, width: borderBox.width - bl - br, height: borderBox.height - bt - bb }; const contentBox = { left: paddingBox.left + pl, top: paddingBox.top + pt, width: paddingBox.width - pl - pr, height: paddingBox.height - pt - pb }; const marginBox = { left: borderBox.left - ml, top: borderBox.top - mt, width: borderBox.width + ml + mr, height: borderBox.height + mt + mb }; // Update container position overlay.trackingElement = element; overlay.style.left = marginBox.left + "px"; overlay.style.top = marginBox.top + "px"; overlay.style.width = marginBox.width + "px"; overlay.style.height = marginBox.height + "px"; overlay.classList.remove('hidden'); const refs = overlay._refs; const mLeft = marginBox.left; // Update a rect's position, size, and color in place function setRect(rect, left, top, width, height, color) { const s = rect.style; s.left = (left - mLeft) + "px"; s.top = (top - marginBox.top) + "px"; s.width = Math.max(0, width) + "px"; s.height = Math.max(0, height) + "px"; s.backgroundColor = color; } // Padding region. Rects stay in place when hidden, only their fill goes away, // so nothing has to be rebuilt when they come back. const boxModelHidden = SHARED_STATE._boxModelHighlightHidden; const padColor = boxModelHidden ? "transparent" : COLORS.highlightPadding; setRect(refs.padTop, paddingBox.left, paddingBox.top, paddingBox.width, pt, padColor); setRect(refs.padBottom, paddingBox.left, contentBox.top + contentBox.height, paddingBox.width, pb, padColor); setRect(refs.padLeft, paddingBox.left, contentBox.top, pl, contentBox.height, padColor); setRect(refs.padRight, contentBox.left + contentBox.width, contentBox.top, pr, contentBox.height, padColor); // Margin region const margColor = boxModelHidden ? "transparent" : COLORS.highlightMargin; setRect(refs.marTop, marginBox.left, marginBox.top, marginBox.width, mt, margColor); setRect(refs.marBottom, marginBox.left, borderBox.top + borderBox.height, marginBox.width, mb, margColor); setRect(refs.marLeft, marginBox.left, borderBox.top, ml, borderBox.height, margColor); setRect(refs.marRight, borderBox.left + borderBox.width, borderBox.top, mr, borderBox.height, margColor); // Outline const isEditable = element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); const outlineColor = isEditable ? COLORS.outlineEditable : COLORS.outlineNonEditable; const outlineStyle = refs.outline.style; outlineStyle.left = (borderBox.left - mLeft) + "px"; outlineStyle.top = (borderBox.top - marginBox.top) + "px"; outlineStyle.width = borderBox.width + "px"; outlineStyle.height = borderBox.height + "px"; outlineStyle.border = `1px solid ${outlineColor}`; } function _updateOverlay(overlay, element) { _paintOverlay(overlay, element, _measureOverlay(element)); } function Highlight(trigger) { this.trigger = !!trigger; this.elements = []; this.selector = ""; this._overlays = []; } Highlight.prototype = { add: function (element) { if (this.elements.includes(element) || element === window.document) { return; } if (this.trigger) { _trigger(element, "highlight", 1); } this.elements.push(element); const overlay = _getOverlay(); this._overlays.push(overlay); _updateOverlay(overlay, element); }, addAll: function (elements) { const seen = new Set(this.elements); const fresh = []; for (let i = 0; i < elements.length; i++) { const element = elements[i]; if (element !== window.document && !seen.has(element)) { seen.add(element); fresh.push(element); } } const measured = _measureAll(fresh); for (let i = 0; i < fresh.length; i++) { if (this.trigger) { _trigger(fresh[i], "highlight", 1); } this.elements.push(fresh[i]); const overlay = _getOverlay(); this._overlays.push(overlay); _paintOverlay(overlay, fresh[i], measured[i]); } }, clear: function () { this._overlays.forEach(function (overlay) { _releaseOverlay(overlay); }); this._overlays = []; if (this.trigger) { this.elements.forEach(function (el) { _trigger(el, "highlight", 0); }); } this.elements = []; // Reset the cached selector so that redraw() uses the elements // array instead of re-querying the DOM with a stale selector. // Without this, a selector like [data-brackets-id='3'] persists // after the element is replaced (e.g. tag name change assigns a // new ID), causing redraw() to find zero matches and release // all overlays — making the highlight vanish. this.selector = ""; }, redraw: function () { const elements = this.selector ? Array.from(window.document.querySelectorAll(this.selector)) : this.elements.slice(); // Adjust overlay count to match element count while (this._overlays.length > elements.length) { _releaseOverlay(this._overlays.pop()); } while (this._overlays.length < elements.length) { this._overlays.push(_getOverlay()); } this.elements = elements; // Update all overlays in place — no DOM creation or destruction const measured = _measureAll(elements); for (let i = 0; i < elements.length; i++) { _paintOverlay(this._overlays[i], elements[i], measured[i]); } } }; // 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"; } /** * Applies the current hover state in a single batched DOM update. * Called once per animation frame via requestAnimationFrame. * _lastHoverTarget holds the element to highlight (or null to clear). */ function _applyHoverState() { _pendingHoverRAF = null; if (!_hoverHighlight || !shouldShowHighlightOnHover()) { return; } _hoverHighlight.clear(); const hoverBoxHandler = LivePreviewView.getToolHandler("HoverBox"); if (hoverBoxHandler) { hoverBoxHandler.dismiss(); } const element = _lastHoverTarget; if (element && (element !== previouslySelectedElement || _selectedFromEditor)) { _hoverHighlight.add(element); if (hoverBoxHandler) { hoverBoxHandler.showHoverBox(element); } } } /** * Schedules a hover state update for the next animation frame. * Multiple calls within one frame collapse into a single DOM update. */ function _scheduleHoverUpdate() { if (!_pendingHoverRAF) { _pendingHoverRAF = requestAnimationFrame(_applyHoverState); } } function onElementHover(event) { // don't want highlighting and stuff when auto scrolling or when dragging (svgs) // for dragging normal html elements its already taken care of...so we just add svg drag checking if (SHARED_STATE.isAutoScrolling || SHARED_STATE._isDraggingSVG) { return; } const element = event.target; if (element === _lastHoverTarget) { return; } if(isBodyElement(element) || !LivePreviewView.isElementInspectable(element) || element.nodeType !== Node.ELEMENT_NODE) { return; } _lastHoverTarget = element; // if _hoverHighlight is uninitialized, initialize it if (!_hoverHighlight && shouldShowHighlightOnHover()) { _hoverHighlight = new Highlight(true); } if (_hoverHighlight && shouldShowHighlightOnHover()) { _scheduleHoverUpdate(); } } function _clearHoverState() { if (SHARED_STATE.isAutoScrolling) { return; } if (_hoverHighlight && shouldShowHighlightOnHover()) { _lastHoverTarget = null; _scheduleHoverUpdate(); } } function onElementHoverOut(event) { const element = event.target; // Use isElementInspectable (not isElementEditable) so that JS-rendered // elements also get their hover highlight and hover box properly dismissed. if(LivePreviewView.isElementInspectable(element) && element.nodeType === Node.ELEMENT_NODE) { _clearHoverState(); } } // for popped out window: the in-panel iframe case is forwarded parent-side via _LD.clearHoverState(). function onDocumentMouseLeave() { _clearHoverState(); } function scrollElementToViewPort(element) { if (!element) { return; } // Check if element is in viewport, if not scroll to it if (!isInViewport(element)) { let top = getDocumentOffsetTop(element); if (top) { top -= (window.innerHeight / 2); window.scrollTo(0, top); } } } /** * this function is responsible to select an element in the live preview * @param {Element} element - The DOM element to select * @param {boolean} [fromEditor] - If true, this is an editor-cursor-driven selection; * only lightweight highlights (outline, margin/padding overlay) are shown, not interactive * UI like the control box or the styles bar. * @param {boolean} [byName] - Selected by name (a layers panel row), so the edit * opt-out and the body block don't apply while it stays selected. */ function selectElement(element, fromEditor, byName) { // When a cursor-based highlight re-selects the already-selected element, // just refresh the highlight overlay without dismissing existing UI panels // (control box, editor box, element-info). This prevents cursor activity // after a source edit (e.g., tag name change) from tearing down the // element properties panel and losing its state. if (fromEditor && element === previouslySelectedElement) { if (!_clickHighlight) { _clickHighlight = new Highlight(); } _clickHighlight.clear(); _clickHighlight.add(element); return; } dismissUIAndCleanupState(); // set after the dismissal, which clears the previous selection's exemption _namedSelection = byName ? element : null; // this should also be there when users are in highlight mode scrollElementToViewPort(element); if(!LivePreviewView.isElementInspectable(element, true)) { return false; } // Only invoke tool handlers for user-initiated clicks in the live preview, // not for editor cursor movements which should only show lightweight highlights if (!fromEditor) { // when user clicks on a non-editable element if (!element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR)) { getAllToolHandlers().forEach(handler => { if (handler.onNonEditableElementClick) { handler.onNonEditableElementClick(element); } }); } // make sure that the element is actually visible to the user if (isElementVisible(element)) { // Notify handlers about element selection getAllToolHandlers().forEach(handler => { if (handler.onElementSelected) { handler.onElementSelected(element); } }); } } if (!_clickHighlight) { _clickHighlight = new Highlight(); } _clickHighlight.clear(); _clickHighlight.add(element); previouslySelectedElement = element; _selectedFromEditor = fromEditor || false; window.__current_ph_lp_selected = element; if (isSourceless(element)) { _watchSourcelessSelection(element); } } function _elementIndexPath(element) { const path = []; let el = element; while (el && el !== window.document.body) { const parent = el.parentElement; if (!parent) { return null; } path.unshift(Array.prototype.indexOf.call(parent.children, el)); el = parent; } return el === window.document.body ? path : null; } function _elementAtIndexPath(path) { let el = window.document.body; for (let i = 0; i < path.length && el; i++) { el = el.children[path[i]]; } return el || null; } // a re-render replaces a script-added node, so re-select the same tag in the same place or dismiss function _watchSourcelessSelection(element) { _unwatchSourcelessSelection(); _sourcelessPath = _elementIndexPath(element); if (!_sourcelessPath) { return; } _sourcelessTag = element.tagName; _sourcelessClass = typeof element.className === "string" ? element.className : ""; _sourcelessObserver = new MutationObserver(function () { if (_sourcelessCheckTimer || !previouslySelectedElement || previouslySelectedElement.isConnected) { return; } _sourcelessCheckTimer = setTimeout(_recoverSourcelessSelection, SOURCELESS_RECOVER_DELAY_MS); }); _sourcelessObserver.observe(window.document.body, { childList: true, subtree: true }); } function _unwatchSourcelessSelection() { if (_sourcelessObserver) { _sourcelessObserver.disconnect(); _sourcelessObserver = null; } if (_sourcelessCheckTimer) { clearTimeout(_sourcelessCheckTimer); _sourcelessCheckTimer = null; } _sourcelessPath = null; } function _recoverSourcelessSelection() { _sourcelessCheckTimer = null; const old = previouslySelectedElement; if (!old || old.isConnected || !_sourcelessPath) { return; } const fresh = _elementAtIndexPath(_sourcelessPath); const className = fresh && typeof fresh.className === "string" ? fresh.className : ""; if (fresh && fresh.tagName === _sourcelessTag && className === _sourcelessClass && isSourceless(fresh) && isElementInspectable(fresh, true) && isElementVisible(fresh)) { const fromEditor = _selectedFromEditor; selectElement(fresh, fromEditor); } else { dismissUIAndCleanupState(); } } function disableHoverListeners() { window.document.removeEventListener("mouseover", onElementHover); window.document.removeEventListener("mousemove", onElementHover); window.document.removeEventListener("mouseout", onElementHoverOut); window.document.documentElement.removeEventListener("mouseleave", onDocumentMouseLeave); // Cancel any pending rAF hover update so stale callbacks don't fire if (_pendingHoverRAF) { cancelAnimationFrame(_pendingHoverRAF); _pendingHoverRAF = null; } _lastHoverTarget = null; } function enableHoverListeners() { // don't enable hover listeners if user is currently editing an element // this was added to fix a specific bug: // lets say user double clicked an element: so as soon as the first click is made, // 'breiflyDisableHoverListeners' is called which has a timer to re-enable hover listeners, // because of which even during editing the hover listeners were working if (SHARED_STATE._currentlyEditingElement) { return; } if (config.mode === 'edit' && shouldShowHighlightOnHover()) { disableHoverListeners(); window.document.addEventListener("mouseover", onElementHover); window.document.addEventListener("mousemove", onElementHover); window.document.addEventListener("mouseout", onElementHoverOut); window.document.documentElement.addEventListener("mouseleave", onDocumentMouseLeave); } } /** * this function disables hover listeners for 800ms to prevent ui conclicts * Used when user performs click actions to avoid UI box conflicts */ function brieflyDisableHoverListeners() { if (_hoverLockTimer) { clearTimeout(_hoverLockTimer); } disableHoverListeners(); _hoverLockTimer = setTimeout(() => { enableHoverListeners(); _hoverLockTimer = null; }, 800); } /** * 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 handleElementClick(element, event) { // Check for dismiss action first - dismiss LP editing when clicked (takes precedence over no-edit) if(element && ( element.closest('.phcode-dismiss-lp-edit') || element.classList.contains('phcode-dismiss-lp-edit-this'))) { dismissUIAndCleanupState(); event.preventDefault(); event.stopPropagation(); return; } // Opted-out elements: silent no-op so the user's existing selection isn't dismissed. // (isElementInspectable would also reject them, but that path runs dismissUIAndCleanupState.) if(element && (element.closest('.phcode-no-lp-edit') || element.classList.contains('phcode-no-lp-edit-this'))) { return; } // a blank-space click lands on the body and deselects, even a body selected by name if (isBodyElement(element) || !LivePreviewView.isElementInspectable(element)) { dismissUIAndCleanupState(); return; } // if anything is currently selected, we need to clear that const selection = window.getSelection(); if (selection && selection.toString().length > 0) { selection.removeAllRanges(); } sendSelectionToEditor(element); brieflyDisableHoverListeners(); selectElement(element); } /** * Tells the editor which element is now selected, so the cursor jumps to it and * the css reverse highlight follows. Split out of the click handler because a * selection can also be asked for from the editor side, which must report itself * the same way without a pointer gesture ever touching the page. * * @param {HTMLElement} element */ function sendSelectionToEditor(element) { if (config.syncSourceAndPreview === false) { return; } // sent without a tagId too, so a css file in the editor can still jump to the rule const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); MessageBroker.send({ "tagId": tagId || null, "sourceless": !tagId, "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, "allSelectors": window.getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === "true", "clicked": true }); } // clear CSS selector highlights function clearCssSelectorHighlight() { if (_cssSelectorHighlightTimer) { clearTimeout(_cssSelectorHighlightTimer); _cssSelectorHighlightTimer = null; } if (_cssSelectorHighlight) { _cssSelectorHighlight.clear(); _cssSelectorHighlight = null; } } // create CSS selector highlights for edit mode function createCssSelectorHighlight(nodes, rule) { // Clear any existing highlights clearCssSelectorHighlight(); // Highlight all matching elements except the selected one // (it already has a click highlight) _cssSelectorHighlight = new Highlight(); const wanted = []; for (let i = 0; i < nodes.length; i++) { if (nodes[i] !== previouslySelectedElement && LivePreviewView.isElementInspectable(nodes[i], true) && nodes[i].nodeType === Node.ELEMENT_NODE) { wanted.push(nodes[i]); } } _cssSelectorHighlight.addAll(wanted); _cssSelectorHighlight.selector = rule; } // remove active highlights function hideHighlight() { if (_clickHighlight) { _clickHighlight.clear(); _clickHighlight = null; } if (_hoverHighlight) { _hoverHighlight.clear(); _hoverHighlight = null; } clearCssSelectorHighlight(); } // highlight an element function highlight(element, clear) { if (!_clickHighlight) { _clickHighlight = new Highlight(); } if (clear) { _clickHighlight.clear(); } if (LivePreviewView.isElementInspectable(element, true) && element.nodeType === Node.ELEMENT_NODE) { _clickHighlight.add(element); } } function highlightAll(elements) { if (!_clickHighlight) { _clickHighlight = new Highlight(); } const wanted = []; for (let i = 0; i < elements.length; i++) { if (LivePreviewView.isElementInspectable(elements[i], true) && elements[i].nodeType === Node.ELEMENT_NODE) { wanted.push(elements[i]); } } _clickHighlight.addAll(wanted); } /** * Find the best element to select from a list of matched nodes * Prefers: previously selected element > parent of selected > first valid element * @param {NodeList} nodes - The nodes matching the CSS rule * @param {string} rule - The CSS rule used to match nodes * @returns {{element: Element|null, skipSelection: boolean}} - The element to select and whether to skip selection */ function findBestElementToSelect(nodes, rule) { let firstValidElement = null; let elementToSelect = null; for (let i = 0; i < nodes.length; i++) { if(!LivePreviewView.isElementInspectable(nodes[i], true) || nodes[i].tagName === "BR") { continue; } // Store the first valid element as a fallback if (!firstValidElement) { firstValidElement = nodes[i]; } // if hover lock timer is active, skip selection as it's already handled by handleElementClick if (_hoverLockTimer && nodes[i] === previouslySelectedElement) { return { element: null, skipSelection: true }; } // Check if the currently selected element or any of its parents have a highlight if (previouslySelectedElement) { if (nodes[i] === previouslySelectedElement) { // Exact match - prefer this elementToSelect = previouslySelectedElement; break; } else if (!elementToSelect && previouslySelectedElement.closest && nodes[i] === previouslySelectedElement.closest(rule)) { // The node is a parent of the currently selected element. we stop at the first parent, after that // we only scan for exact match elementToSelect = nodes[i]; } } } return { element: elementToSelect || firstValidElement, skipSelection: false }; } /** * Highlight all elements matching a CSS rule and select the best one * @param {string} rule - The CSS rule to highlight */ function highlightRule(rule) { hideHighlight(); // Filter out the universal selector (*) from the rule - highlighting everything // is not useful, similar to how we skip the html tag in isElementInspectable. // The rule can be a comma-separated list of selectors (from multi-cursor), // so we filter out any standalone * segments and keep valid ones. rule = rule.split(",").map(s => s.trim()).filter(s => s !== "*").join(","); if (!rule) { dismissUIAndCleanupState(); return; } const nodes = window.document.querySelectorAll(rule); // Both edit and highlight modes go through the same selection path: // selectElement() handles scroll-to-view and the prominent click-highlight, // createCssSelectorHighlight() shows siblings dimly. fromEditor=true // suppresses tool-handler invocation, so highlight mode gets the // highlighting/scroll behavior without any UI boxes. const { element, skipSelection } = findBestElementToSelect(nodes, rule); if (skipSelection) { // A recent preview click owns the selection and its open tools. // Keep the existing selector highlight without re-selecting it. highlightAll(nodes); _clickHighlight.selector = rule; } else { if (element) { // Select first: drawing every match here would immediately be // cleared by selectElement() and drawn again as siblings below. selectElement(element, true); } else { // No valid element found, dismiss UI dismissUIAndCleanupState(); } } createCssSelectorHighlight(nodes, rule); } // recreate UI boxes so that they are placed properly function redrawUIBoxes() { // commented out for unified box redesign // if (SHARED_STATE._toolBox) { // const element = SHARED_STATE._toolBox.element; // const toolBoxHandler = LivePreviewView.getToolHandler("ToolBox"); // if (toolBoxHandler) { // toolBoxHandler.dismiss(); // toolBoxHandler.createToolBox(element); // } // } // if (SHARED_STATE._infoBox) { // const element = SHARED_STATE._infoBox.element; // const infoBoxHandler = LivePreviewView.getToolHandler("InfoBox"); // if (infoBoxHandler) { // infoBoxHandler.dismiss(); // infoBoxHandler.createInfoBox(element); // } // } } // redraw active highlights function redrawHighlights() { if (_clickHighlight) { _clickHighlight.redraw(); } if (_hoverHighlight) { _hoverHighlight.redraw(); } } // just a wrapper function when we need to redraw highlights as well as UI boxes function redrawEverything() { redrawHighlights(); redrawUIBoxes(); // Call redraw on all registered handlers getAllToolHandlers().forEach(handler => { if (handler.redraw) { handler.redraw(); } }); } // Throttle resize redraws to one per animation frame — avoids redundant // layout reads when the browser fires multiple resize events per frame. let _pendingResizeRAF = null; function _onWindowResize() { if (!_pendingResizeRAF) { _pendingResizeRAF = requestAnimationFrame(function () { _pendingResizeRAF = null; redrawEverything(); }); } } window.addEventListener("resize", _onWindowResize); /** * Constructor * @param {Document} htmlDocument */ function DOMEditHandler(htmlDocument) { this.htmlDocument = htmlDocument; this.rememberedNodes = null; this.entityParseParent = htmlDocument.createElement("div"); } /** * @private * Find the first matching element with the specified data-brackets-id * @param {string} id * @return {Element} */ DOMEditHandler.prototype._queryBracketsID = function (id) { if (!id) { return null; } if (this.rememberedNodes && this.rememberedNodes[id]) { return this.rememberedNodes[id]; } var results = this.htmlDocument.querySelectorAll(`[${GLOBALS.DATA_BRACKETS_ID_ATTR}='${id}']`); return results && results[0]; }; // True for elements Phoenix adds to the page itself, like the tool boxes and // the highlight overlays. They are not part of the user's source file. function _isPhoenixInternalNode(node) { return !!node && node.nodeType === Node.ELEMENT_NODE && (node.hasAttribute(GLOBALS.PHCODE_INTERNAL_ATTR) || node.className === GLOBALS.HIGHLIGHT_CLASSNAME); } /** The first of the Phoenix elements sitting at the end of `parent`, else null. */ function _firstTrailingInternalNode(parent) { let node = parent.lastChild; let first = null; while (_isPhoenixInternalNode(node)) { first = node; node = node.previousSibling; } return first; } /** * @private * Insert a new child element * @param {Element} targetElement Parent element already in the document * @param {Element} childElement New child element * @param {Object} edit */ DOMEditHandler.prototype._insertChildNode = function (targetElement, childElement, edit) { var before = this._queryBracketsID(edit.beforeID), after = this._queryBracketsID(edit.afterID); if (edit.firstChild) { before = targetElement.firstChild; } else if (edit.lastChild) { // Phoenix's tool boxes are the last children of
, so appending // here would put the new element after them, in the wrong place. before = _firstTrailingInternalNode(targetElement); if (!before) { after = targetElement.lastChild; } } if (before) { targetElement.insertBefore(childElement, before); } else if (after && (after !== targetElement.lastChild)) { targetElement.insertBefore(childElement, after.nextSibling); } else { targetElement.appendChild(childElement); } }; /** * @private * Given a string containing encoded entity references, returns the string with the entities decoded. * @param {string} text The text to parse. * @return {string} The decoded text. */ DOMEditHandler.prototype._parseEntities = function (text) { // Kind of a hack: just set the innerHTML of a div to the text, which will parse the entities, then // read the content out. var result; this.entityParseParent.innerHTML = text; result = this.entityParseParent.textContent; this.entityParseParent.textContent = ""; return result; }; /** * @private * @param {Node} node * @return {boolean} true if node expects its content to be * raw text (not parsed for entities) according to the HTML5 spec. */ function _isRawTextNode(node) { return ( node.nodeType === Node.ELEMENT_NODE && /script|style|noscript|noframes|noembed|iframe|xmp/i.test(node.tagName) ); } /** * @private * Replace a range of text and comment nodes with an optional new text node * @param {Element} targetElement * @param {Object} edit */ DOMEditHandler.prototype._textReplace = function (targetElement, edit) { function prevIgnoringHighlights(node) { do { node = node.previousSibling; } while (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME); return node; } function nextIgnoringHighlights(node) { do { node = node.nextSibling; } while (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME); return node; } function lastChildIgnoringHighlights(node) { node = (node.childNodes.length ? node.childNodes.item(node.childNodes.length - 1) : null); if (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME) { node = prevIgnoringHighlights(node); } return node; } var start = (edit.afterID) ? this._queryBracketsID(edit.afterID) : null, startMissing = edit.afterID && !start, end = (edit.beforeID) ? this._queryBracketsID(edit.beforeID) : null, endMissing = edit.beforeID && !end, moveNext = start && nextIgnoringHighlights(start), current = moveNext || (end && prevIgnoringHighlights(end)) || lastChildIgnoringHighlights(targetElement), next, textNode = (edit.content !== undefined) ? this.htmlDocument.createTextNode( _isRawTextNode(targetElement) ? edit.content : this._parseEntities(edit.content) ) : null, lastRemovedWasText, isText; // remove all nodes inside the range while (current && (current !== end)) { isText = current.nodeType === Node.TEXT_NODE; // if start is defined, delete following text nodes // if start is not defined, delete preceding text nodes next = (moveNext) ? nextIgnoringHighlights(current) : prevIgnoringHighlights(current); // only delete up to the nearest element. // if the start/end tag was deleted in a prior edit, stop removing // nodes when we hit adjacent text nodes if ((current.nodeType === Node.ELEMENT_NODE) || ((startMissing || endMissing) && (isText && lastRemovedWasText))) { break; } else { lastRemovedWasText = isText; if (current.remove) { current.remove(); } else if (current.parentNode && current.parentNode.removeChild) { current.parentNode.removeChild(current); } current = next; } } if (textNode) { // OK to use nextSibling here (not nextIgnoringHighlights) because we do literally // want to insert immediately after the start tag. if (start && start.nextSibling) { targetElement.insertBefore(textNode, start.nextSibling); } else if (end) { targetElement.insertBefore(textNode, end); } else { targetElement.appendChild(textNode); } } }; /** * @private * Apply an array of DOM edits to the document * @param {Array.