225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import { useDesignMode } from './DesignModeContext';
|
|
import { useUpdateManager } from './UpdateManager';
|
|
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
|
|
|
export const DesignModeManager: React.FC = () => {
|
|
const { isDesignMode, selectElement, selectedElement, updateElementContent } = useDesignMode();
|
|
|
|
// Initialize UpdateManager to enable context menu and direct editing features
|
|
useUpdateManager();
|
|
|
|
|
|
useEffect(() => {
|
|
if (!isDesignMode) {
|
|
document.querySelectorAll('[data-design-selected]').forEach(el => {
|
|
el.removeAttribute('data-design-selected');
|
|
});
|
|
return;
|
|
}
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
// Don't select the overlay itself or controls (assuming they are in shadow DOM or have specific attribute)
|
|
// Since this manager runs in the main document context (event listeners), we need to be careful.
|
|
// The UI will be in a separate root, likely in Shadow DOM, so events might not bubble up the same way
|
|
// OR we need to check if target is part of our UI.
|
|
if ((e.target as HTMLElement).closest('#__vite_plugin_design_mode__')) return;
|
|
|
|
// Don't handle clicks on context menu
|
|
if ((e.target as HTMLElement).closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
|
if (
|
|
(e.target as HTMLElement).closest(`[${AttributeNames.staticContent}="true"]`)
|
|
|| (e.target as HTMLElement).closest(`[${AttributeNames.staticClass}="true"]`)
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const target = e.target as HTMLElement;
|
|
selectElement(target);;
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
// Double-click handling is now managed by UpdateManager → EditManager
|
|
// This prevents conflicts and ensures data-ignore-mutation is properly set
|
|
/*
|
|
const handleDoubleClick = (e: MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest('#__vite_plugin_design_mode__')) return;
|
|
if ((e.target as HTMLElement).closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Check if element is marked as static content
|
|
if (!target.hasAttribute(AttributeNames.staticContent)) {
|
|
// alert('Not editable: only plain static text (no expressions).');
|
|
return;
|
|
}
|
|
|
|
// IMPORTANT: Save original content BEFORE enabling editing
|
|
const originalContent = target.innerText;
|
|
|
|
// Enable content editing
|
|
target.contentEditable = 'true';
|
|
target.focus();
|
|
|
|
const cleanup = () => {
|
|
target.contentEditable = 'false';
|
|
target.removeEventListener('blur', handleBlur);
|
|
target.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
cleanup();
|
|
// Use the saved original content, not current innerText
|
|
const newContent = target.innerText;
|
|
if (newContent !== originalContent) {
|
|
// Create a custom version of updateElementContent that uses our saved original
|
|
const sourceInfoStr = target.getAttribute(AttributeNames.info);
|
|
let filePath: string | null = null;
|
|
let line: number | null = null;
|
|
let column: number | null = null;
|
|
|
|
if (sourceInfoStr) {
|
|
try {
|
|
const sourceInfo = JSON.parse(sourceInfoStr);
|
|
filePath = sourceInfo.fileName;
|
|
line = sourceInfo.lineNumber;
|
|
column = sourceInfo.columnNumber;
|
|
} catch (e) {
|
|
console.warn(`Failed to parse ${AttributeNames.info}:`, e);
|
|
}
|
|
}
|
|
|
|
if (filePath && line !== null && column !== null) {
|
|
fetch('/__appdev_design_mode/update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
filePath,
|
|
line,
|
|
column,
|
|
newValue: newContent,
|
|
type: 'content',
|
|
originalValue: originalContent,
|
|
}),
|
|
}).then(response => {
|
|
if (response.ok) {
|
|
console.log('[DesignMode] Content updated successfully');
|
|
} else {
|
|
console.error('[DesignMode] Failed to update content');
|
|
}
|
|
}).catch(error => {
|
|
console.error('[DesignMode] Error updating content:', error);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
target.blur(); // Will trigger handleBlur
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
target.innerText = originalContent;
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
target.addEventListener('blur', handleBlur);
|
|
target.addEventListener('keydown', handleKeyDown);
|
|
};
|
|
*/
|
|
|
|
const handleMouseOver = (e: MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest('#__vite_plugin_design_mode__')) return;
|
|
if ((e.target as HTMLElement).closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
|
if (
|
|
(e.target as HTMLElement).hasAttribute(AttributeNames.staticContent)
|
|
|| (e.target as HTMLElement).hasAttribute(AttributeNames.staticClass)
|
|
) {
|
|
const target = e.target as HTMLElement;
|
|
target.setAttribute('data-design-hover', 'true');
|
|
};
|
|
};
|
|
|
|
const handleMouseOut = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
// Keep hover while context-menu hover flag is set
|
|
if (!target.hasAttribute(AttributeNames.contextMenuHover)) {
|
|
target.removeAttribute('data-design-hover');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('click', handleClick, true);
|
|
// document.addEventListener('dblclick', handleDoubleClick, true);
|
|
document.addEventListener('mouseover', handleMouseOver);
|
|
document.addEventListener('mouseout', handleMouseOut);
|
|
|
|
// Inject global styles for design mode into the MAIN document head
|
|
const styleId = 'appdev-design-mode-styles';
|
|
if (!document.getElementById(styleId)) {
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
style.textContent = `
|
|
[data-design-hover="true"] {
|
|
outline: 2px dashed #60a5fa !important; /* blue-400 */
|
|
outline-offset: 2px;
|
|
cursor: pointer;
|
|
}
|
|
[data-design-selected="true"] {
|
|
outline: 2px solid #2563eb !important; /* blue-600 */
|
|
outline-offset: 2px;
|
|
}
|
|
[contenteditable="true"] {
|
|
outline: 2px solid #22c55e !important; /* green-500 */
|
|
cursor: text;
|
|
background-color: rgba(34, 197, 94, 0.1);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
return () => {
|
|
// Optional: remove styles when unmounting?
|
|
// For now, let's keep them or we can remove them if we want to be clean.
|
|
// Since this component might be mounted/unmounted, maybe better to keep them
|
|
// or manage them carefully.
|
|
// Let's remove them to be clean.
|
|
const style = document.getElementById(styleId);
|
|
if (style) {
|
|
style.remove();
|
|
}
|
|
|
|
document.removeEventListener('click', handleClick, true);
|
|
// document.removeEventListener('dblclick', handleDoubleClick, true);
|
|
document.removeEventListener('mouseover', handleMouseOver);
|
|
document.removeEventListener('mouseout', handleMouseOut);
|
|
|
|
document.querySelectorAll('[data-design-hover]').forEach(el => {
|
|
el.removeAttribute('data-design-hover');
|
|
});
|
|
};
|
|
}, [isDesignMode, selectElement, updateElementContent]);
|
|
|
|
useEffect(() => {
|
|
document.querySelectorAll('[data-design-selected]').forEach(el => {
|
|
el.removeAttribute('data-design-selected');
|
|
});
|
|
|
|
if (selectedElement) {
|
|
selectedElement.setAttribute('data-design-selected', 'true');
|
|
}
|
|
}, [selectedElement]);
|
|
|
|
return null; // No UI to render, just logic and global side effects
|
|
};
|