Files
Housekeeping-web-admin/node_modules/@base44/vite-plugin/dist/injections/visual-edit-agent.js
T
2026-06-24 09:48:54 +02:00

631 lines
27 KiB
JavaScript

import { findElementsById, updateElementClasses, updateElementAttribute, collectAllowedAttributes, ALLOWED_ATTRIBUTES, getElementSelectorId, stopAnimations, resumeAnimations, findInstrumentedElement, resolveHoverTarget, positionLabel, injectFontFaceCss } from "./utils.js";
import { createLayerController } from "./layer-dropdown/controller.js";
import { LAYER_DROPDOWN_ATTR } from "./layer-dropdown/consts.js";
import { createInlineEditController } from "../capabilities/inline-edit/index.js";
import { THEME_FONT_PREVIEW_ID } from "../consts.js";
import { createCanvasWheelZoomBridgeController } from "./canvas-wheel-zoom-bridge.js";
import { createPageHeightBridgeController } from "./page-height-bridge.js";
import { createAutoPopupSuppressorController } from "./auto-popup-suppressor.js";
const REPOSITION_DELAY_MS = 50;
export function setupVisualEditAgent() {
const canvasWheelZoomBridge = createCanvasWheelZoomBridgeController();
const pageHeightBridge = createPageHeightBridgeController();
const autoPopupSuppressor = createAutoPopupSuppressorController();
// State variables (replacing React useState/useRef)
let isVisualEditMode = false;
let isPopoverDragging = false;
let isDropdownOpen = false;
let hoverOverlays = [];
let selectedOverlays = [];
let currentHighlightedElements = [];
let selectedElementId = null;
let selectedElement = null;
// Create overlay element
const createOverlay = (isSelected = false) => {
const overlay = document.createElement("div");
overlay.style.position = "absolute";
overlay.style.pointerEvents = "none";
overlay.style.transition = "all 0.1s ease-in-out";
overlay.style.zIndex = "9999";
if (isSelected) {
overlay.style.border = "2px solid #2563EB";
}
else {
overlay.style.border = "2px solid #95a5fc";
overlay.style.backgroundColor = "rgba(99, 102, 241, 0.05)";
}
return overlay;
};
// Position overlay relative to element
const positionOverlay = (overlay, element, isSelected = false) => {
if (!element || !isVisualEditMode)
return;
const htmlElement = element;
// Force layout recalculation
void htmlElement.offsetWidth;
const rect = element.getBoundingClientRect();
overlay.style.top = `${rect.top + window.scrollY}px`;
overlay.style.left = `${rect.left + window.scrollX}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
// Check if label already exists in overlay
let label = overlay.querySelector("div");
if (!label) {
label = document.createElement("div");
label.textContent = element.tagName.toLowerCase();
label.style.position = "absolute";
label.style.left = "-2px";
label.style.padding = "2px 8px";
label.style.fontSize = "11px";
label.style.fontWeight = isSelected ? "500" : "400";
label.style.color = isSelected ? "#ffffff" : "#526cff";
label.style.backgroundColor = isSelected ? "#2563EB" : "#DBEAFE";
label.style.borderRadius = "3px";
label.style.minWidth = "24px";
label.style.textAlign = "center";
overlay.appendChild(label);
}
positionLabel(label, rect);
};
// --- Inline edit controller ---
const inlineEdit = createInlineEditController({
findElementsById,
getSelectedElementId: () => selectedElementId,
getSelectedOverlays: () => selectedOverlays,
positionOverlay,
clearSelection: () => {
inlineEdit.clearSelectedMarks(selectedElementId);
clearSelectedOverlays();
selectedElementId = null;
selectedElement = null;
},
createSelectionOverlays: (elements, elementId) => {
elements.forEach((el) => {
const overlay = createOverlay(true);
document.body.appendChild(overlay);
selectedOverlays.push(overlay);
positionOverlay(overlay, el, true);
});
selectedElementId = elementId;
},
});
const clearSelection = () => {
inlineEdit.clearSelectedMarks(selectedElementId);
clearSelectedOverlays();
selectedElementId = null;
selectedElement = null;
};
// Clear hover overlays
const clearHoverOverlays = () => {
hoverOverlays.forEach((overlay) => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
hoverOverlays = [];
currentHighlightedElements = [];
};
const clearSelectedOverlays = () => {
selectedOverlays.forEach((overlay) => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
selectedOverlays = [];
};
const TEXT_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'a', 'label'];
const notifyElementSelected = (element) => {
const htmlElement = element;
const rect = element.getBoundingClientRect();
const svgElement = element;
const isTextElement = TEXT_TAGS.includes(element.tagName?.toLowerCase());
const arrEl = htmlElement.closest("[data-arr-variable-name]");
const staticArrayName = arrEl?.dataset?.arrVariableName || null;
const rawIdx = arrEl?.dataset?.arrIndex;
const staticArrayIndex = rawIdx != null ? parseInt(rawIdx, 10) : null;
const staticArrayField = htmlElement.dataset?.arrField || null;
const collectionEl = htmlElement.closest("[data-collection-id]");
const itemFieldEl = htmlElement.closest("[data-collection-item-field]");
const itemIdEl = htmlElement.closest("[data-collection-item-id]");
window.parent.postMessage({
type: "element-selected",
tagName: element.tagName,
classes: svgElement.className?.baseVal ||
element.className ||
"",
visualSelectorId: getElementSelectorId(element),
content: isTextElement ? htmlElement.innerText : undefined,
dataSourceLocation: htmlElement.dataset.sourceLocation,
isDynamicContent: htmlElement.dataset.dynamicContent === "true",
linenumber: htmlElement.dataset.linenumber,
filename: htmlElement.dataset.filename,
position: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
},
attributes: collectAllowedAttributes(element, ALLOWED_ATTRIBUTES),
isTextElement,
staticArrayName,
staticArrayIndex,
staticArrayField,
collectionId: collectionEl?.dataset?.collectionId || null,
collectionItemField: itemFieldEl?.dataset?.collectionItemField || null,
collectionItemId: itemIdEl?.dataset?.collectionItemId || null,
}, "*");
};
// Select an element: create overlays, update state, notify parent
const selectElement = (element) => {
const visualSelectorId = getElementSelectorId(element);
clearSelectedOverlays();
const elements = findElementsById(visualSelectorId || null);
elements.forEach((el) => {
const overlay = createOverlay(true);
document.body.appendChild(overlay);
selectedOverlays.push(overlay);
positionOverlay(overlay, el, true);
});
selectedElementId = visualSelectorId || null;
selectedElement = element;
clearHoverOverlays();
notifyElementSelected(element);
return selectedOverlays[0];
};
const notifyDeselection = () => {
selectedElementId = null;
window.parent.postMessage({ type: "unselect-element" }, "*");
};
// Hover detection via mousemove + elementFromPoint (since app elements have pointer-events: none)
let lastHoveredSelectorId = null;
let pendingMouseMoveRaf = null;
const clearHoverState = () => {
clearHoverOverlays();
lastHoveredSelectorId = null;
};
const applyHoverOverlays = (selectorId) => {
const elements = findElementsById(selectorId);
clearHoverOverlays();
elements.forEach((el) => {
const overlay = createOverlay(false);
document.body.appendChild(overlay);
hoverOverlays.push(overlay);
positionOverlay(overlay, el);
});
currentHighlightedElements = elements;
lastHoveredSelectorId = selectorId;
};
const handleMouseMove = (e) => {
if (!isVisualEditMode || isPopoverDragging || inlineEdit.isEditing())
return;
if (pendingMouseMoveRaf !== null)
return;
pendingMouseMoveRaf = requestAnimationFrame(() => {
pendingMouseMoveRaf = null;
if (isDropdownOpen) {
clearHoverState();
return;
}
const selectorId = resolveHoverTarget(e.clientX, e.clientY, selectedElementId);
if (!selectorId) {
clearHoverState();
return;
}
if (lastHoveredSelectorId === selectorId)
return;
applyHoverOverlays(selectorId);
});
};
// Clear hover overlays when mouse leaves the viewport
const handleMouseLeave = () => {
if (pendingMouseMoveRaf !== null) {
cancelAnimationFrame(pendingMouseMoveRaf);
pendingMouseMoveRaf = null;
}
clearHoverState();
};
// Handle element click
const handleElementClick = (e) => {
if (!isVisualEditMode)
return;
const target = e.target;
// Let layer dropdown clicks pass through without interference
if (target.closest(`[${LAYER_DROPDOWN_ATTR}]`))
return;
// Let clicks inside the editable element pass through to the browser
// so the user can reposition the cursor and select text naturally.
if (inlineEdit.enabled && target instanceof HTMLElement && target.contentEditable === "true") {
return;
}
// Clicking outside the editable element exits inline editing mode.
if (inlineEdit.isEditing()) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
inlineEdit.stopEditing();
return;
}
// Close dropdowns when clicking anywhere in iframe if a dropdown is open
if (isDropdownOpen) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
window.parent.postMessage({ type: "close-dropdowns" }, "*");
return;
}
// Prevent default behavior immediately when in visual edit mode
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const element = findInstrumentedElement(e.clientX, e.clientY);
if (!element) {
return;
}
const htmlElement = element;
const visualSelectorId = getElementSelectorId(element);
const isAlreadySelected = selectedElementId === visualSelectorId &&
htmlElement.dataset.selected === "true";
if (isAlreadySelected && inlineEdit.enabled && inlineEdit.canEdit(htmlElement)) {
inlineEdit.startEditing(htmlElement);
return;
}
inlineEdit.stopEditing();
if (inlineEdit.enabled) {
inlineEdit.markElementsSelected(findElementsById(visualSelectorId));
}
const selectedOverlay = selectElement(element);
layerController.attachToOverlay(selectedOverlay, element);
};
const unselectElement = () => {
inlineEdit.stopEditing();
clearSelection();
};
const updateElementClassesAndReposition = (visualSelectorId, classes) => {
const elements = findElementsById(visualSelectorId);
if (elements.length === 0)
return;
updateElementClasses(elements, classes);
// Use a small delay to allow the browser to recalculate layout before repositioning
setTimeout(() => {
// Reposition selected overlays
if (selectedElementId === visualSelectorId) {
selectedOverlays.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
// Reposition hover overlays if needed
if (currentHighlightedElements.length > 0) {
const hoveredElement = currentHighlightedElements[0];
const hoveredId = hoveredElement?.dataset?.visualSelectorId;
if (hoveredId === visualSelectorId) {
hoverOverlays.forEach((overlay, index) => {
if (index < currentHighlightedElements.length) {
positionOverlay(overlay, currentHighlightedElements[index]);
}
});
}
}
}, REPOSITION_DELAY_MS);
};
// Update element attribute by visual selector ID
const updateElementAttributeAndReposition = (visualSelectorId, attribute, value, arrIndex) => {
const elements = findElementsById(visualSelectorId);
if (elements.length === 0)
return;
const targetArrIndex = arrIndex ??
(selectedElementId === visualSelectorId
? selectedElement?.dataset.arrIndex
: undefined);
updateElementAttribute(elements, attribute, value, targetArrIndex);
// Reposition overlays after attribute change (e.g. image src swap can affect layout)
setTimeout(() => {
if (selectedElementId === visualSelectorId) {
selectedOverlays.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
}, REPOSITION_DELAY_MS);
};
const updateElementContent = (visualSelectorId, content, arrIndex) => {
let elements = findElementsById(visualSelectorId);
if (elements.length === 0) {
return;
}
if (arrIndex != null) {
elements = elements.filter((el) => el.dataset.arrIndex === String(arrIndex));
}
elements.forEach((element) => {
element.innerText = content;
});
setTimeout(() => {
if (selectedElementId === visualSelectorId) {
selectedOverlays.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
}, REPOSITION_DELAY_MS);
};
// --- Layer dropdown controller ---
const layerController = createLayerController({
createPreviewOverlay: (element) => {
const overlay = createOverlay(false);
overlay.style.zIndex = "9998";
document.body.appendChild(overlay);
positionOverlay(overlay, element);
return overlay;
},
getSelectedElementId: () => selectedElementId,
selectElement,
onDeselect: notifyDeselection,
});
// Toggle visual edit mode
const toggleVisualEditMode = (isEnabled) => {
isVisualEditMode = isEnabled;
if (!isEnabled) {
canvasWheelZoomBridge.disable();
resumeAnimations();
inlineEdit.stopEditing();
clearSelection();
layerController.cleanup();
handleMouseLeave();
document.body.style.cursor = "default";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
document.removeEventListener("click", handleElementClick, true);
}
else {
document.body.style.cursor = "crosshair";
stopAnimations();
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave);
document.addEventListener("click", handleElementClick, true);
}
};
// Handle scroll events to update popover position
const handleScroll = () => {
if (selectedElementId) {
const element = selectedElement;
if (element && element.isConnected) {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const isInViewport = rect.top < viewportHeight &&
rect.bottom > 0 &&
rect.left < viewportWidth &&
rect.right > 0;
const elementPosition = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
};
window.parent.postMessage({
type: "element-position-update",
position: elementPosition,
isInViewport: isInViewport,
visualSelectorId: selectedElementId,
}, "*");
}
}
};
// Handle messages from parent window
const handleMessage = (event) => {
const message = event.data;
switch (message.type) {
case "toggle-visual-edit-mode":
toggleVisualEditMode(message.data.enabled);
if (message.data.specs?.newInlineEditEnabled !== undefined) {
inlineEdit.enabled = message.data.specs.newInlineEditEnabled;
}
break;
case "update-classes":
if (message.data && message.data.classes !== undefined) {
updateElementClassesAndReposition(message.data.visualSelectorId, message.data.classes);
}
else {
console.warn("[VisualEditAgent] Invalid update-classes message:", message);
}
break;
case "update-attribute":
if (message.data &&
message.data.visualSelectorId &&
message.data.attribute !== undefined &&
message.data.value !== undefined) {
updateElementAttributeAndReposition(message.data.visualSelectorId, message.data.attribute, message.data.value, message.data.arrIndex);
}
else {
console.warn("[VisualEditAgent] Invalid update-attribute message:", message);
}
break;
case "unselect-element":
unselectElement();
break;
case "refresh-page":
window.location.reload();
break;
case "update-content":
if (message.data && message.data.content !== undefined) {
updateElementContent(message.data.visualSelectorId, message.data.content, message.data.arrIndex);
}
else {
console.warn("[VisualEditAgent] Invalid update-content message:", message);
}
break;
case "request-element-position":
if (selectedElementId && selectedElement && selectedElement.isConnected) {
const rect = selectedElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const isInViewport = rect.top < viewportHeight &&
rect.bottom > 0 &&
rect.left < viewportWidth &&
rect.right > 0;
const elementPosition = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
};
window.parent.postMessage({
type: "element-position-update",
position: elementPosition,
isInViewport: isInViewport,
visualSelectorId: selectedElementId,
}, "*");
}
break;
case "popover-drag-state":
if (message.data && message.data.isDragging !== undefined) {
isPopoverDragging = message.data.isDragging;
if (message.data.isDragging) {
clearHoverOverlays();
}
}
break;
case "dropdown-state":
if (message.data && message.data.isOpen !== undefined) {
isDropdownOpen = message.data.isOpen;
if (message.data.isOpen) {
clearHoverOverlays();
}
}
break;
case "update-theme-variables":
if (message.data?.variables) {
const target = message.data.mode === 'dark'
? document.querySelector('.dark')
: document.documentElement;
if (target) {
for (const [name, value] of Object.entries(message.data.variables)) {
target.style.setProperty(name, value);
}
}
}
break;
case "inject-font-import":
if (message.data?.fontUrl) {
let fontStyle = document.getElementById(THEME_FONT_PREVIEW_ID);
if (!fontStyle) {
fontStyle = document.createElement('style');
fontStyle.id = THEME_FONT_PREVIEW_ID;
document.head.appendChild(fontStyle);
}
fontStyle.textContent = `@import url('${message.data.fontUrl}');`;
}
break;
case "inject-font-face":
if (message.data) {
injectFontFaceCss(message.data.id, message.data.css);
}
break;
case "toggle-inline-edit-mode":
if (message.data) {
inlineEdit.handleToggleMessage(message.data);
}
break;
case "freeze-vh-units":
pageHeightBridge.freezeVhUnits(typeof message.referenceVhBase === "number"
? message.referenceVhBase
: undefined);
break;
case "measure-page-height":
pageHeightBridge.measurePageHeight(event.origin && event.origin !== "null" ? event.origin : "*", typeof message.settleMs === "number" ? message.settleMs : undefined);
break;
case "suppress-auto-popups":
autoPopupSuppressor.suppress();
break;
case "toggle-canvas-wheel-zoom-bridge":
canvasWheelZoomBridge.enable();
break;
default:
break;
}
};
// Handle window resize to reposition overlays
const handleResize = () => {
if (selectedElementId) {
const elements = findElementsById(selectedElementId);
selectedOverlays.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
if (currentHighlightedElements.length > 0) {
hoverOverlays.forEach((overlay, index) => {
if (index < currentHighlightedElements.length) {
positionOverlay(overlay, currentHighlightedElements[index]);
}
});
}
};
// Initialize: Add IDs to elements that don't have them but have linenumbers
const elementsWithLineNumber = document.querySelectorAll("[data-linenumber]:not([data-visual-selector-id])");
elementsWithLineNumber.forEach((el, index) => {
const htmlEl = el;
const id = `visual-id-${htmlEl.dataset.filename}-${htmlEl.dataset.linenumber}-${index}`;
htmlEl.dataset.visualSelectorId = id;
});
// Create mutation observer to detect layout changes
const mutationObserver = new MutationObserver((mutations) => {
const needsUpdate = mutations.some((mutation) => {
const hasVisualId = (node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node;
if (el.dataset && el.dataset.visualSelectorId) {
return true;
}
for (let i = 0; i < el.children.length; i++) {
if (hasVisualId(el.children[i])) {
return true;
}
}
}
return false;
};
const isLayoutChange = mutation.type === "attributes" &&
(mutation.attributeName === "style" ||
mutation.attributeName === "class" ||
mutation.attributeName === "width" ||
mutation.attributeName === "height");
return isLayoutChange && hasVisualId(mutation.target);
});
if (needsUpdate) {
setTimeout(handleResize, REPOSITION_DELAY_MS);
}
});
// Set up event listeners
window.addEventListener("message", handleMessage);
window.addEventListener("scroll", handleScroll, true);
document.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize);
// Start observing DOM mutations
mutationObserver.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ["style", "class", "width", "height"],
});
// Send ready message to parent
window.parent.postMessage({ type: "visual-edit-agent-ready" }, "*");
}
//# sourceMappingURL=visual-edit-agent.js.map