Initial commit
This commit is contained in:
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
@@ -0,0 +1,315 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
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
src/plugins/visual-editor/visual-editor-config.js
Normal file
137
src/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
src/plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
src/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'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
@@ -0,0 +1,384 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import generate from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
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 generateFunction = generate.default || generate;
|
||||
const output = generateFunction(babelAst, {
|
||||
sourceMaps: true,
|
||||
sourceFileName: 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;
|
||||
|
||||
absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||
if (filePath.includes('..') || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) || absoluteFilePath.includes('node_modules')) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Invalid path' }));
|
||||
}
|
||||
|
||||
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
|
||||
const babelAst = parse(originalContent, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true
|
||||
});
|
||||
|
||||
let targetNodePath = null;
|
||||
const visitor = {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
if (node.loc && node.loc.start.line === line && node.loc.start.column + 1 === column) {
|
||||
targetNodePath = path;
|
||||
path.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
traverseBabel.default(babelAst, visitor);
|
||||
|
||||
if (!targetNodePath) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||
}
|
||||
|
||||
const generateFunction = generate.default || generate;
|
||||
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
|
||||
const beforeOutput = generateFunction(targetOpeningElement, {});
|
||||
beforeCode = beforeOutput.code;
|
||||
|
||||
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;
|
||||
|
||||
const afterOutput = generateFunction(targetOpeningElement, {});
|
||||
afterCode = afterOutput.code;
|
||||
}
|
||||
} else {
|
||||
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||
const beforeOutput = generateFunction(parentElementNode, {});
|
||||
beforeCode = beforeOutput.code;
|
||||
|
||||
parentElementNode.children = [];
|
||||
if (newFullText && newFullText.trim() !== '') {
|
||||
const newTextNode = t.jsxText(newFullText);
|
||||
parentElementNode.children.push(newTextNode);
|
||||
}
|
||||
modified = true;
|
||||
|
||||
const afterOutput = generateFunction(parentElementNode, {});
|
||||
afterCode = afterOutput.code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) {
|
||||
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||
}
|
||||
|
||||
const output = generateFunction(babelAst, {});
|
||||
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.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
125
src/plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
src/plugins/vite-plugin-iframe-route-restoration.js
Normal file
@@ -0,0 +1,125 @@
|
||||
export default function iframeRouteRestorationPlugin() {
|
||||
return {
|
||||
name: 'vite:iframe-route-restoration',
|
||||
apply: 'serve',
|
||||
transformIndexHtml() {
|
||||
const script = `
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
"https://horizons.hostinger.com",
|
||||
"https://horizons.hostinger.dev",
|
||||
"https://horizons-frontend-local.hostinger.dev",
|
||||
];
|
||||
|
||||
// Check to see if the page is in an iframe
|
||||
if (window.self !== window.top) {
|
||||
const STORAGE_KEY = 'horizons-iframe-saved-route';
|
||||
|
||||
const getCurrentRoute = () => location.pathname + location.search + location.hash;
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
const currentRoute = getCurrentRoute();
|
||||
sessionStorage.setItem(STORAGE_KEY, currentRoute);
|
||||
window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const replaceHistoryState = (url) => {
|
||||
try {
|
||||
history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||
return true;
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
if (!saved.startsWith('/')) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getCurrentRoute();
|
||||
if (current !== saved) {
|
||||
if (!replaceHistoryState(saved)) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
try {
|
||||
const text = (document.body?.innerText || '').trim();
|
||||
|
||||
// If the restored route results in too little content, assume it is invalid and navigate home
|
||||
if (text.length < 50) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
} catch {}
|
||||
}, 1000));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const originalReplaceState = history.replaceState;
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const 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;
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', save);
|
||||
window.addEventListener('hashchange', save);
|
||||
window.addEventListener("message", function (event) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
|
||||
if (event.data?.type === "redirect-home" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if(saved && saved !== '/') {
|
||||
replaceHistoryState('/')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
restore();
|
||||
}
|
||||
`;
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: script,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user