)
+ if (isJSXMemberExpression(jsxOpeningElement.name)) {
+ let current = jsxOpeningElement.name;
+ while (isJSXMemberExpression(current)) {
+ current = current.property;
+ }
+ if (isJSXIdentifier(current)) {
+ return COMPONENT_BLACKLIST.has(current.name);
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Generates code from an AST node
+ * @param {object} node - Babel AST node
+ * @param {object} options - Generator options
+ * @returns {string} Generated code
+ */
+export function generateCode(node, options = {}) {
+ const generateFunction = generate.default || generate;
+ const output = generateFunction(node, options);
+ return output.code;
+}
+
+/**
+ * Generates a full source file from AST with source maps
+ * @param {object} ast - Babel AST
+ * @param {string} sourceFileName - Source file name for source map
+ * @param {string} originalCode - Original source code
+ * @returns {{code: string, map: object}} - Object containing generated code and source map
+ */
+export function generateSourceWithMap(ast, sourceFileName, originalCode) {
+ const generateFunction = generate.default || generate;
+ return generateFunction(ast, {
+ sourceMaps: true,
+ sourceFileName,
+ }, originalCode);
+}
+
+/**
+ * Extracts code blocks from a JSX element at a specific location
+ * @param {string} filePath - Relative file path
+ * @param {number} line - Line number
+ * @param {number} column - Column number
+ * @param {object} [domContext] - Optional DOM context to return on failure
+ * @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
+ */
+export function extractCodeBlocks(filePath, line, column, domContext) {
+ try {
+ // Validate file path
+ const validation = validateFilePath(filePath);
+ if (!validation.isValid) {
+ return { success: false, error: validation.error, domContext };
+ }
+
+ // Parse AST
+ const ast = parseFileToAST(validation.absolutePath);
+
+ // Find target node
+ const targetNodePath = findJSXElementAtPosition(ast, line, column);
+
+ if (!targetNodePath) {
+ return { success: false, error: 'Target node not found at specified line/column', domContext };
+ }
+
+ // Check if the target node is a blacklisted component
+ const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
+
+ if (isBlacklisted) {
+ return {
+ success: true,
+ filePath,
+ specificLine: '',
+ };
+ }
+
+ // Get specific line code
+ const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
+
+ return {
+ success: true,
+ filePath,
+ specificLine,
+ };
+ } catch (error) {
+ console.error('[ast-utils] Error extracting code blocks:', error);
+ return { success: false, error: 'Failed to extract code blocks', domContext };
+ }
+}
+
+/**
+ * Project root path
+ */
+export { VITE_PROJECT_ROOT };
diff --git a/contribute/plugins/visual-editor/edit-mode-script.js b/contribute/plugins/visual-editor/edit-mode-script.js
new file mode 100644
index 0000000..57ed8c6
--- /dev/null
+++ b/contribute/plugins/visual-editor/edit-mode-script.js
@@ -0,0 +1,357 @@
+// eslint-disable-next-line import/no-unresolved
+import { POPUP_STYLES } from "./plugins/visual-editor/visual-editor-config.js";
+
+const PLUGIN_APPLY_EDIT_API_URL = "/api/apply-edit";
+
+const ALLOWED_PARENT_ORIGINS = [
+ "https://horizons.hostinger.com",
+ "https://horizons.hostinger.dev",
+ "https://horizons-frontend-local.hostinger.dev",
+ "http://localhost:4000",
+];
+
+let disabledTooltipElement = null;
+let currentDisabledHoverElement = null;
+
+let translations = {
+ disabledTooltipText: "This text can be changed only through chat.",
+ disabledTooltipTextImage: "This image can only be changed through chat.",
+};
+
+let areStylesInjected = false;
+
+let globalEventHandlers = null;
+
+let currentEditingInfo = null;
+
+function injectPopupStyles() {
+ if (areStylesInjected) return;
+
+ const styleElement = document.createElement("style");
+ styleElement.id = "inline-editor-styles";
+ styleElement.textContent = POPUP_STYLES;
+ document.head.appendChild(styleElement);
+ areStylesInjected = true;
+}
+
+function findEditableElementAtPoint(event) {
+ let editableElement = event.target.closest("[data-edit-id]");
+
+ if (editableElement) {
+ return editableElement;
+ }
+
+ const elementsAtPoint = document.elementsFromPoint(
+ event.clientX,
+ event.clientY
+ );
+
+ const found = elementsAtPoint.find(
+ (el) => el !== event.target && el.hasAttribute("data-edit-id")
+ );
+ if (found) return found;
+
+ return null;
+}
+
+function findDisabledElementAtPoint(event) {
+ const direct = event.target.closest("[data-edit-disabled]");
+ if (direct) return direct;
+ const elementsAtPoint = document.elementsFromPoint(
+ event.clientX,
+ event.clientY
+ );
+ const found = elementsAtPoint.find(
+ (el) => el !== event.target && el.hasAttribute("data-edit-disabled")
+ );
+ if (found) return found;
+ return null;
+}
+
+function showPopup(targetElement, editId, currentContent, isImage = false) {
+ currentEditingInfo = { editId, targetElement };
+
+ const parentOrigin = getParentOrigin();
+
+ if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
+ const eventType = isImage ? "imageEditEnter" : "editEnter";
+
+ window.parent.postMessage(
+ {
+ type: eventType,
+ payload: { currentText: currentContent },
+ },
+ parentOrigin
+ );
+ }
+}
+
+function handleGlobalEvent(event) {
+ if (
+ !document.getElementById("root")?.getAttribute("data-edit-mode-enabled")
+ ) {
+ return;
+ }
+
+ // Don't handle if selection mode is active
+ if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
+ return;
+ }
+
+ if (event.target.closest("#inline-editor-popup")) {
+ return;
+ }
+
+ const editableElement = findEditableElementAtPoint(event);
+
+ if (editableElement) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ if (event.type === "click") {
+ const editId = editableElement.getAttribute("data-edit-id");
+ if (!editId) {
+ console.warn("[INLINE EDITOR] Clicked element missing data-edit-id");
+ return;
+ }
+
+ const isImage = editableElement.tagName.toLowerCase() === "img";
+ let currentContent = "";
+
+ if (isImage) {
+ currentContent = editableElement.getAttribute("src") || "";
+ } else {
+ currentContent = editableElement.textContent || "";
+ }
+
+ showPopup(editableElement, editId, currentContent, isImage);
+ }
+ } else {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ }
+}
+
+function getParentOrigin() {
+ if (
+ window.location.ancestorOrigins &&
+ window.location.ancestorOrigins.length > 0
+ ) {
+ return window.location.ancestorOrigins[0];
+ }
+
+ if (document.referrer) {
+ try {
+ return new URL(document.referrer).origin;
+ } catch (e) {
+ console.warn("Invalid referrer URL:", document.referrer);
+ }
+ }
+
+ return null;
+}
+
+async function handleEditSave(updatedText) {
+ const newText = updatedText
+ // Replacing characters that cause Babel parser to crash
+ .replace(//g, ">")
+ .replace(/{/g, "{")
+ .replace(/}/g, "}");
+
+ const { editId } = currentEditingInfo;
+
+ try {
+ const response = await fetch(PLUGIN_APPLY_EDIT_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ editId: editId,
+ newFullText: newText,
+ }),
+ });
+
+ const result = await response.json();
+ if (result.success) {
+ const parentOrigin = getParentOrigin();
+ if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
+ window.parent.postMessage(
+ {
+ type: "editApplied",
+ payload: {
+ editId: editId,
+ fileContent: result.newFileContent,
+ beforeCode: result.beforeCode,
+ afterCode: result.afterCode,
+ },
+ },
+ parentOrigin
+ );
+ } else {
+ console.error("Unauthorized parent origin:", parentOrigin);
+ }
+ } else {
+ console.error(
+ `[vite][visual-editor] Error saving changes: ${result.error}`
+ );
+ }
+ } catch (error) {
+ console.error(
+ `[vite][visual-editor] Error during fetch for ${editId}:`,
+ error
+ );
+ }
+}
+
+function createDisabledTooltip() {
+ if (disabledTooltipElement) return;
+
+ disabledTooltipElement = document.createElement("div");
+ disabledTooltipElement.id = "inline-editor-disabled-tooltip";
+ document.body.appendChild(disabledTooltipElement);
+}
+
+function showDisabledTooltip(targetElement, isImage = false) {
+ if (!disabledTooltipElement) createDisabledTooltip();
+
+ disabledTooltipElement.textContent = isImage
+ ? translations.disabledTooltipTextImage
+ : translations.disabledTooltipText;
+
+ if (!disabledTooltipElement.isConnected) {
+ document.body.appendChild(disabledTooltipElement);
+ }
+ disabledTooltipElement.classList.add("tooltip-active");
+
+ const tooltipWidth = disabledTooltipElement.offsetWidth;
+ const tooltipHeight = disabledTooltipElement.offsetHeight;
+ const rect = targetElement.getBoundingClientRect();
+
+ // Ensures that tooltip is not off the screen with 5px margin
+ let newLeft = rect.left + window.scrollX + rect.width / 2 - tooltipWidth / 2;
+ let newTop = rect.bottom + window.scrollY + 5;
+
+ if (newLeft < window.scrollX) {
+ newLeft = window.scrollX + 5;
+ }
+ if (newLeft + tooltipWidth > window.innerWidth + window.scrollX) {
+ newLeft = window.innerWidth + window.scrollX - tooltipWidth - 5;
+ }
+ if (newTop + tooltipHeight > window.innerHeight + window.scrollY) {
+ newTop = rect.top + window.scrollY - tooltipHeight - 5;
+ }
+ if (newTop < window.scrollY) {
+ newTop = window.scrollY + 5;
+ }
+
+ disabledTooltipElement.style.left = `${newLeft}px`;
+ disabledTooltipElement.style.top = `${newTop}px`;
+}
+
+function hideDisabledTooltip() {
+ if (disabledTooltipElement) {
+ disabledTooltipElement.classList.remove("tooltip-active");
+ }
+}
+
+function handleDisabledElementHover(event) {
+ const isImage = event.currentTarget.tagName.toLowerCase() === "img";
+
+ showDisabledTooltip(event.currentTarget, isImage);
+}
+
+function handleDisabledElementLeave() {
+ hideDisabledTooltip();
+}
+
+function handleDisabledGlobalHover(event) {
+ const disabledElement = findDisabledElementAtPoint(event);
+ if (disabledElement) {
+ if (currentDisabledHoverElement !== disabledElement) {
+ currentDisabledHoverElement = disabledElement;
+ const isImage = disabledElement.tagName.toLowerCase() === "img";
+ showDisabledTooltip(disabledElement, isImage);
+ }
+ } else {
+ if (currentDisabledHoverElement) {
+ currentDisabledHoverElement = null;
+ hideDisabledTooltip();
+ }
+ }
+}
+
+function enableEditMode() {
+ // Don't enable if selection mode is active
+ if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
+ console.warn("[EDIT MODE] Cannot enable edit mode while selection mode is active");
+ return;
+ }
+
+ document
+ .getElementById("root")
+ ?.setAttribute("data-edit-mode-enabled", "true");
+
+ injectPopupStyles();
+
+ if (!globalEventHandlers) {
+ globalEventHandlers = {
+ mousedown: handleGlobalEvent,
+ pointerdown: handleGlobalEvent,
+ click: handleGlobalEvent,
+ };
+
+ Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
+ document.addEventListener(eventType, handler, true);
+ });
+ }
+
+ document.addEventListener("mousemove", handleDisabledGlobalHover, true);
+
+ document.querySelectorAll("[data-edit-disabled]").forEach((el) => {
+ el.removeEventListener("mouseenter", handleDisabledElementHover);
+ el.addEventListener("mouseenter", handleDisabledElementHover);
+ el.removeEventListener("mouseleave", handleDisabledElementLeave);
+ el.addEventListener("mouseleave", handleDisabledElementLeave);
+ });
+}
+
+function disableEditMode() {
+ document.getElementById("root")?.removeAttribute("data-edit-mode-enabled");
+
+ hideDisabledTooltip();
+
+ if (globalEventHandlers) {
+ Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
+ document.removeEventListener(eventType, handler, true);
+ });
+ globalEventHandlers = null;
+ }
+
+ document.removeEventListener("mousemove", handleDisabledGlobalHover, true);
+ currentDisabledHoverElement = null;
+
+ document.querySelectorAll("[data-edit-disabled]").forEach((el) => {
+ el.removeEventListener("mouseenter", handleDisabledElementHover);
+ el.removeEventListener("mouseleave", handleDisabledElementLeave);
+ });
+}
+
+window.addEventListener("message", function (event) {
+ if (event.data?.type === "edit-save") {
+ handleEditSave(event.data?.payload?.newText);
+ }
+ if (event.data?.type === "enable-edit-mode") {
+ if (event.data?.translations) {
+ translations = { ...translations, ...event.data.translations };
+ }
+
+ enableEditMode();
+ }
+ if (event.data?.type === "disable-edit-mode") {
+ disableEditMode();
+ }
+});
diff --git a/contribute/plugins/visual-editor/visual-editor-config.js b/contribute/plugins/visual-editor/visual-editor-config.js
new file mode 100644
index 0000000..a5fa052
--- /dev/null
+++ b/contribute/plugins/visual-editor/visual-editor-config.js
@@ -0,0 +1,137 @@
+export const POPUP_STYLES = `
+#inline-editor-popup {
+ width: 360px;
+ position: fixed;
+ z-index: 10000;
+ background: #161718;
+ color: white;
+ border: 1px solid #4a5568;
+ border-radius: 16px;
+ padding: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+ flex-direction: column;
+ gap: 10px;
+ display: none;
+}
+
+@media (max-width: 768px) {
+ #inline-editor-popup {
+ width: calc(100% - 20px);
+ }
+}
+
+#inline-editor-popup.is-active {
+ display: flex;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+#inline-editor-popup.is-disabled-view {
+ padding: 10px 15px;
+}
+
+#inline-editor-popup textarea {
+ height: 100px;
+ padding: 4px 8px;
+ background: transparent;
+ color: white;
+ font-family: inherit;
+ font-size: 0.875rem;
+ line-height: 1.42;
+ resize: none;
+ outline: none;
+}
+
+#inline-editor-popup .button-container {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+#inline-editor-popup .popup-button {
+ border: none;
+ padding: 6px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 0.75rem;
+ font-weight: 700;
+ height: 34px;
+ outline: none;
+}
+
+#inline-editor-popup .save-button {
+ background: #673de6;
+ color: white;
+}
+
+#inline-editor-popup .cancel-button {
+ background: transparent;
+ border: 1px solid #3b3d4a;
+ color: white;
+
+ &:hover {
+ background:#474958;
+ }
+}
+`;
+
+export function getPopupHTMLTemplate(saveLabel, cancelLabel) {
+ return `
+
+
+
+
+
+ `;
+}
+
+export const EDIT_MODE_STYLES = `
+ #root[data-edit-mode-enabled="true"] [data-edit-id] {
+ cursor: pointer;
+ outline: 2px dashed #357DF9;
+ outline-offset: 2px;
+ min-height: 1em;
+ }
+ #root[data-edit-mode-enabled="true"] img[data-edit-id] {
+ outline-offset: -2px;
+ }
+ #root[data-edit-mode-enabled="true"] {
+ cursor: pointer;
+ }
+ #root[data-edit-mode-enabled="true"] [data-edit-id]:hover {
+ background-color: #357DF933;
+ outline-color: #357DF9;
+ }
+
+ @keyframes fadeInTooltip {
+ from {
+ opacity: 0;
+ transform: translateY(5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ #inline-editor-disabled-tooltip {
+ display: none;
+ opacity: 0;
+ position: absolute;
+ background-color: #1D1E20;
+ color: white;
+ padding: 4px 8px;
+ border-radius: 8px;
+ z-index: 10001;
+ font-size: 14px;
+ border: 1px solid #3B3D4A;
+ max-width: 184px;
+ text-align: center;
+ }
+
+ #inline-editor-disabled-tooltip.tooltip-active {
+ display: block;
+ animation: fadeInTooltip 0.2s ease-out forwards;
+ }
+`;
diff --git a/contribute/plugins/visual-editor/vite-plugin-edit-mode.js b/contribute/plugins/visual-editor/vite-plugin-edit-mode.js
new file mode 100644
index 0000000..58790b8
--- /dev/null
+++ b/contribute/plugins/visual-editor/vite-plugin-edit-mode.js
@@ -0,0 +1,32 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { fileURLToPath } from 'url';
+import { EDIT_MODE_STYLES } from './visual-editor-config';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = resolve(__filename, '..');
+
+export default function inlineEditDevPlugin() {
+ return {
+ name: 'vite:inline-edit-dev',
+ apply: 'serve',
+ transformIndexHtml() {
+ const scriptPath = resolve(__dirname, 'edit-mode-script.js');
+ const scriptContent = readFileSync(scriptPath, 'utf-8');
+
+ return [
+ {
+ tag: 'script',
+ attrs: { type: 'module' },
+ children: scriptContent,
+ injectTo: 'body'
+ },
+ {
+ tag: 'style',
+ children: EDIT_MODE_STYLES,
+ injectTo: 'head'
+ }
+ ];
+ }
+ };
+}
diff --git a/contribute/plugins/visual-editor/vite-plugin-react-inline-editor.js b/contribute/plugins/visual-editor/vite-plugin-react-inline-editor.js
new file mode 100644
index 0000000..315afea
--- /dev/null
+++ b/contribute/plugins/visual-editor/vite-plugin-react-inline-editor.js
@@ -0,0 +1,365 @@
+import path from 'path';
+import { parse } from '@babel/parser';
+import traverseBabel from '@babel/traverse';
+import * as t from '@babel/types';
+import fs from 'fs';
+import {
+ validateFilePath,
+ parseFileToAST,
+ findJSXElementAtPosition,
+ generateCode,
+ generateSourceWithMap,
+ VITE_PROJECT_ROOT
+} from '../utils/ast-utils.js';
+
+const EDITABLE_HTML_TAGS = ["a", "Button", "button", "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
+
+function parseEditId(editId) {
+ const parts = editId.split(':');
+
+ if (parts.length < 3) {
+ return null;
+ }
+
+ const column = parseInt(parts.at(-1), 10);
+ const line = parseInt(parts.at(-2), 10);
+ const filePath = parts.slice(0, -2).join(':');
+
+ if (!filePath || isNaN(line) || isNaN(column)) {
+ return null;
+ }
+
+ return { filePath, line, column };
+}
+
+function checkTagNameEditable(openingElementNode, editableTagsList) {
+ if (!openingElementNode || !openingElementNode.name) return false;
+ const nameNode = openingElementNode.name;
+
+ // Check 1: Direct name (for ,