Files
qiming/qiming-vite-plugin-design-mode/packages/client-react/src/DesignModeManager.tsx
2026-06-01 13:43:09 +08:00

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
};