initial commit
This commit is contained in:
430
plugins/selection-mode/selection-mode-script.js
Normal file
430
plugins/selection-mode/selection-mode-script.js
Normal file
@@ -0,0 +1,430 @@
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const PRIMARY_400_COLOR = '#7B68EE';
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const DATA_SELECTION_MODE_ENABLED_ATTRIBUTE = 'data-selection-mode-enabled';
|
||||
const MESSAGE_TYPE_ENABLE_SELECTION_MODE = 'enableSelectionMode';
|
||||
const MESSAGE_TYPE_DISABLE_SELECTION_MODE = 'disableSelectionMode';
|
||||
|
||||
let selectionModeEnabled = false;
|
||||
let currentHoverElement = null;
|
||||
let overlayDiv = null;
|
||||
let selectedOverlayDiv = null;
|
||||
let selectedElement = null;
|
||||
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('selection-mode-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-mode-styles';
|
||||
style.textContent = `
|
||||
#selection-mode-overlay {
|
||||
position: absolute;
|
||||
border: 2px dashed ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999999;
|
||||
}
|
||||
#selection-mode-selected-overlay {
|
||||
position: absolute;
|
||||
border: 3px solid ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999998;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
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 {
|
||||
console.warn('[SELECTION MODE] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file path from React Fiber metadata (simplified - only for filePath)
|
||||
* @param {*} node - DOM node
|
||||
* @returns {string|null} - File path if found, null otherwise
|
||||
*/
|
||||
function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fiber = node[fiberKey];
|
||||
if (!fiber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Traverse up the fiber tree to find source metadata
|
||||
let currentFiber = fiber;
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSS selector path to uniquely identify the element
|
||||
* @param {*} element
|
||||
* @returns {string} CSS selector path
|
||||
*/
|
||||
function getPathToElement(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break; // ID is unique, stop here
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStyles(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
function extractDOMContext(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML,
|
||||
selector: getPathToElement(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStyles(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? element.textContent?.trim()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
overlayDiv = document.createElement('div');
|
||||
overlayDiv.id = 'selection-mode-overlay';
|
||||
document.body.appendChild(overlayDiv);
|
||||
}
|
||||
|
||||
function createSelectedOverlay() {
|
||||
if (selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
selectedOverlayDiv = document.createElement('div');
|
||||
selectedOverlayDiv.id = 'selection-mode-selected-overlay';
|
||||
document.body.appendChild(selectedOverlayDiv);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (overlayDiv && overlayDiv.parentNode) {
|
||||
overlayDiv.parentNode.removeChild(overlayDiv);
|
||||
overlayDiv = null;
|
||||
}
|
||||
if (selectedOverlayDiv && selectedOverlayDiv.parentNode) {
|
||||
selectedOverlayDiv.parentNode.removeChild(selectedOverlayDiv);
|
||||
selectedOverlayDiv = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlay(element) {
|
||||
if (!overlayDiv) {
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
overlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
overlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
overlayDiv.style.width = `${rect.width}px`;
|
||||
overlayDiv.style.height = `${rect.height}px`;
|
||||
overlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSelectedOverlay(element) {
|
||||
if (!selectedOverlayDiv) {
|
||||
createSelectedOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
selectedOverlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
selectedOverlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
selectedOverlayDiv.style.width = `${rect.width}px`;
|
||||
selectedOverlayDiv.style.height = `${rect.height}px`;
|
||||
selectedOverlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
if (overlayDiv) {
|
||||
overlayDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!element) {
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if we're hovering a different element
|
||||
if (currentHoverElement !== element) {
|
||||
currentHoverElement = element;
|
||||
|
||||
// Show outline on the element
|
||||
showOverlay(element);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (!element) {
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
|
||||
showOverlay(element);
|
||||
}
|
||||
|
||||
function stripFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHoverElement) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
|
||||
if (!element || element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const domContext = extractDOMContext(currentHoverElement);
|
||||
|
||||
if (!domContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedElement = currentHoverElement;
|
||||
if (selectedElement) {
|
||||
showSelectedOverlay(selectedElement);
|
||||
}
|
||||
|
||||
// Extract file path from React Fiber (if available)
|
||||
const filePath = getFilePathFromNode(currentHoverElement);
|
||||
const strippedFilePath = filePath ? stripFilePath(filePath) : undefined;
|
||||
|
||||
// Send domContext and filePath to parent window
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'elementSelected',
|
||||
payload: {
|
||||
filePath: strippedFilePath,
|
||||
domContext,
|
||||
},
|
||||
},
|
||||
parentOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
}
|
||||
|
||||
function enableSelectionMode() {
|
||||
if (selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = true;
|
||||
document.getElementById('root')?.setAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE, 'true');
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
createOverlay();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('touchstart', handleTouchStart, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
function disableSelectionMode() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = false;
|
||||
document.getElementById('root')?.removeAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE);
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
hideOverlay();
|
||||
removeOverlay();
|
||||
currentHoverElement = null;
|
||||
selectedElement = null;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('touchstart', handleTouchStart, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === MESSAGE_TYPE_ENABLE_SELECTION_MODE) {
|
||||
enableSelectionMode();
|
||||
}
|
||||
if (event.data?.type === MESSAGE_TYPE_DISABLE_SELECTION_MODE) {
|
||||
disableSelectionMode();
|
||||
}
|
||||
});
|
||||
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
export default function selectionModePlugin() {
|
||||
return {
|
||||
name: 'vite:selection-mode',
|
||||
apply: 'serve',
|
||||
|
||||
transformIndexHtml() {
|
||||
const scriptPath = resolve(__dirname, 'selection-mode-script.js');
|
||||
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: scriptContent,
|
||||
injectTo: 'body',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
279
plugins/utils/ast-utils.js
Normal file
279
plugins/utils/ast-utils.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import generate from '@babel/generator';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import {
|
||||
isJSXIdentifier,
|
||||
isJSXMemberExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||
const COMPONENT_BLACKLIST = new Set([
|
||||
'Helmet',
|
||||
'HelmetProvider',
|
||||
'Head',
|
||||
'head',
|
||||
'Meta',
|
||||
'meta',
|
||||
'Script',
|
||||
'script',
|
||||
'NoScript',
|
||||
'noscript',
|
||||
'Style',
|
||||
'style',
|
||||
'title',
|
||||
'Title',
|
||||
'link',
|
||||
'Link',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates that a file path is safe to access
|
||||
* @param {string} filePath - Relative file path
|
||||
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||
*/
|
||||
export function validateFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return { isValid: false, error: 'Missing filePath' };
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||
|
||||
if (filePath.includes('..')
|
||||
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||
|| absoluteFilePath.includes('node_modules')) {
|
||||
return { isValid: false, error: 'Invalid path' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return { isValid: false, error: 'File not found' };
|
||||
}
|
||||
|
||||
return { isValid: true, absolutePath: absoluteFilePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a file into a Babel AST
|
||||
* @param {string} absoluteFilePath - Absolute path to file
|
||||
* @returns {object} Babel AST
|
||||
*/
|
||||
export function parseFileToAST(absoluteFilePath) {
|
||||
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
|
||||
return parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a JSX opening element at a specific line and column
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {number} line - Line number (1-indexed)
|
||||
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||
* @returns {object | null} Babel path to the JSX opening element
|
||||
*/
|
||||
export function findJSXElementAtPosition(ast, line, column) {
|
||||
let targetNodePath = null;
|
||||
let closestNodePath = null;
|
||||
let closestDistance = Infinity;
|
||||
const allNodesOnLine = [];
|
||||
|
||||
const visitor = {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
if (node.loc) {
|
||||
// Exact match (with tolerance for off-by-one column differences)
|
||||
if (node.loc.start.line === line
|
||||
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||
targetNodePath = path;
|
||||
path.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track all nodes on the same line
|
||||
if (node.loc.start.line === line) {
|
||||
allNodesOnLine.push({
|
||||
path,
|
||||
column: node.loc.start.column,
|
||||
distance: Math.abs(node.loc.start.column - column),
|
||||
});
|
||||
}
|
||||
|
||||
// Track closest match on the same line for fallback
|
||||
if (node.loc.start.line === line) {
|
||||
const distance = Math.abs(node.loc.start.column - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Also check JSXElement nodes that contain the position
|
||||
JSXElement(path) {
|
||||
const node = path.node;
|
||||
if (!node.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this element spans the target line (for multi-line elements)
|
||||
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're inside this element's range, consider its opening element
|
||||
if (!path.node.openingElement?.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingLine = path.node.openingElement.loc.start.line;
|
||||
const openingCol = path.node.openingElement.loc.start.column;
|
||||
|
||||
// Prefer elements that start on the exact line
|
||||
if (openingLine === line) {
|
||||
const distance = Math.abs(openingCol - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle elements that start before the target line
|
||||
if (openingLine < line) {
|
||||
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
traverseBabel.default(ast, visitor);
|
||||
|
||||
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||
const threshold = closestDistance < 100 ? 50 : 500;
|
||||
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSX element name is blacklisted
|
||||
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||
* @returns {boolean} True if blacklisted
|
||||
*/
|
||||
function isBlacklistedComponent(jsxOpeningElement) {
|
||||
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||
}
|
||||
|
||||
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||
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 };
|
||||
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.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
125
plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
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