initial commit
This commit is contained in:
356
plugins/visual-editor/edit-mode-script.js
Normal file
356
plugins/visual-editor/edit-mode-script.js
Normal file
@@ -0,0 +1,356 @@
|
||||
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, "{")
|
||||
.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();
|
||||
}
|
||||
});
|
||||
137
plugins/visual-editor/visual-editor-config.js
Normal file
137
plugins/visual-editor/visual-editor-config.js
Normal file
@@ -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 `
|
||||
<textarea></textarea>
|
||||
<div class="button-container">
|
||||
<button class="popup-button cancel-button">${cancelLabel}</button>
|
||||
<button class="popup-button save-button">${saveLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
@@ -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'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
@@ -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 <p>, <Button>)
|
||||
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: Property name of a member expression (for <motion.h1>, check if "h1" is in editableTagsList)
|
||||
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateImageSrc(openingNode) {
|
||||
if (!openingNode || !openingNode.name || openingNode.name.name !== 'img') {
|
||||
return { isValid: true, reason: null }; // Not an image, skip validation
|
||||
}
|
||||
|
||||
const hasPropsSpread = openingNode.attributes.some(attr =>
|
||||
t.isJSXSpreadAttribute(attr) &&
|
||||
attr.argument &&
|
||||
t.isIdentifier(attr.argument) &&
|
||||
attr.argument.name === 'props'
|
||||
);
|
||||
|
||||
if (hasPropsSpread) {
|
||||
return { isValid: false, reason: 'props-spread' };
|
||||
}
|
||||
|
||||
const srcAttr = openingNode.attributes.find(attr =>
|
||||
t.isJSXAttribute(attr) &&
|
||||
attr.name &&
|
||||
attr.name.name === 'src'
|
||||
);
|
||||
|
||||
if (!srcAttr) {
|
||||
return { isValid: false, reason: 'missing-src' };
|
||||
}
|
||||
|
||||
if (!t.isStringLiteral(srcAttr.value)) {
|
||||
return { isValid: false, reason: 'dynamic-src' };
|
||||
}
|
||||
|
||||
if (!srcAttr.value.value || srcAttr.value.value.trim() === '') {
|
||||
return { isValid: false, reason: 'empty-src' };
|
||||
}
|
||||
|
||||
return { isValid: true, reason: null };
|
||||
}
|
||||
|
||||
export default function inlineEditPlugin() {
|
||||
return {
|
||||
name: 'vite-inline-edit-plugin',
|
||||
enforce: 'pre',
|
||||
|
||||
transform(code, id) {
|
||||
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeFilePath = path.relative(VITE_PROJECT_ROOT, id);
|
||||
const webRelativeFilePath = relativeFilePath.split(path.sep).join('/');
|
||||
|
||||
try {
|
||||
const babelAst = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true
|
||||
});
|
||||
|
||||
let attributesAdded = 0;
|
||||
|
||||
traverseBabel.default(babelAst, {
|
||||
enter(path) {
|
||||
if (path.isJSXOpeningElement()) {
|
||||
const openingNode = path.node;
|
||||
const elementNode = path.parentPath.node; // The JSXElement itself
|
||||
|
||||
if (!openingNode.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyHasId = openingNode.attributes.some(
|
||||
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'data-edit-id'
|
||||
);
|
||||
|
||||
if (alreadyHasId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 1: Is the current element tag type editable?
|
||||
const isCurrentElementEditable = checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS);
|
||||
if (!isCurrentElementEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageValidation = validateImageSrc(openingNode);
|
||||
if (!imageValidation.isValid) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral('true')
|
||||
);
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldBeDisabledDueToChildren = false;
|
||||
|
||||
// Condition 2: Does the element have dynamic or editable children
|
||||
if (t.isJSXElement(elementNode) && elementNode.children) {
|
||||
// Check if element has {...props} spread attribute - disable editing if it does
|
||||
const hasPropsSpread = openingNode.attributes.some(attr => t.isJSXSpreadAttribute(attr)
|
||||
&& attr.argument
|
||||
&& t.isIdentifier(attr.argument)
|
||||
&& attr.argument.name === 'props'
|
||||
);
|
||||
|
||||
const hasDynamicChild = elementNode.children.some(child =>
|
||||
t.isJSXExpressionContainer(child)
|
||||
);
|
||||
|
||||
if (hasDynamicChild || hasPropsSpread) {
|
||||
shouldBeDisabledDueToChildren = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldBeDisabledDueToChildren && t.isJSXElement(elementNode) && elementNode.children) {
|
||||
const hasEditableJsxChild = elementNode.children.some(child => {
|
||||
if (t.isJSXElement(child)) {
|
||||
return checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasEditableJsxChild) {
|
||||
shouldBeDisabledDueToChildren = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBeDisabledDueToChildren) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral('true')
|
||||
);
|
||||
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 3: Parent is non-editable if AT LEAST ONE child JSXElement is a non-editable type.
|
||||
if (t.isJSXElement(elementNode) && elementNode.children && elementNode.children.length > 0) {
|
||||
let hasNonEditableJsxChild = false;
|
||||
for (const child of elementNode.children) {
|
||||
if (t.isJSXElement(child)) {
|
||||
if (!checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS)) {
|
||||
hasNonEditableJsxChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNonEditableJsxChild) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral("true")
|
||||
);
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Condition 4: Is any ancestor JSXElement also editable?
|
||||
let currentAncestorCandidatePath = path.parentPath.parentPath;
|
||||
while (currentAncestorCandidatePath) {
|
||||
const ancestorJsxElementPath = currentAncestorCandidatePath.isJSXElement()
|
||||
? currentAncestorCandidatePath
|
||||
: currentAncestorCandidatePath.findParent(p => p.isJSXElement());
|
||||
|
||||
if (!ancestorJsxElementPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (checkTagNameEditable(ancestorJsxElementPath.node.openingElement, EDITABLE_HTML_TAGS)) {
|
||||
return;
|
||||
}
|
||||
currentAncestorCandidatePath = ancestorJsxElementPath.parentPath;
|
||||
}
|
||||
|
||||
const line = openingNode.loc.start.line;
|
||||
const column = openingNode.loc.start.column + 1;
|
||||
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
||||
|
||||
const idAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-id'),
|
||||
t.stringLiteral(editId)
|
||||
);
|
||||
|
||||
openingNode.attributes.push(idAttribute);
|
||||
attributesAdded++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (attributesAdded > 0) {
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, code);
|
||||
return { code: output.code, map: output.map };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Updates source code based on the changes received from the client
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/apply-edit', async (req, res, next) => {
|
||||
if (req.method !== 'POST') return next();
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
|
||||
req.on('end', async () => {
|
||||
let absoluteFilePath = '';
|
||||
try {
|
||||
const { editId, newFullText } = JSON.parse(body);
|
||||
|
||||
if (!editId || typeof newFullText === 'undefined') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Missing editId or newFullText' }));
|
||||
}
|
||||
|
||||
const parsedId = parseEditId(editId);
|
||||
if (!parsedId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Invalid editId format (filePath:line:column)' }));
|
||||
}
|
||||
|
||||
const { filePath, line, column } = parsedId;
|
||||
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: validation.error }));
|
||||
}
|
||||
absoluteFilePath = validation.absolutePath;
|
||||
|
||||
// Parse AST
|
||||
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
const babelAst = parseFileToAST(absoluteFilePath);
|
||||
|
||||
// Find target node (note: apply-edit uses column+1)
|
||||
const targetNodePath = findJSXElementAtPosition(babelAst, line, column + 1);
|
||||
|
||||
if (!targetNodePath) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||
}
|
||||
|
||||
const targetOpeningElement = targetNodePath.node;
|
||||
const parentElementNode = targetNodePath.parentPath?.node;
|
||||
|
||||
const isImageElement = targetOpeningElement.name && targetOpeningElement.name.name === 'img';
|
||||
|
||||
let beforeCode = '';
|
||||
let afterCode = '';
|
||||
let modified = false;
|
||||
|
||||
if (isImageElement) {
|
||||
// Handle image src attribute update
|
||||
beforeCode = generateCode(targetOpeningElement);
|
||||
|
||||
const srcAttr = targetOpeningElement.attributes.find(attr =>
|
||||
t.isJSXAttribute(attr) && attr.name && attr.name.name === 'src'
|
||||
);
|
||||
|
||||
if (srcAttr && t.isStringLiteral(srcAttr.value)) {
|
||||
srcAttr.value = t.stringLiteral(newFullText);
|
||||
modified = true;
|
||||
afterCode = generateCode(targetOpeningElement);
|
||||
}
|
||||
} else {
|
||||
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||
beforeCode = generateCode(parentElementNode);
|
||||
|
||||
parentElementNode.children = [];
|
||||
if (newFullText && newFullText.trim() !== '') {
|
||||
const newTextNode = t.jsxText(newFullText);
|
||||
parentElementNode.children.push(newTextNode);
|
||||
}
|
||||
modified = true;
|
||||
afterCode = generateCode(parentElementNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) {
|
||||
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||
}
|
||||
|
||||
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||
const newContent = output.code;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
newFileContent: newContent,
|
||||
beforeCode,
|
||||
afterCode,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal server error during edit application.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user