Visual editor (Pro only) (#1828)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Prototype visual editing mode for the preview app. Toggle the mode, pick
elements (single or multiple), and edit margin, padding, border,
background, static text, and text styles with live updates, then save
changes back to code.

- **New Features**
- Pen tool button to enable/disable visual editing in the preview and
toggle single/multi select; pro-only.
- Inline toolbar anchored to the selected element for Margin (X/Y),
Padding (X/Y), Border (width/radius/color), Background color, Edit Text
(when static), and Text Style (font size/weight/color/font family).
- Reads computed styles from the iframe and applies changes in real
time; auto-appends px; overlay updates on scroll/resize.
- Save/Discard dialog batches edits and writes Tailwind classes to
source files via IPC; uses AST/recast to update className and text,
replacing conflicting classes by prefix; supports multiple components.
- New visual editor worker to get/apply styles and enable inline text
editing via postMessage; selector client updated for coordinates
streaming and highlight/deselect.
- Proxy injects the visual editor client; new atoms track selected
component, coordinates, and pending changes; component analysis flags
dynamic styling and static text.
  - Uses runtimeId to correctly target and edit duplicate components.

- **Dependencies**
  - Added @babel/parser for AST-based text updates.
  - Added recast for safer code transformations.

<sup>Written for commit cdd50d33387a29103864f4743ae7570d64d61e93.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Mohamed Aziz Mejri
2025-12-09 22:09:19 +01:00
committed by GitHub
parent c174778d5f
commit 352d4330ed
28 changed files with 3455 additions and 65 deletions

View File

@@ -4,6 +4,9 @@
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;
@@ -51,7 +54,7 @@
return { overlay, label };
}
function updateOverlay(el, isSelected = false) {
function updateOverlay(el, isSelected = false, isHighlighted = false) {
// If no element, hide hover overlay
if (!el) {
if (hoverOverlay) hoverOverlay.style.display = "none";
@@ -67,14 +70,19 @@
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 #7f22fe",
background: "rgba(127, 34, 254, 0.05)",
border: `3px solid ${borderColor}`,
background: backgroundColor,
});
css(label, { display: "none" });
@@ -143,6 +151,30 @@
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() {
@@ -156,17 +188,70 @@
}
currentHoveredElement = null;
highlightedElement = null;
}
function removeOverlayById(componentId) {
const index = overlays.findIndex(
({ el }) => el.dataset.dyadId === componentId,
);
if (index !== -1) {
const { overlay } = overlays[index];
// 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(index, 1);
overlays.splice(indicesToRemove[i], 1);
}
if (
highlightedElement &&
highlightedElement.dataset.dyadId === componentId
) {
highlightedElement = null;
}
}
// 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
@@ -227,11 +312,43 @@
/* ---------- 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;
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,
@@ -243,8 +360,8 @@
currentHoveredElement = el;
// If hovering over a selected component, show its label
if (hoveredItem) {
// 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";
}
@@ -280,29 +397,76 @@
e.preventDefault();
e.stopPropagation();
const selectedItem = overlays.find((item) => item.el === e.target);
if (selectedItem) {
removeOverlayById(state.element.dataset.dyadId);
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: state.element.dataset.dyadId,
componentId: clickedComponentId,
},
"*",
);
return;
}
updateOverlay(state.element, true);
// 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)",
});
}
}
requestAnimationFrame(updateAllOverlayPositions);
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: state.element.dataset.dyadId,
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,
},
},
"*",
@@ -362,10 +526,30 @@
/* ---------- message bridge -------------------------------------------- */
window.addEventListener("message", (e) => {
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 === "remove-dyad-component-overlay") {
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);
}
@@ -380,8 +564,9 @@
document.addEventListener("mouseleave", onMouseLeave, true);
// Update overlay positions on window resize
// Update overlay positions on window resize and scroll
window.addEventListener("resize", updateAllOverlayPositions);
window.addEventListener("scroll", updateAllOverlayPositions, true);
function initializeComponentSelector() {
if (!document.body) {