Ctrl K
ComunidadGuías…

worker/dyad-component-selector-client.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
(() => {
  const OVERLAY_CLASS = "__dyad_overlay__";
  let overlays = [];
  let hoverOverlay = null;
  let hoverLabel = null;
  let currentHoveredElement = null;
  let highlightedElement = null;
  let componentCoordinates = null; // Store the last selected component's coordinates
  let isProMode = false; // Track if pro mode is enabled
  //detect if the user is using Mac
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;

  // The possible states are:
  // { type: 'inactive' }
  // { type: 'inspecting', element: ?HTMLElement }
  // { type: 'selected', element: HTMLElement }
  let state = { type: "inactive" };

  /* ---------- helpers --------------------------------------------------- */
  const css = (el, obj) => Object.assign(el.style, obj);

  function makeOverlay() {
    const overlay = document.createElement("div");
    overlay.className = OVERLAY_CLASS;
    css(overlay, {
      position: "absolute",
      border: "2px solid #7f22fe",
      background: "rgba(0,170,255,.05)",
      pointerEvents: "none",
      zIndex: "2147483647", // max
      borderRadius: "4px",
      boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
    });

    const label = document.createElement("div");
    css(label, {
      position: "absolute",
      left: "0",
      top: "100%",
      transform: "translateY(4px)",
      background: "#7f22fe",
      color: "#fff",
      fontFamily: "monospace",
      fontSize: "12px",
      lineHeight: "1.2",
      padding: "3px 5px",
      whiteSpace: "nowrap",
      borderRadius: "4px",
      boxShadow: "0 1px 4px rgba(0, 0, 0, 0.1)",
    });
    overlay.appendChild(label);
    document.body.appendChild(overlay);

    return { overlay, label };
  }

  function updateOverlay(el, isSelected = false, isHighlighted = false) {
    // If no element, hide hover overlay
    if (!el) {
      if (hoverOverlay) hoverOverlay.style.display = "none";
      return;
    }

    if (isSelected) {
      if (overlays.some((item) => item.el === el)) {
        return;
      }

      const { overlay, label } = makeOverlay();
      overlays.push({ overlay, label, el });

      const rect = el.getBoundingClientRect();
      const borderColor = isHighlighted ? "#00ff00" : "#7f22fe";
      const backgroundColor = isHighlighted
        ? "rgba(0, 255, 0, 0.05)"
        : "rgba(127, 34, 254, 0.05)";

      css(overlay, {
        top: `${rect.top + window.scrollY}px`,
        left: `${rect.left + window.scrollX}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
        display: "block",
        border: `3px solid ${borderColor}`,
        background: backgroundColor,
      });

      css(label, { display: "none" });

      return;
    }

    // Otherwise, this is a hover overlay: reuse the hover overlay node
    if (!hoverOverlay || !hoverLabel) {
      const o = makeOverlay();
      hoverOverlay = o.overlay;
      hoverLabel = o.label;
    }

    const rect = el.getBoundingClientRect();
    css(hoverOverlay, {
      top: `${rect.top + window.scrollY}px`,
      left: `${rect.left + window.scrollX}px`,
      width: `${rect.width}px`,
      height: `${rect.height}px`,
      display: "block",
      border: "2px solid #7f22fe",
      background: "rgba(0,170,255,.05)",
    });
    css(hoverLabel, { background: "#7f22fe" });
    while (hoverLabel.firstChild) hoverLabel.removeChild(hoverLabel.firstChild);
    const name = el.dataset.dyadName || "<unknown>";
    const file = (el.dataset.dyadId || "").split(":")[0];
    const nameEl = document.createElement("div");
    nameEl.textContent = name;
    hoverLabel.appendChild(nameEl);
    if (file) {
      const fileEl = document.createElement("span");
      css(fileEl, { fontSize: "10px", opacity: ".8" });
      fileEl.textContent = file.replace(/\\/g, "/");
      hoverLabel.appendChild(fileEl);
    }

    // Update positions after showing hover label in case it caused layout shift
    requestAnimationFrame(updateAllOverlayPositions);
  }

  function updateAllOverlayPositions() {
    // Update all selected overlays
    overlays.forEach(({ overlay, el }) => {
      const rect = el.getBoundingClientRect();
      css(overlay, {
        top: `${rect.top + window.scrollY}px`,
        left: `${rect.left + window.scrollX}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
      });
    });

    // Update hover overlay if visible
    if (
      hoverOverlay &&
      hoverOverlay.style.display !== "none" &&
      state.element
    ) {
      const rect = state.element.getBoundingClientRect();
      css(hoverOverlay, {
        top: `${rect.top + window.scrollY}px`,
        left: `${rect.left + window.scrollX}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
      });
    }

    // Send updated coordinates for highlighted or selected component to parent
    if (highlightedElement) {
      // Multi-selector mode: send coordinates for the highlighted component
      const highlightedItem = overlays.find(
        ({ el }) => el === highlightedElement,
      );

      if (highlightedItem) {
        const rect = highlightedItem.el.getBoundingClientRect();
        window.parent.postMessage(
          {
            type: "dyad-component-coordinates-updated",
            coordinates: {
              top: rect.top,
              left: rect.left,
              width: rect.width,
              height: rect.height,
            },
          },
          "*",
        );
      }
    }
  }

  function clearOverlays() {
    overlays.forEach(({ overlay }) => overlay.remove());
    overlays = [];

    if (hoverOverlay) {
      hoverOverlay.remove();
      hoverOverlay = null;
      hoverLabel = null;
    }

    currentHoveredElement = null;
    highlightedElement = null;
  }

  function removeOverlayById(componentId) {
    // Remove all overlays with the same componentId
    const indicesToRemove = [];
    overlays.forEach((item, index) => {
      if (item.el.dataset.dyadId === componentId) {
        indicesToRemove.push(index);
      }
    });

    // Remove in reverse order to maintain correct indices
    for (let i = indicesToRemove.length - 1; i >= 0; i--) {
      const { overlay } = overlays[indicesToRemove[i]];
      overlay.remove();
      overlays.splice(indicesToRemove[i], 1);
    }

    if (
      highlightedElement &&
      highlightedElement.dataset.dyadId === componentId
    ) {
      highlightedElement = null;
    }
  }

  /**
   * Detects if an element is a non-interactive overlay (e.g. a gradient div
   * with absolute positioning covering its parent). When such an element is
   * the click target it blocks selection of the meaningful content underneath.
   * Returns the parent dyad-tagged element if the current one is an overlay,
   * or the element itself otherwise.
   */
  function skipOverlayElement(el) {
    if (!el || !el.parentElement) return el;

    // Never skip content-bearing elements
    const tag = el.tagName.toLowerCase();
    if (
      tag === "img" ||
      tag === "video" ||
      tag === "canvas" ||
      tag === "svg" ||
      tag === "iframe"
    ) {
      return el;
    }

    const style = getComputedStyle(el);

    // Only consider absolutely/fixed positioned elements
    if (style.position !== "absolute" && style.position !== "fixed") return el;

    // Don't skip scrollable containers (e.g. message lists with overflow-y-auto)
    if (style.overflowY === "auto" || style.overflowY === "scroll") return el;

    // Must cover a large portion of its parent (inset-0 pattern)
    const parentRect = el.parentElement.getBoundingClientRect();
    const elRect = el.getBoundingClientRect();

    if (parentRect.width === 0 || parentRect.height === 0) return el;

    const widthRatio = elRect.width / parentRect.width;
    const heightRatio = elRect.height / parentRect.height;

    // 98% accounts for sub-pixel rounding from borders/box-sizing while
    // being tight enough to only match true inset-0 overlays.
    if (widthRatio < 0.98 || heightRatio < 0.98) return el;

    // This looks like an overlay — walk up to the parent with a dyad-id
    let parent = el.parentElement;
    while (parent && !parent.dataset.dyadId) parent = parent.parentElement;

    return parent || el;
  }

  // Helper function to check if mouse is over the toolbar
  function isMouseOverToolbar(mouseX, mouseY) {
    if (!componentCoordinates) return false;

    // Toolbar is positioned at bottom of component: top = coordinates.top + coordinates.height + 4px
    const toolbarTop =
      componentCoordinates.top + componentCoordinates.height + 4;
    const toolbarLeft = componentCoordinates.left;
    const toolbarHeight = 60;
    // Add some padding to the width since we don't know exact width
    const toolbarWidth = componentCoordinates.width || 400;

    return (
      mouseY >= toolbarTop &&
      mouseY <= toolbarTop + toolbarHeight &&
      mouseX >= toolbarLeft &&
      mouseX <= toolbarLeft + toolbarWidth
    );
  }

  // Helper function to check if the highlighted component is inside another selected component
  function isHighlightedComponentChildOfSelected() {
    if (!highlightedElement) return null;

    const highlightedItem = overlays.find(
      ({ el }) => el === highlightedElement,
    );
    if (!highlightedItem) return null;

    // Check if any other selected component contains the highlighted element
    for (const item of overlays) {
      if (item.el === highlightedItem.el) continue; // Skip the highlighted component itself
      if (item.el.contains(highlightedItem.el)) {
        return item; // Return the parent component
      }
    }
    return null;
  }

  // Helper function to show/hide and populate label for a selected overlay
  function updateSelectedOverlayLabel(item, show) {
    const { label, el } = item;

    if (!show) {
      css(label, { display: "none" });
      // Update positions after hiding label in case it caused layout shift
      requestAnimationFrame(updateAllOverlayPositions);
      return;
    }

    // Clear and populate label
    css(label, { display: "block", background: "#7f22fe" });
    while (label.firstChild) label.removeChild(label.firstChild);

    // Add "Edit with AI" line
    const editLine = document.createElement("div");
    const svgNS = "http://www.w3.org/2000/svg";
    const svg = document.createElementNS(svgNS, "svg");
    svg.setAttribute("width", "12");
    svg.setAttribute("height", "12");
    svg.setAttribute("viewBox", "0 0 16 16");
    svg.setAttribute("fill", "none");
    Object.assign(svg.style, {
      display: "inline-block",
      verticalAlign: "-2px",
      marginRight: "4px",
    });
    const path = document.createElementNS(svgNS, "path");
    path.setAttribute(
      "d",
      "M8 0L9.48528 6.51472L16 8L9.48528 9.48528L8 16L6.51472 9.48528L0 8L6.51472 6.51472L8 0Z",
    );
    path.setAttribute("fill", "white");
    svg.appendChild(path);
    editLine.appendChild(svg);
    editLine.appendChild(document.createTextNode("Edit with AI"));
    label.appendChild(editLine);

    // Add component name and file
    const name = el.dataset.dyadName || "<unknown>";
    const file = (el.dataset.dyadId || "").split(":")[0];
    const nameEl = document.createElement("div");
    nameEl.textContent = name;
    label.appendChild(nameEl);
    if (file) {
      const fileEl = document.createElement("span");
      css(fileEl, { fontSize: "10px", opacity: ".8" });
      fileEl.textContent = file.replace(/\\/g, "/");
      label.appendChild(fileEl);
    }

    // Update positions after showing label in case it caused layout shift
    requestAnimationFrame(updateAllOverlayPositions);
  }

  /* ---------- event handlers -------------------------------------------- */
  function onMouseMove(e) {
    // Check if mouse is over toolbar - if so, hide the label and treat as if mouse left component
    if (isMouseOverToolbar(e.clientX, e.clientY)) {
      if (currentHoveredElement) {
        const previousItem = overlays.find(
          (item) => item.el === currentHoveredElement,
        );
        if (previousItem) {
          updateSelectedOverlayLabel(previousItem, false);
        }
        currentHoveredElement = null;
      }
      return;
    }

    let el = e.target;
    while (el && !el.dataset.dyadId) el = el.parentElement;
    if (el) el = skipOverlayElement(el);

    const hoveredItem = overlays.find((item) => item.el === el);

    // Check if the highlighted component is a child of another selected component
    const parentOfHighlighted = isHighlightedComponentChildOfSelected();

    // If hovering over the highlighted component and it has a parent, hide the parent's label
    if (
      hoveredItem &&
      hoveredItem.el === highlightedElement &&
      parentOfHighlighted
    ) {
      // Hide the parent component's label
      updateSelectedOverlayLabel(parentOfHighlighted, false);
      // Also clear currentHoveredElement if it's the parent
      if (currentHoveredElement === parentOfHighlighted.el) {
        currentHoveredElement = null;
      }
      return;
    }

    if (currentHoveredElement && currentHoveredElement !== el) {
      const previousItem = overlays.find(
        (item) => item.el === currentHoveredElement,
      );
      if (previousItem) {
        updateSelectedOverlayLabel(previousItem, false);
      }
    }

    currentHoveredElement = el;

    // If hovering over a selected component, show its label only if it's not highlighted
    if (hoveredItem && hoveredItem.el !== highlightedElement) {
      updateSelectedOverlayLabel(hoveredItem, true);
      if (hoverOverlay) hoverOverlay.style.display = "none";
    }

    // Handle inspecting state (component selector is active)
    if (state.type === "inspecting") {
      if (state.element === el) return;
      state.element = el;

      if (!hoveredItem && el) {
        updateOverlay(el, false);
      } else if (!el) {
        if (hoverOverlay) hoverOverlay.style.display = "none";
      }
    }
  }

  function onMouseLeave(e) {
    if (!e.relatedTarget) {
      if (hoverOverlay) {
        hoverOverlay.style.display = "none";
        requestAnimationFrame(updateAllOverlayPositions);
      }
      currentHoveredElement = null;
      if (state.type === "inspecting") {
        state.element = null;
      }
    }
  }

  function onClick(e) {
    if (state.type !== "inspecting" || !state.element) return;
    e.preventDefault();
    e.stopPropagation();

    const clickedComponentId = state.element.dataset.dyadId;
    const selectedItem = overlays.find((item) => item.el === state.element);

    // If clicking on the currently highlighted component, deselect it
    if (selectedItem && (highlightedElement === state.element || !isProMode)) {
      if (state.element.contentEditable === "true") {
        return;
      }

      removeOverlayById(clickedComponentId);
      requestAnimationFrame(updateAllOverlayPositions);
      highlightedElement = null;

      // Only post message once for all elements with the same ID
      window.parent.postMessage(
        {
          type: "dyad-component-deselected",
          componentId: clickedComponentId,
        },
        "*",
      );
      return;
    }

    // Update only the previously highlighted component
    if (highlightedElement && highlightedElement !== state.element) {
      const previousItem = overlays.find(
        (item) => item.el === highlightedElement,
      );
      if (previousItem) {
        css(previousItem.overlay, {
          border: `3px solid #7f22fe`,
          background: "rgba(127, 34, 254, 0.05)",
        });
      }
    }

    highlightedElement = state.element;

    if (selectedItem && isProMode) {
      css(selectedItem.overlay, {
        border: `3px solid #00ff00`,
        background: "rgba(0, 255, 0, 0.05)",
      });
    }

    if (!selectedItem) {
      updateOverlay(state.element, true, isProMode);
      requestAnimationFrame(updateAllOverlayPositions);
    }

    // Assign a unique runtime ID to this element if it doesn't have one
    if (!state.element.dataset.dyadRuntimeId) {
      state.element.dataset.dyadRuntimeId = `dyad-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    }

    const rect = state.element.getBoundingClientRect();
    window.parent.postMessage(
      {
        type: "dyad-component-selected",
        component: {
          id: clickedComponentId,
          name: state.element.dataset.dyadName,
          runtimeId: state.element.dataset.dyadRuntimeId,
        },
        coordinates: {
          top: rect.top,
          left: rect.left,
          width: rect.width,
          height: rect.height,
        },
      },
      "*",
    );
  }

  function onKeyDown(e) {
    const key = e.key.toLowerCase();
    const hasCtrlOrMeta = isMac ? e.metaKey : e.ctrlKey;

    // Electron's native reload accelerators target the whole Dyad renderer.
    // Keep reload shortcuts scoped to the preview even while it owns focus.
    const hasUnexpectedReloadModifier =
      e.altKey || (isMac ? e.ctrlKey : e.metaKey);
    if (key === "r" && hasCtrlOrMeta) {
      if (hasUnexpectedReloadModifier) {
        // Chromium still treats mixed primary-modifier chords as reloads. Let
        // real AltGr input through, but suppress those other browser defaults.
        if (!e.getModifierState("AltGraph")) e.preventDefault();
        return;
      }
      e.preventDefault();
      window.parent.postMessage(
        {
          type: "dyad-preview-reload-shortcut",
        },
        "*",
      );
      return;
    }

    // Ignore keystrokes if the user is typing in an input field, textarea, or editable element
    if (
      e.target.tagName === "INPUT" ||
      e.target.tagName === "TEXTAREA" ||
      e.target.isContentEditable
    ) {
      return;
    }

    // Forward shortcuts to parent window
    const hasShift = e.shiftKey;
    if (key === "c" && hasShift && hasCtrlOrMeta) {
      e.preventDefault();
      window.parent.postMessage(
        {
          type: "dyad-select-component-shortcut",
        },
        "*",
      );
    }
  }

  /* ---------- activation / deactivation --------------------------------- */
  function activate() {
    if (state.type === "inactive") {
      window.addEventListener("click", onClick, true);
    }
    state = { type: "inspecting", element: null };
  }

  function deactivate() {
    if (state.type === "inactive") return;

    window.removeEventListener("click", onClick, true);
    // Don't clear overlays on deactivate - keep selected components visible
    // Hide only the hover overlay and all labels
    if (hoverOverlay) {
      hoverOverlay.style.display = "none";
    }

    // Hide all labels when deactivating
    overlays.forEach((item) => updateSelectedOverlayLabel(item, false));
    currentHoveredElement = null;

    state = { type: "inactive" };
  }

  /* ---------- message bridge -------------------------------------------- */
  window.addEventListener("message", (e) => {
    // The shim can also run inside iframes nested in the app preview. Bubble a
    // child's shortcut request one frame at a time until the top preview frame
    // can deliver it to Dyad with the source identity the parent expects.
    if (
      e.source !== window.parent &&
      e.origin === window.location.origin &&
      e.data?.type === "dyad-preview-reload-shortcut"
    ) {
      window.parent.postMessage(e.data, "*");
      return;
    }
    if (e.source !== window.parent) return;
    if (e.data.type === "dyad-pro-mode") {
      isProMode = e.data.enabled;
    }
    if (e.data.type === "activate-dyad-component-selector") activate();
    if (e.data.type === "deactivate-dyad-component-selector") deactivate();
    if (e.data.type === "activate-dyad-visual-editing") {
      activate();
    }
    if (e.data.type === "deactivate-dyad-visual-editing") {
      deactivate();
      clearOverlays();
    }
    if (e.data.type === "clear-dyad-component-overlays") clearOverlays();
    if (e.data.type === "update-dyad-overlay-positions") {
      updateAllOverlayPositions();
    }
    if (e.data.type === "update-component-coordinates") {
      // Store component coordinates for toolbar hover detection
      componentCoordinates = e.data.coordinates;
    }
    if (
      e.data.type === "remove-dyad-component-overlay" ||
      e.data.type === "deselect-dyad-component"
    ) {
      if (e.data.componentId) {
        removeOverlayById(e.data.componentId);
      }
    }
    if (e.data.type === "restore-dyad-component-overlays") {
      const componentIds = e.data.componentIds;
      if (Array.isArray(componentIds)) {
        clearOverlays();
        for (const id of componentIds) {
          const el = document.querySelector(
            `[data-dyad-id="${CSS.escape(id)}"]`,
          );
          if (el) {
            updateOverlay(el, true);
          }
        }
        requestAnimationFrame(updateAllOverlayPositions);
      }
    }
  });

  // Always listen for keyboard shortcuts
  window.addEventListener("keydown", onKeyDown, true);

  // Always listen for mouse move to show/hide labels on selected overlays
  window.addEventListener("mousemove", onMouseMove, true);

  document.addEventListener("mouseleave", onMouseLeave, true);

  // Update overlay positions on window resize and scroll
  window.addEventListener("resize", updateAllOverlayPositions);
  window.addEventListener("scroll", updateAllOverlayPositions, true);

  function initializeComponentSelector() {
    if (!document.body) {
      console.error(
        "Dyad component selector initialization failed: document.body not found.",
      );
      return;
    }

    // Usually the tagged elements are added right away, but in some cases (e.g.
    // supabase auth loading), it can take a while and thus we use a timeout/observer
    // to wait for tagged elements to appear.
    //
    // see: https://github.com/dyad-sh/dyad/issues/2231
    const INIT_TIMEOUT_MS = 60_000; // Wait up to 60 seconds for tagged elements
    let observer = null;
    let timeoutId = null;

    function checkForTaggedElements() {
      if (document.body.querySelector("[data-dyad-id]")) {
        // Clean up observer and timeout
        if (observer) {
          observer.disconnect();
          observer = null;
        }
        if (timeoutId) {
          clearTimeout(timeoutId);
          timeoutId = null;
        }

        window.parent.postMessage(
          {
            type: "dyad-component-selector-initialized",
          },
          "*",
        );
        console.debug("Dyad component selector initialized");
        return true;
      }
      return false;
    }

    // First, try immediately
    setTimeout(() => {
      if (checkForTaggedElements()) {
        return;
      }

      // If not found, set up MutationObserver to watch for tagged elements
      console.debug(
        "Dyad component selector waiting for tagged elements to appear...",
      );

      observer = new MutationObserver((mutations) => {
        // Filter mutations to only process relevant changes
        const hasRelevantMutation = mutations.some((mutation) => {
          // Attribute mutation on data-dyad-id (already filtered by attributeFilter)
          if (mutation.type === "attributes") {
            return true;
          }
          // Check if any added nodes have data-dyad-id
          if (mutation.type === "childList") {
            for (const node of mutation.addedNodes) {
              if (node.nodeType === Node.ELEMENT_NODE) {
                if (
                  node.hasAttribute("data-dyad-id") ||
                  node.querySelector("[data-dyad-id]")
                ) {
                  return true;
                }
              }
            }
          }
          return false;
        });

        if (hasRelevantMutation) {
          checkForTaggedElements();
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["data-dyad-id"],
      });

      // Set a timeout to give up after INIT_TIMEOUT_MS
      timeoutId = setTimeout(() => {
        if (observer) {
          observer.disconnect();
          observer = null;
        }
        // Only warn if we never found tagged elements
        if (!document.body.querySelector("[data-dyad-id]")) {
          console.warn(
            "Dyad component selector not initialized because no DOM elements were tagged",
          );
        }
      }, INIT_TIMEOUT_MS);
    }, 0);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeComponentSelector);
  } else {
    initializeComponentSelector();
  }
})();