"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useDesignMode } from './DesignModeContext';
|
||||
import { bridge } from './bridge';
|
||||
import {
|
||||
UpdateStyleMessage,
|
||||
UpdateContentMessage,
|
||||
ToggleDesignModeMessage,
|
||||
ElementSelectedMessage,
|
||||
ElementDeselectedMessage
|
||||
} from '@xagi/design-mode-shared/messages';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
|
||||
import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver';
|
||||
import { extractSourceInfo } from '@xagi/design-mode-shared/sourceInfo';
|
||||
|
||||
export const DesignModeBridge: React.FC = () => {
|
||||
const { selectedElement, modifyElementClass, updateElementContent } =
|
||||
useDesignMode();
|
||||
|
||||
/** True in iframe with an active bridge target. */
|
||||
const isBridgeReady = useCallback(() => {
|
||||
if (window.self === window.top) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!bridge.isConnectedToTarget()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const safeSend = useCallback(
|
||||
async (type: string, payload: any) => {
|
||||
if (!isBridgeReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await bridge.send(message as any);
|
||||
} catch (error) {
|
||||
// Failed to send message
|
||||
}
|
||||
},
|
||||
[isBridgeReady]
|
||||
);
|
||||
|
||||
/** Push ELEMENT_SELECTED / DESELECTED to parent after a short delay (bridge init). */
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (selectedElement) {
|
||||
const sourceInfo = resolveSourceInfo(selectedElement);
|
||||
// console.log('[DesignModeBridge] resolveSourceInfo', window.location.href, sourceInfo);
|
||||
|
||||
// Static text: static-content attr + DOM is text-only
|
||||
const hasStaticContentAttr = selectedElement.hasAttribute(AttributeNames.staticContent);
|
||||
const isActuallyPureText = isPureStaticText(selectedElement);
|
||||
const isStaticText = hasStaticContentAttr && isActuallyPureText;
|
||||
|
||||
// Editable class when static-class is present
|
||||
const isStaticClass = selectedElement.hasAttribute(AttributeNames.staticClass);
|
||||
|
||||
const elementData = {
|
||||
tagName: selectedElement.tagName.toLowerCase(),
|
||||
className: selectedElement.className || '',
|
||||
textContent: (
|
||||
selectedElement.textContent ||
|
||||
selectedElement.innerText ||
|
||||
''
|
||||
).substring(0, 100),
|
||||
sourceInfo: sourceInfo || {
|
||||
fileName: '',
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
},
|
||||
isStaticText: isStaticText || false,
|
||||
isStaticClass: isStaticClass,
|
||||
};
|
||||
|
||||
if (
|
||||
!elementData.sourceInfo.fileName ||
|
||||
elementData.sourceInfo.lineNumber === 0
|
||||
) {
|
||||
console.warn(
|
||||
'[DesignModeBridge] Warning: Element missing source mapping attributes. Updates may fail.'
|
||||
);
|
||||
}
|
||||
|
||||
safeSend('ELEMENT_SELECTED', { elementInfo: elementData });
|
||||
} else {
|
||||
safeSend('ELEMENT_DESELECTED', null);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedElement, safeSend]);
|
||||
|
||||
/** Parent → iframe: UPDATE_STYLE / UPDATE_CONTENT / TOGGLE */
|
||||
useEffect(() => {
|
||||
if (!isBridgeReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeStyle = bridge.on<UpdateStyleMessage>('UPDATE_STYLE', (message) => {
|
||||
const payload = message.payload;
|
||||
|
||||
if (selectedElement && payload?.sourceInfo && payload?.newClass) {
|
||||
// Require matching file:line:col
|
||||
const elementSourceInfo = extractSourceInfo(selectedElement);
|
||||
|
||||
if (!elementSourceInfo) {
|
||||
console.warn('[DesignModeBridge] Selected element missing source info');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceMatches =
|
||||
elementSourceInfo.fileName === payload.sourceInfo.fileName &&
|
||||
elementSourceInfo.lineNumber === payload.sourceInfo.lineNumber &&
|
||||
elementSourceInfo.columnNumber === payload.sourceInfo.columnNumber;
|
||||
|
||||
if (sourceMatches) {
|
||||
modifyElementClass(selectedElement, payload.newClass);
|
||||
} else {
|
||||
console.warn(
|
||||
'[DesignModeBridge] Source info mismatch, ignoring style update'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeContent = bridge.on<UpdateContentMessage>('UPDATE_CONTENT', (message) => {
|
||||
const payload = message.payload;
|
||||
|
||||
if (
|
||||
selectedElement &&
|
||||
payload?.sourceInfo &&
|
||||
payload?.newContent !== undefined
|
||||
) {
|
||||
// Require matching file:line:col
|
||||
const elementSourceInfo = extractSourceInfo(selectedElement);
|
||||
|
||||
if (!elementSourceInfo) {
|
||||
console.warn('[DesignModeBridge] Selected element missing source info');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceMatches =
|
||||
elementSourceInfo.fileName === payload.sourceInfo.fileName &&
|
||||
elementSourceInfo.lineNumber === payload.sourceInfo.lineNumber &&
|
||||
elementSourceInfo.columnNumber === payload.sourceInfo.columnNumber;
|
||||
|
||||
if (sourceMatches) {
|
||||
updateElementContent(selectedElement, payload.newContent);
|
||||
} else {
|
||||
console.warn(
|
||||
'[DesignModeBridge] Source info mismatch, ignoring content update'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.on<ToggleDesignModeMessage>(
|
||||
'TOGGLE_DESIGN_MODE',
|
||||
(message) => {
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeStyle();
|
||||
unsubscribeContent();
|
||||
unsubscribeToggle();
|
||||
};
|
||||
}, [
|
||||
selectedElement,
|
||||
modifyElementClass,
|
||||
updateElementContent,
|
||||
isBridgeReady,
|
||||
]);
|
||||
|
||||
/** Periodic bridge.healthCheck in iframe */
|
||||
useEffect(() => {
|
||||
if (!isBridgeReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkBridgeHealth = async () => {
|
||||
try {
|
||||
const health = await bridge.healthCheck();
|
||||
console.log('[DesignModeBridge] Bridge health check:', health);
|
||||
} catch (error) {
|
||||
console.warn('[DesignModeBridge] Bridge health check failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkBridgeHealth();
|
||||
|
||||
const healthTimer = setInterval(checkBridgeHealth, 30000);
|
||||
|
||||
return () => clearInterval(healthTimer);
|
||||
}, [isBridgeReady]);
|
||||
|
||||
return null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDesignMode } from './DesignModeContext';
|
||||
|
||||
// Tailwind Presets (Copied from InteractiveDemo)
|
||||
const TAILWIND_PRESETS = {
|
||||
bgColors: [
|
||||
{ label: 'White', value: 'bg-white' },
|
||||
{ label: 'Slate 50', value: 'bg-slate-50' },
|
||||
{ label: 'Blue 50', value: 'bg-blue-50' },
|
||||
{ label: 'Blue 100', value: 'bg-blue-100' },
|
||||
{ label: 'Blue 600', value: 'bg-blue-600' },
|
||||
{ label: 'Red 50', value: 'bg-red-50' },
|
||||
{ label: 'Green 50', value: 'bg-green-50' },
|
||||
],
|
||||
textColors: [
|
||||
{ label: 'Slate 900', value: 'text-slate-900' },
|
||||
{ label: 'Slate 600', value: 'text-slate-600' },
|
||||
{ label: 'Blue 600', value: 'text-blue-600' },
|
||||
{ label: 'White', value: 'text-white' },
|
||||
{ label: 'Red 600', value: 'text-red-600' },
|
||||
],
|
||||
fontSizes: [
|
||||
{ label: 'Small', value: 'text-sm' },
|
||||
{ label: 'Base', value: 'text-base' },
|
||||
{ label: 'Large', value: 'text-lg' },
|
||||
{ label: 'XL', value: 'text-xl' },
|
||||
{ label: '2XL', value: 'text-2xl' },
|
||||
{ label: '4XL', value: 'text-4xl' },
|
||||
],
|
||||
paddings: [
|
||||
{ label: '0', value: 'p-0' },
|
||||
{ label: '2', value: 'p-2' },
|
||||
{ label: '4', value: 'p-4' },
|
||||
{ label: '6', value: 'p-6' },
|
||||
{ label: '8', value: 'p-8' },
|
||||
{ label: '12', value: 'p-12' },
|
||||
],
|
||||
rounded: [
|
||||
{ label: 'None', value: 'rounded-none' },
|
||||
{ label: 'Small', value: 'rounded-sm' },
|
||||
{ label: 'Medium', value: 'rounded-md' },
|
||||
{ label: 'Large', value: 'rounded-lg' },
|
||||
{ label: 'Full', value: 'rounded-full' },
|
||||
]
|
||||
};
|
||||
|
||||
export const DesignModeUI: React.FC = () => {
|
||||
const { isDesignMode, toggleDesignMode, selectedElement, modifyElementClass, modifications, resetModifications } = useDesignMode();
|
||||
const [currentClasses, setCurrentClasses] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement) {
|
||||
setCurrentClasses(selectedElement.className);
|
||||
} else {
|
||||
setCurrentClasses('');
|
||||
}
|
||||
}, [selectedElement, modifications]);
|
||||
|
||||
const modifyClass = (newClass: string) => {
|
||||
if (!selectedElement) return;
|
||||
modifyElementClass(selectedElement, newClass);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 99999,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: '10px',
|
||||
pointerEvents: 'none' // Allow clicking through the container area
|
||||
}}>
|
||||
{/* Main Toggle */}
|
||||
<div style={{
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'white',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<label style={{ fontSize: '14px', fontWeight: 500, color: '#1e293b' }}>
|
||||
Design Mode
|
||||
</label>
|
||||
<button
|
||||
onClick={toggleDesignMode}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDesignMode ? '#2563eb' : '#e2e8f0',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: isDesignMode ? '18px' : '2px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.2)'
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Panel */}
|
||||
{isDesignMode && selectedElement && (
|
||||
<div style={{
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
width: '320px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px', paddingBottom: '12px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: '#0f172a' }}>
|
||||
Edit Element
|
||||
</h3>
|
||||
<code style={{ fontSize: '12px', backgroundColor: '#f1f5f9', padding: '2px 6px', borderRadius: '4px', color: '#64748b' }}>
|
||||
{selectedElement.tagName.toLowerCase()}{selectedElement.id ? `#${selectedElement.id}` : ''}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 500, color: '#64748b', marginBottom: '8px' }}>Background</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{TAILWIND_PRESETS.bgColors.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => modifyClass(preset.value)}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: preset.value === 'bg-white' ? '#ffffff' :
|
||||
preset.value === 'bg-slate-50' ? '#f8fafc' :
|
||||
preset.value === 'bg-blue-50' ? '#eff6ff' :
|
||||
preset.value === 'bg-blue-100' ? '#dbeafe' :
|
||||
preset.value === 'bg-blue-600' ? '#2563eb' :
|
||||
preset.value === 'bg-red-50' ? '#fef2f2' :
|
||||
preset.value === 'bg-green-50' ? '#f0fdf4' : '#eee',
|
||||
boxShadow: currentClasses.includes(preset.value) ? '0 0 0 2px #3b82f6' : 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 500, color: '#64748b', marginBottom: '8px' }}>Text Color</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{TAILWIND_PRESETS.textColors.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => modifyClass(preset.value)}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: preset.value === 'text-white' ? '#000' : // Show black 'A' for white text option for visibility
|
||||
preset.value === 'text-slate-900' ? '#0f172a' :
|
||||
preset.value === 'text-slate-600' ? '#475569' :
|
||||
preset.value === 'text-blue-600' ? '#2563eb' :
|
||||
preset.value === 'text-red-600' ? '#dc2626' : '#000',
|
||||
boxShadow: currentClasses.includes(preset.value) ? '0 0 0 2px #3b82f6' : 'none'
|
||||
}}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 500, color: '#64748b', marginBottom: '8px' }}>Padding</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{TAILWIND_PRESETS.paddings.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => modifyClass(preset.value)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: currentClasses.includes(preset.value) ? '#eff6ff' : 'white',
|
||||
color: currentClasses.includes(preset.value) ? '#1d4ed8' : '#64748b',
|
||||
borderColor: currentClasses.includes(preset.value) ? '#93c5fd' : '#e2e8f0'
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rounded */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 500, color: '#64748b', marginBottom: '8px' }}>Rounded</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{TAILWIND_PRESETS.rounded.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => modifyClass(preset.value)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: currentClasses.includes(preset.value) ? '#eff6ff' : 'white',
|
||||
color: currentClasses.includes(preset.value) ? '#1d4ed8' : '#64748b',
|
||||
borderColor: currentClasses.includes(preset.value) ? '#93c5fd' : '#e2e8f0'
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', borderTop: '1px solid #f1f5f9' }}>
|
||||
<button
|
||||
onClick={resetModifications}
|
||||
disabled={modifications.length === 0}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: modifications.length === 0 ? '#94a3b8' : '#dc2626',
|
||||
backgroundColor: modifications.length === 0 ? '#f1f5f9' : '#fef2f2',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: modifications.length === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset All Modifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,596 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useDesignMode } from './DesignModeContext';
|
||||
import { ElementInfo, SourceInfo } from '@xagi/design-mode-shared/messages';
|
||||
import { AttributeNames, isSourceMappingAttribute } from '@xagi/design-mode-shared/attributeNames';
|
||||
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
|
||||
import { extractSourceInfo as extractSourceInfoFromAttributes } from '@xagi/design-mode-shared/sourceInfo';
|
||||
|
||||
/**
|
||||
* Click/hover selection for mapped elements; emits to listeners / iframe.
|
||||
*/
|
||||
export class SelectionManager {
|
||||
private selectedElement: HTMLElement | null = null;
|
||||
private selectionListeners: Set<(element: HTMLElement | null) => void> = new Set();
|
||||
private hoverElement: HTMLElement | null = null;
|
||||
private isSelecting = false;
|
||||
private selectionStartTime = 0;
|
||||
private preventNextClick = false;
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private config: {
|
||||
enableSelection: boolean;
|
||||
enableHover: boolean;
|
||||
selectionDelay: number;
|
||||
excludeSelectors: string[];
|
||||
includeOnlyElements: boolean;
|
||||
} = {
|
||||
enableSelection: true,
|
||||
enableHover: true,
|
||||
selectionDelay: 0,
|
||||
excludeSelectors: [
|
||||
'script',
|
||||
'style',
|
||||
'meta',
|
||||
'link',
|
||||
'head',
|
||||
'title',
|
||||
'html',
|
||||
'body',
|
||||
'[data-selection-exclude="true"]',
|
||||
'.no-selection'
|
||||
],
|
||||
includeOnlyElements: false
|
||||
}
|
||||
) {
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
private initializeEventListeners() {
|
||||
if (!this.config.enableSelection) return;
|
||||
|
||||
this.container.addEventListener('click', this.handleClick.bind(this), true);
|
||||
this.container.addEventListener('mousedown', this.handleMouseDown.bind(this), true);
|
||||
this.container.addEventListener('mouseup', this.handleMouseUp.bind(this), true);
|
||||
|
||||
if (this.config.enableHover) {
|
||||
this.container.addEventListener('mouseenter', this.handleMouseEnter.bind(this), true);
|
||||
this.container.addEventListener('mouseleave', this.handleMouseLeave.bind(this), true);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts when selection enabled
|
||||
if (this.config.enableSelection) {
|
||||
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler
|
||||
*/
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (!this.config.enableSelection) return;
|
||||
if (this.preventNextClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.preventNextClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (!this.isValidElement(target)) return;
|
||||
|
||||
// Optional selectionDelay
|
||||
if (this.config.selectionDelay > 0) {
|
||||
setTimeout(() => {
|
||||
this.selectElement(target);
|
||||
}, this.config.selectionDelay);
|
||||
} else {
|
||||
this.selectElement(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mousedown
|
||||
*/
|
||||
private handleMouseDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!this.isValidElement(target)) return;
|
||||
|
||||
this.isSelecting = true;
|
||||
this.selectionStartTime = Date.now();
|
||||
this.preventNextClick = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* mouseup
|
||||
*/
|
||||
private handleMouseUp(event: MouseEvent) {
|
||||
if (!this.isSelecting) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const duration = Date.now() - this.selectionStartTime;
|
||||
|
||||
// Long-press (>500ms) suppresses click selection
|
||||
if (duration > 500) {
|
||||
this.preventNextClick = true;
|
||||
}
|
||||
|
||||
this.isSelecting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* mouseenter
|
||||
*/
|
||||
private handleMouseEnter(event: MouseEvent) {
|
||||
if (!this.config.enableHover) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (!this.isValidElement(target)) return;
|
||||
|
||||
this.hoverElement = target;
|
||||
this.onHoverElement(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* mouseleave
|
||||
*/
|
||||
private handleMouseLeave(event: MouseEvent) {
|
||||
if (!this.config.enableHover) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (this.hoverElement === target) {
|
||||
this.hoverElement = null;
|
||||
this.onHoverElement(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* keydown
|
||||
*/
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Esc clears selection
|
||||
if (event.key === 'Escape') {
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+A: select first match
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
|
||||
event.preventDefault();
|
||||
this.selectAll();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+D: clear
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'd') {
|
||||
event.preventDefault();
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* keyup
|
||||
*/
|
||||
private handleKeyUp(event: KeyboardEvent) {
|
||||
// noop
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether element may be selected (mapped + static-content or static-class)
|
||||
*/
|
||||
private isValidElement(element: HTMLElement): boolean {
|
||||
if (!element || !element.tagName) return false;
|
||||
|
||||
// Exclude context menu
|
||||
if (element.closest(`[${AttributeNames.contextMenu}="true"]`)) return false;
|
||||
|
||||
// Exclusion list
|
||||
for (const selector of this.config.excludeSelectors) {
|
||||
if (element.matches(selector) || element.closest(selector)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional tag whitelist
|
||||
if (this.config.includeOnlyElements) {
|
||||
const validElements = ['DIV', 'SPAN', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BUTTON', 'A'];
|
||||
if (!validElements.includes(element.tagName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Must have -info JSON
|
||||
if (!element.hasAttribute(AttributeNames.info)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Need static-content or static-class
|
||||
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
|
||||
const hasStaticClass = element.hasAttribute(AttributeNames.staticClass);
|
||||
|
||||
console.log('hasStaticContent', element, hasStaticContent);
|
||||
console.log('hasStaticClass', element, hasStaticClass);
|
||||
|
||||
if (!hasStaticContent && !hasStaticClass) {
|
||||
// Dynamic content/style — skip
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selection highlight
|
||||
*/
|
||||
private selectElement(element: HTMLElement) {
|
||||
if (this.selectedElement === element) return;
|
||||
|
||||
// Clear previous outline
|
||||
if (this.selectedElement) {
|
||||
this.clearElementHighlighting(this.selectedElement);
|
||||
}
|
||||
|
||||
this.selectedElement = element;
|
||||
|
||||
// Highlight new node
|
||||
this.highlightElement(element);
|
||||
|
||||
// Notify subscribers
|
||||
this.selectionListeners.forEach(listener => listener(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* clearSelection
|
||||
*/
|
||||
public clearSelection() {
|
||||
if (this.selectedElement) {
|
||||
this.clearElementHighlighting(this.selectedElement);
|
||||
this.selectedElement = null;
|
||||
this.selectionListeners.forEach(listener => listener(null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* highlightElement
|
||||
*/
|
||||
private highlightElement(element: HTMLElement) {
|
||||
// Restore prior inline styles if any
|
||||
const existingHighlight = element.getAttribute('data-selection-highlight');
|
||||
if (existingHighlight) {
|
||||
const existingStyles = JSON.parse(existingHighlight);
|
||||
Object.entries(existingStyles).forEach(([property, value]) => {
|
||||
(element.style as any)[property] = value;
|
||||
});
|
||||
}
|
||||
|
||||
// Snapshot pre-highlight
|
||||
const originalStyles = {
|
||||
outline: element.style.outline,
|
||||
boxShadow: element.style.boxShadow,
|
||||
backgroundColor: element.style.backgroundColor,
|
||||
cursor: element.style.cursor
|
||||
};
|
||||
|
||||
// Selection chrome
|
||||
element.style.outline = '2px solid #007acc';
|
||||
element.style.boxShadow = '0 0 0 2px rgba(0, 122, 204, 0.3)';
|
||||
element.style.backgroundColor = 'rgba(0, 122, 204, 0.1)';
|
||||
element.style.cursor = 'pointer';
|
||||
|
||||
// Persist snapshot on data-selection-highlight
|
||||
element.setAttribute('data-selection-highlight', JSON.stringify(originalStyles));
|
||||
}
|
||||
|
||||
/**
|
||||
* clearElementHighlighting
|
||||
*/
|
||||
private clearElementHighlighting(element: HTMLElement) {
|
||||
const highlightData = element.getAttribute('data-selection-highlight');
|
||||
if (highlightData) {
|
||||
try {
|
||||
const originalStyles = JSON.parse(highlightData);
|
||||
Object.entries(originalStyles).forEach(([property, value]) => {
|
||||
(element.style as any)[property] = value;
|
||||
});
|
||||
element.removeAttribute('data-selection-highlight');
|
||||
} catch (e) {
|
||||
console.warn('[SelectionManager] Failed to restore original styles:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onHoverElement
|
||||
*/
|
||||
private onHoverElement(element: HTMLElement | null) {
|
||||
// Hook for hover UX
|
||||
}
|
||||
|
||||
/**
|
||||
* selectAll (first candidate only)
|
||||
*/
|
||||
private selectAll() {
|
||||
const selectableElements = Array.from(
|
||||
this.container.querySelectorAll('*')
|
||||
).filter(el => this.isValidElement(el as HTMLElement));
|
||||
|
||||
// Future: multi-select
|
||||
if (selectableElements.length > 0) {
|
||||
this.selectElement(selectableElements[0] as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addSelectionListener
|
||||
*/
|
||||
public addSelectionListener(listener: (element: HTMLElement | null) => void) {
|
||||
this.selectionListeners.add(listener);
|
||||
return () => this.selectionListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* getSelectedElement
|
||||
*/
|
||||
public getSelectedElement(): HTMLElement | null {
|
||||
return this.selectedElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* getHoverElement
|
||||
*/
|
||||
public getHoverElement(): HTMLElement | null {
|
||||
return this.hoverElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractSourceInfo
|
||||
*/
|
||||
private extractSourceInfo(element: HTMLElement): SourceInfo | null {
|
||||
return extractSourceInfoFromAttributes(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up for library component root
|
||||
*/
|
||||
private findComponentRoot(element: HTMLElement): HTMLElement {
|
||||
const sourceInfo = this.extractSourceInfo(element);
|
||||
if (!sourceInfo || !sourceInfo.fileName) return element;
|
||||
|
||||
// Heuristic: components/ui or common
|
||||
const isLibraryComponent = sourceInfo.fileName.includes('/components/ui/') ||
|
||||
sourceInfo.fileName.includes('/components/common/');
|
||||
|
||||
if (!isLibraryComponent) return element;
|
||||
|
||||
// Stop at file boundary
|
||||
let current = element;
|
||||
let componentRoot = element;
|
||||
|
||||
while (current.parentElement) {
|
||||
const parent = current.parentElement;
|
||||
const parentSourceInfo = this.extractSourceInfo(parent);
|
||||
|
||||
// Boundary: different file or no mapping
|
||||
if (!parentSourceInfo || parentSourceInfo.fileName !== sourceInfo.fileName) {
|
||||
componentRoot = current;
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return componentRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractElementInfo
|
||||
*/
|
||||
public extractElementInfo(element: HTMLElement): ElementInfo | null {
|
||||
if (!element) return null;
|
||||
|
||||
// Prefer library root
|
||||
const targetElement = this.findComponentRoot(element);
|
||||
const sourceInfo = this.extractSourceInfo(targetElement);
|
||||
|
||||
if (!sourceInfo) {
|
||||
console.warn('[SelectionManager] Element has no source mapping:', targetElement);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Layout (rect unused below — kept for future)
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(targetElement);
|
||||
|
||||
// Static text flags
|
||||
const hasStaticContentAttr = targetElement.hasAttribute(AttributeNames.staticContent);
|
||||
const isActuallyPureText = isPureStaticText(targetElement);
|
||||
const isStaticText = hasStaticContentAttr && isActuallyPureText;
|
||||
|
||||
|
||||
const isStaticClass = targetElement.hasAttribute(AttributeNames.staticClass);
|
||||
|
||||
// Text snapshot
|
||||
let textContent = '';
|
||||
if (isStaticText) {
|
||||
textContent = this.getElementTextContent(targetElement);
|
||||
} else {
|
||||
textContent = targetElement.innerText || targetElement.textContent || '';
|
||||
}
|
||||
|
||||
// Ancestor chain with mappings
|
||||
const hierarchy: { tagName: string; componentName?: string; fileName?: string }[] = [];
|
||||
let current: HTMLElement | null = targetElement;
|
||||
while (current && current !== document.body) {
|
||||
const info = this.extractSourceInfo(current);
|
||||
if (info) {
|
||||
hierarchy.push({
|
||||
tagName: current.tagName.toLowerCase(),
|
||||
componentName: info.componentName,
|
||||
fileName: info.fileName
|
||||
});
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// DOM attrs as pseudo-props
|
||||
const props = this.getElementAttributes(targetElement);
|
||||
|
||||
return {
|
||||
tagName: targetElement.tagName.toLowerCase(),
|
||||
className: targetElement.className || '',
|
||||
textContent: textContent,
|
||||
sourceInfo,
|
||||
isStaticText: isStaticText || false,
|
||||
isStaticClass: isStaticClass,
|
||||
componentName: sourceInfo.componentName,
|
||||
componentPath: sourceInfo.fileName,
|
||||
props,
|
||||
hierarchy
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getElementTextContent
|
||||
*/
|
||||
private getElementTextContent(element: HTMLElement): string {
|
||||
|
||||
let textContent = element.textContent || '';
|
||||
|
||||
// Truncate preview
|
||||
if (textContent.length > 100) {
|
||||
textContent = textContent.substring(0, 100) + '...';
|
||||
}
|
||||
|
||||
return textContent.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* getElementAttributes
|
||||
*/
|
||||
private getElementAttributes(element: HTMLElement): Record<string, string> {
|
||||
const attributes: Record<string, string> = {};
|
||||
const elementAttributes = Array.from(element.attributes);
|
||||
|
||||
elementAttributes.forEach(attr => {
|
||||
// Strip mapping + selection attrs
|
||||
if (!isSourceMappingAttribute(attr.name) &&
|
||||
!attr.name.startsWith('data-selection-')) {
|
||||
attributes[attr.name] = attr.value;
|
||||
}
|
||||
});
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* getElementDomPath
|
||||
*/
|
||||
private getElementDomPath(element: HTMLElement): string {
|
||||
const path: string[] = [];
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current && current !== this.container) {
|
||||
let selector = current.tagName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.className) {
|
||||
const classes = Array.from(current.classList).slice(0, 3);
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
|
||||
// nth-of-type disambiguation
|
||||
const siblings = Array.from(current.parentNode?.children || []);
|
||||
const sameTagSiblings = siblings.filter(sibling =>
|
||||
sibling.tagName === current!.tagName
|
||||
);
|
||||
|
||||
if (sameTagSiblings.length > 1) {
|
||||
const index = sameTagSiblings.indexOf(current);
|
||||
selector += `:nth-of-type(${index + 1})`;
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy
|
||||
*/
|
||||
public destroy() {
|
||||
|
||||
this.clearSelection();
|
||||
|
||||
|
||||
this.container.removeEventListener('click', this.handleClick.bind(this), true);
|
||||
this.container.removeEventListener('mousedown', this.handleMouseDown.bind(this), true);
|
||||
this.container.removeEventListener('mouseup', this.handleMouseUp.bind(this), true);
|
||||
this.container.removeEventListener('mouseenter', this.handleMouseEnter.bind(this), true);
|
||||
this.container.removeEventListener('mouseleave', this.handleMouseLeave.bind(this), true);
|
||||
document.removeEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.removeEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
|
||||
|
||||
this.selectionListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectionManager React Hook
|
||||
*/
|
||||
export const useSelectionManager = (config?: {
|
||||
enableSelection?: boolean;
|
||||
enableHover?: boolean;
|
||||
selectionDelay?: number;
|
||||
excludeSelectors?: string[];
|
||||
includeOnlyElements?: boolean;
|
||||
}) => {
|
||||
const selectionManagerRef = useRef<SelectionManager | null>(null);
|
||||
const { selectElement, config: designModeConfig } = useDesignMode();
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.body;
|
||||
selectionManagerRef.current = new SelectionManager(container, {
|
||||
enableSelection: designModeConfig.iframeMode?.enableSelection ?? true,
|
||||
enableHover: true,
|
||||
selectionDelay: 0,
|
||||
excludeSelectors: [
|
||||
'script', 'style', 'meta', 'link', 'head', 'title', 'html', 'body',
|
||||
'[data-selection-exclude="true"]', '.no-selection'
|
||||
],
|
||||
includeOnlyElements: false,
|
||||
...config
|
||||
});
|
||||
|
||||
// Bridge to React context
|
||||
const unsubscribe = selectionManagerRef.current.addSelectionListener((element) => {
|
||||
if (element && designModeConfig.iframeMode?.enabled) {
|
||||
// extractElementInfo + selectElement
|
||||
const elementInfo = selectionManagerRef.current?.extractElementInfo(element);
|
||||
if (elementInfo) {
|
||||
selectElement(element);
|
||||
}
|
||||
} else if (!element) {
|
||||
selectElement(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
selectionManagerRef.current?.destroy();
|
||||
selectionManagerRef.current = null;
|
||||
};
|
||||
}, [selectElement, designModeConfig]);
|
||||
|
||||
return {
|
||||
selectionManager: selectionManagerRef.current
|
||||
};
|
||||
};
|
||||
|
||||
export default SelectionManager;
|
||||
@@ -0,0 +1,816 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useDesignMode } from './DesignModeContext';
|
||||
import { SourceInfo, AddToChatMessage, CopyElementMessage } from '@xagi/design-mode-shared/messages';
|
||||
import { extractSourceInfo, hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
|
||||
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
import { showContextMenu, MenuItem } from './ui/ContextMenu';
|
||||
import { UpdateOperation, UpdateState, UpdateResult, BatchUpdateItem, UpdateManagerConfig } from './types/UpdateTypes';
|
||||
import { HistoryManager } from '@xagi/design-mode-shared/HistoryManager';
|
||||
import { ObserverManager } from './managers/ObserverManager';
|
||||
import { EditManager } from './managers/EditManager';
|
||||
import { UpdateService } from '@xagi/design-mode-shared/UpdateService';
|
||||
import { bridge } from './bridge';
|
||||
import { Toast } from './ui/Toast';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Coordinates direct edit, context menu, batching, and persistence hooks.
|
||||
*/
|
||||
export class UpdateManager {
|
||||
private updateQueue: UpdateState[] = [];
|
||||
private historyManager: HistoryManager;
|
||||
private observerManager: ObserverManager;
|
||||
private editManager: EditManager;
|
||||
private updateService: UpdateService;
|
||||
private callbacks: Map<UpdateOperation, Set<(update: UpdateState) => void>> =
|
||||
new Map();
|
||||
private batchTimer: NodeJS.Timeout | null = null;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Design mode state
|
||||
private isDesignMode: boolean = false;
|
||||
private selectedElement: HTMLElement | null = null;
|
||||
private selectElementCallback: ((element: HTMLElement | null) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private config: UpdateManagerConfig = {
|
||||
enableDirectEdit: true,
|
||||
enableBatching: true,
|
||||
batchDebounceMs: 300,
|
||||
maxRetries: 3,
|
||||
autoSave: true,
|
||||
saveDelay: 1000,
|
||||
validation: {
|
||||
validateSource: true,
|
||||
validateValue: true,
|
||||
maxLength: 10000,
|
||||
},
|
||||
}
|
||||
) {
|
||||
this.historyManager = new HistoryManager();
|
||||
this.observerManager = new ObserverManager(
|
||||
(target, type) => this.editManager.handleDirectEdit(target, type),
|
||||
(node) => this.setupElementEditHandlers(node)
|
||||
);
|
||||
this.editManager = new EditManager(
|
||||
(update) => this.updateService.processUpdate(update),
|
||||
this.config
|
||||
);
|
||||
this.updateService = new UpdateService(
|
||||
this.config,
|
||||
(update) => {
|
||||
this.historyManager.add(update);
|
||||
this.notifyCallbacks(update.operation, update);
|
||||
if (this.config.autoSave) {
|
||||
this.triggerAutoSave();
|
||||
}
|
||||
},
|
||||
(update) => {
|
||||
// onFail callback
|
||||
if (update.error) {
|
||||
Toast.error(`Update failed: ${update.error}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (this.config.enableDirectEdit) {
|
||||
this.observerManager.enable();
|
||||
}
|
||||
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* (Observer wired in ObserverManager)
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* register dblclick / contextmenu
|
||||
*/
|
||||
private initializeEventListeners() {
|
||||
if (!this.config.enableDirectEdit) return;
|
||||
|
||||
// Double-click → edit
|
||||
document.addEventListener('dblclick', this.handleDblClick.bind(this));
|
||||
|
||||
// Context menu
|
||||
document.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||
|
||||
// Shortcuts (optional)
|
||||
// document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Double-click: only when design mode on
|
||||
*/
|
||||
private handleDblClick(event: MouseEvent) {
|
||||
// Require design mode
|
||||
if (!this.isDesignMode) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Need source mapping
|
||||
if (!hasSourceMapping(target)) return;
|
||||
|
||||
// Skip plugin UI
|
||||
if (target.closest('#__vite_plugin_design_mode__')) return;
|
||||
|
||||
// Skip context menu DOM
|
||||
if (target.closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
||||
|
||||
// static-content="true" only
|
||||
// Respects configurable attribute prefix
|
||||
const staticContentAttr = target.getAttribute(AttributeNames.staticContent);
|
||||
if (staticContentAttr !== 'true') {
|
||||
// Not editable without static text marker
|
||||
return;
|
||||
}
|
||||
|
||||
// preventDefault for dblclick
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Check if it's pure static text - REMOVED to let EditManager handle validation
|
||||
// if (!isPureStaticText(target)) {
|
||||
// console.log('[UpdateManager] Ignored dblclick on non-static text element');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Delegate to EditManager
|
||||
this.editManager.handleDirectEdit(target, 'content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu: add to chat / copy
|
||||
*/
|
||||
private handleContextMenu(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Only show context menu if design mode is enabled
|
||||
if (!this.isDesignMode) return;
|
||||
|
||||
// Exclude context menu
|
||||
if (target.closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
||||
|
||||
// Must be mapped
|
||||
if (!hasSourceMapping(target)) return;
|
||||
|
||||
// isSelected flag
|
||||
const isSelected = !!(this.selectedElement && target === this.selectedElement);
|
||||
|
||||
// Preserve hover while menu opens
|
||||
const hadHoverState = target.hasAttribute('data-design-hover');
|
||||
if (hadHoverState) {
|
||||
// context-menu-hover marker
|
||||
target.setAttribute(AttributeNames.contextMenuHover, 'true');
|
||||
if (target.hasAttribute(AttributeNames.staticClass) || target.hasAttribute(AttributeNames.staticContent)) {
|
||||
// keep data-design-hover
|
||||
target.setAttribute('data-design-hover', 'true');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Custom menu for mapped nodes
|
||||
this.showContextMenu(target, event.clientX, event.clientY, isSelected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update design mode state
|
||||
*/
|
||||
public setDesignModeState(
|
||||
isDesignMode: boolean,
|
||||
selectedElement: HTMLElement | null = null,
|
||||
selectElementCallback?: (element: HTMLElement | null) => void
|
||||
) {
|
||||
this.isDesignMode = isDesignMode;
|
||||
this.selectedElement = selectedElement;
|
||||
if (selectElementCallback) {
|
||||
this.selectElementCallback = selectElementCallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether design mode is active
|
||||
*/
|
||||
public getDesignModeState(): boolean {
|
||||
return this.isDesignMode;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts (currently unused hook)
|
||||
*/
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// F2 → edit
|
||||
if (event.key === 'F2') {
|
||||
const selectedElement = document.activeElement as HTMLElement;
|
||||
if (selectedElement && hasSourceMapping(selectedElement)) {
|
||||
event.preventDefault();
|
||||
this.editManager.handleDirectEdit(selectedElement, 'content');
|
||||
// if (isPureStaticText(selectedElement)) {
|
||||
// this.editManager.handleDirectEdit(selectedElement, 'content');
|
||||
// } else {
|
||||
// console.log('[UpdateManager] Cannot edit non-static text element');
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// Save all
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
this.saveAllChanges();
|
||||
}
|
||||
|
||||
// Undo
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key === 'z' &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.undoLastUpdate();
|
||||
}
|
||||
|
||||
// Redo
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
(event.key === 'y' || (event.key === 'z' && event.shiftKey))
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.redoLastUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer callback: mark editable nodes
|
||||
*/
|
||||
private setupElementEditHandlers(element: HTMLElement) {
|
||||
if (!hasSourceMapping(element)) return;
|
||||
|
||||
// data-edit-enabled
|
||||
element.setAttribute('data-edit-enabled', 'true');
|
||||
|
||||
// dashed outline on hover
|
||||
element.addEventListener('mouseenter', () => {
|
||||
if (this.config.enableDirectEdit && this.isDesignMode) {
|
||||
element.style.outline = '1px dashed #007acc';
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
if (!element.hasAttribute('data-selected')) {
|
||||
element.style.outline = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Internal direct-edit path
|
||||
*/
|
||||
private handleDirectEdit(
|
||||
element: HTMLElement,
|
||||
type: 'style' | 'content' | 'attribute'
|
||||
) {
|
||||
if (!this.config.enableDirectEdit) return;
|
||||
|
||||
const sourceInfo = extractSourceInfo(element);
|
||||
if (!sourceInfo) return;
|
||||
|
||||
switch (type) {
|
||||
case 'content':
|
||||
this.updateContent(element, element.innerText, sourceInfo);
|
||||
break;
|
||||
case 'style':
|
||||
this.updateStyle(element, element.className, sourceInfo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* enterEditMode
|
||||
*/
|
||||
public enterEditMode(
|
||||
element: HTMLElement,
|
||||
type: 'style' | 'content' | 'attribute'
|
||||
) {
|
||||
const sourceInfo = extractSourceInfo(element);
|
||||
if (!sourceInfo) {
|
||||
console.warn('[UpdateManager] Element has no source mapping');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'content':
|
||||
this.editManager.handleDirectEdit(element, 'content');
|
||||
break;
|
||||
case 'style':
|
||||
this.editManager.editStyle(element);
|
||||
break;
|
||||
case 'attribute':
|
||||
this.editManager.editAttributes(element);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* (reserved)
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* showContextMenu
|
||||
*/
|
||||
private showContextMenu(element: HTMLElement, x: number, y: number, isSelected: boolean) {
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// Built-in actions
|
||||
menuItems.push(
|
||||
{
|
||||
label: 'Add to chat',
|
||||
action: () => this.addToChat(element),
|
||||
},
|
||||
{
|
||||
label: 'Copy element',
|
||||
action: () => this.copyElement(element)
|
||||
}
|
||||
);
|
||||
|
||||
showContextMenu(element, x, y, menuItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* (handled in ContextMenu)
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* copyElement
|
||||
*/
|
||||
private copyElement(element: HTMLElement) {
|
||||
const sourceInfo = extractSourceInfo(element);
|
||||
const content = element.innerText || element.textContent || '';
|
||||
|
||||
// Copy element to clipboard (if possible)
|
||||
const elementInfo = {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
className: element.className,
|
||||
content: content,
|
||||
sourceInfo: sourceInfo ?? undefined
|
||||
};
|
||||
|
||||
const textToCopy = JSON.stringify(elementInfo, null, 2);
|
||||
|
||||
// sendCopyMessage helper
|
||||
const sendCopyMessage = (success: boolean, error?: string) => {
|
||||
const message: CopyElementMessage = {
|
||||
type: 'COPY_ELEMENT',
|
||||
payload: {
|
||||
elementInfo: {
|
||||
tagName: elementInfo.tagName,
|
||||
className: elementInfo.className,
|
||||
content: elementInfo.content,
|
||||
sourceInfo: elementInfo.sourceInfo
|
||||
},
|
||||
textContent: textToCopy,
|
||||
success,
|
||||
error
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// iframe: bridge; top: postMessage
|
||||
if (typeof window !== 'undefined' && window.self !== window.top) {
|
||||
|
||||
bridge.send(message).catch(error => {
|
||||
console.error('[UpdateManager] Failed to send COPY_ELEMENT via bridge:', error);
|
||||
// fallback postMessage
|
||||
window.parent.postMessage(message, '*');
|
||||
});
|
||||
} else {
|
||||
|
||||
window.postMessage(message, '*');
|
||||
}
|
||||
};
|
||||
|
||||
// Clipboard API when available
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
let alertMessage = 'Copied element info to clipboard:\n\n';
|
||||
if (sourceInfo) {
|
||||
alertMessage += `File: ${sourceInfo.fileName}\n`;
|
||||
alertMessage += `Line: L${sourceInfo.lineNumber}\n`;
|
||||
alertMessage += `\n`;
|
||||
}
|
||||
alertMessage += `Tag: <${elementInfo.tagName}>\n`;
|
||||
alertMessage += `className: ${elementInfo.className}\n`;
|
||||
alertMessage += `Text: ${content.substring(0, 50)}${content.length > 50 ? '...' : ''}`;
|
||||
|
||||
|
||||
sendCopyMessage(true);
|
||||
}).catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error('[UpdateManager] Failed to copy to clipboard:', err);
|
||||
|
||||
|
||||
sendCopyMessage(false, errorMessage);
|
||||
});
|
||||
} else {
|
||||
|
||||
sendCopyMessage(false, 'Clipboard API not available');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add element content to chat
|
||||
*/
|
||||
private addToChat(element: HTMLElement) {
|
||||
const sourceInfo = extractSourceInfo(element);
|
||||
const content = element.innerText || element.textContent || '';
|
||||
|
||||
// console.log('[UpdateManager] Adding to chat:', { content, sourceInfo });
|
||||
|
||||
|
||||
|
||||
const contextSourceInfo = sourceInfo ?? undefined;
|
||||
|
||||
|
||||
const elementInfo = sourceInfo ? {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
className: element.className,
|
||||
textContent: content,
|
||||
sourceInfo,
|
||||
isStaticText: isPureStaticText(element)
|
||||
} : undefined;
|
||||
|
||||
const message: AddToChatMessage = {
|
||||
type: 'ADD_TO_CHAT',
|
||||
payload: {
|
||||
content,
|
||||
context: {
|
||||
sourceInfo: contextSourceInfo,
|
||||
elementInfo
|
||||
}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// iframe vs top-level postMessage
|
||||
if (typeof window !== 'undefined' && window.self !== window.top) {
|
||||
|
||||
bridge.send(message).catch(error => {
|
||||
console.error('[UpdateManager] Failed to send ADD_TO_CHAT via bridge:', error);
|
||||
|
||||
window.parent.postMessage(message, '*');
|
||||
});
|
||||
} else {
|
||||
|
||||
window.postMessage(message, '*');
|
||||
}
|
||||
|
||||
// Format alert message with source info
|
||||
let alertMessage = 'Added to chat:\n\n';
|
||||
if (sourceInfo) {
|
||||
alertMessage += `File: ${sourceInfo.fileName}\n`;
|
||||
alertMessage += `Line: L${sourceInfo.lineNumber}\n`;
|
||||
alertMessage += `\n`;
|
||||
}
|
||||
alertMessage += `Text:\n${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteElement (stub)
|
||||
*/
|
||||
private deleteElement(element: HTMLElement) {
|
||||
// Destructive edit not implemented
|
||||
}
|
||||
|
||||
/**
|
||||
* updateStyle
|
||||
*/
|
||||
public updateStyle(
|
||||
element: HTMLElement,
|
||||
newClass: string,
|
||||
sourceInfo: SourceInfo
|
||||
): Promise<UpdateResult> {
|
||||
return this.editManager.updateStyle(element, newClass, sourceInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateContent
|
||||
*/
|
||||
public updateContent(
|
||||
element: HTMLElement,
|
||||
newContent: string,
|
||||
sourceInfo?: SourceInfo,
|
||||
oldValue?: string
|
||||
): Promise<UpdateResult> {
|
||||
return this.editManager.updateContent(element, newContent, sourceInfo, oldValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateAttribute
|
||||
*/
|
||||
public updateAttribute(
|
||||
element: HTMLElement,
|
||||
attributeName: string,
|
||||
newValue: string,
|
||||
sourceInfo: SourceInfo
|
||||
): Promise<UpdateResult> {
|
||||
return this.editManager.updateAttribute(element, attributeName, newValue, sourceInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* batchUpdate
|
||||
*/
|
||||
public batchUpdate(updates: BatchUpdateItem[]): Promise<UpdateResult[]> {
|
||||
if (!this.config.enableBatching) {
|
||||
// Sequential when batching off
|
||||
return Promise.all(
|
||||
updates.map(item => {
|
||||
if (item.type === 'style') {
|
||||
return this.updateStyle(
|
||||
item.element,
|
||||
item.newValue,
|
||||
item.sourceInfo
|
||||
);
|
||||
} else if (item.type === 'content') {
|
||||
return this.updateContent(
|
||||
item.element,
|
||||
item.newValue,
|
||||
item.sourceInfo
|
||||
);
|
||||
} else {
|
||||
return this.updateAttribute(
|
||||
item.element,
|
||||
'data-test',
|
||||
item.newValue,
|
||||
item.sourceInfo
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Debounced batch
|
||||
return new Promise(resolve => {
|
||||
|
||||
const updateStates: UpdateState[] = updates.map(item => ({
|
||||
id: this.generateUpdateId(),
|
||||
operation: item.type === 'style' ? 'style_update' : 'content_update',
|
||||
sourceInfo: item.sourceInfo,
|
||||
element: item.element,
|
||||
oldValue: item.originalValue || '',
|
||||
newValue: item.newValue,
|
||||
status: 'pending' as const,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
}));
|
||||
|
||||
|
||||
this.updateQueue.push(...updateStates);
|
||||
|
||||
|
||||
if (this.batchTimer) {
|
||||
clearTimeout(this.batchTimer);
|
||||
}
|
||||
|
||||
this.batchTimer = setTimeout(async () => {
|
||||
const results = await this.processBatchUpdate(updateStates);
|
||||
resolve(results);
|
||||
}, this.config.batchDebounceMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* processUpdate
|
||||
*/
|
||||
private async processUpdate(update: UpdateState): Promise<UpdateResult> {
|
||||
return this.updateService.processUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* processBatchUpdate
|
||||
*/
|
||||
private async processBatchUpdate(
|
||||
updates: UpdateState[]
|
||||
): Promise<UpdateResult[]> {
|
||||
return this.updateService.processBatchUpdate(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* (extract in service)
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* generateUpdateId
|
||||
*/
|
||||
private generateUpdateId(): string {
|
||||
return `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* triggerAutoSave
|
||||
*/
|
||||
private triggerAutoSave() {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveAllChanges();
|
||||
}, this.config.saveDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* saveAllChanges
|
||||
*/
|
||||
public async saveAllChanges(): Promise<void> {
|
||||
const pendingUpdates = this.updateQueue.filter(u => u.status === 'pending');
|
||||
if (pendingUpdates.length === 0) return;
|
||||
|
||||
try {
|
||||
const results = await this.processBatchUpdate(pendingUpdates);
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Failed to save changes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* undoLastUpdate
|
||||
*/
|
||||
public undoLastUpdate(): boolean {
|
||||
const lastUpdate = this.historyManager.undo();
|
||||
if (!lastUpdate) return false;
|
||||
|
||||
// Restore DOM from history
|
||||
lastUpdate.element.innerText = lastUpdate.oldValue;
|
||||
lastUpdate.element.className = lastUpdate.oldValue;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* redoLastUpdate
|
||||
*/
|
||||
public redoLastUpdate(): boolean {
|
||||
const lastReverted = this.historyManager.redo();
|
||||
if (!lastReverted) return false;
|
||||
|
||||
// Re-apply from history
|
||||
lastReverted.element.innerText = lastReverted.newValue;
|
||||
lastReverted.element.className = lastReverted.newValue;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* addUpdateCallback
|
||||
*/
|
||||
public addUpdateCallback(
|
||||
operation: UpdateOperation,
|
||||
callback: (update: UpdateState) => void
|
||||
) {
|
||||
if (!this.callbacks.has(operation)) {
|
||||
this.callbacks.set(operation, new Set());
|
||||
}
|
||||
this.callbacks.get(operation)!.add(callback);
|
||||
|
||||
|
||||
return () => {
|
||||
this.callbacks.get(operation)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* notifyCallbacks
|
||||
*/
|
||||
private notifyCallbacks(operation: UpdateOperation, update: UpdateState) {
|
||||
const callbacks = this.callbacks.get(operation);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => callback(update));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getUpdateStates
|
||||
*/
|
||||
public getUpdateStates(): UpdateState[] {
|
||||
return [...this.updateQueue];
|
||||
}
|
||||
|
||||
/**
|
||||
* getUpdateHistory
|
||||
*/
|
||||
public getUpdateHistory(): UpdateState[] {
|
||||
return this.historyManager.getHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy
|
||||
*/
|
||||
public destroy() {
|
||||
|
||||
this.observerManager.disable();
|
||||
|
||||
|
||||
if (this.batchTimer) {
|
||||
clearTimeout(this.batchTimer);
|
||||
}
|
||||
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
|
||||
|
||||
this.updateQueue = [];
|
||||
this.historyManager.clear();
|
||||
this.callbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateManager React Hook
|
||||
*/
|
||||
export const useUpdateManager = (config?: Partial<UpdateManagerConfig>) => {
|
||||
const updateManagerRef = useRef<UpdateManager | null>(null);
|
||||
const [updateStates, setUpdateStates] = useState<UpdateState[]>([]);
|
||||
const { config: designModeConfig, isDesignMode, selectedElement } = useDesignMode();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Direct edit can run outside strict design mode when enabled
|
||||
updateManagerRef.current = new UpdateManager({
|
||||
enableDirectEdit: designModeConfig.iframeMode?.enableDirectEdit ?? true,
|
||||
enableBatching: designModeConfig.batchUpdate?.enabled ?? true,
|
||||
batchDebounceMs: designModeConfig.batchUpdate?.debounceMs ?? 300,
|
||||
maxRetries: 3,
|
||||
autoSave: true,
|
||||
saveDelay: 1000,
|
||||
validation: {
|
||||
validateSource: true,
|
||||
validateValue: true,
|
||||
maxLength: 10000,
|
||||
},
|
||||
...config,
|
||||
});
|
||||
|
||||
// Track content_update events
|
||||
const unsubscribe = updateManagerRef.current.addUpdateCallback(
|
||||
'content_update',
|
||||
update => {
|
||||
setUpdateStates(prev => [...prev, update]);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
updateManagerRef.current?.destroy();
|
||||
updateManagerRef.current = null;
|
||||
};
|
||||
}, [designModeConfig]);
|
||||
|
||||
// Sync design mode state and selected element with UpdateManager
|
||||
const { selectElement } = useDesignMode();
|
||||
React.useEffect(() => {
|
||||
if (updateManagerRef.current) {
|
||||
updateManagerRef.current.setDesignModeState(isDesignMode, selectedElement, selectElement);
|
||||
}
|
||||
}, [isDesignMode, selectedElement, selectElement]);
|
||||
|
||||
|
||||
return {
|
||||
updateManager: updateManagerRef.current,
|
||||
updateStates,
|
||||
updateStyle: (
|
||||
element: HTMLElement,
|
||||
newClass: string,
|
||||
sourceInfo: SourceInfo
|
||||
) => updateManagerRef.current?.updateStyle(element, newClass, sourceInfo),
|
||||
updateContent: (
|
||||
element: HTMLElement,
|
||||
newContent: string,
|
||||
sourceInfo: SourceInfo
|
||||
) =>
|
||||
updateManagerRef.current?.updateContent(element, newContent, sourceInfo),
|
||||
batchUpdate: (updates: BatchUpdateItem[]) =>
|
||||
updateManagerRef.current?.batchUpdate(updates),
|
||||
saveAllChanges: () => updateManagerRef.current?.saveAllChanges(),
|
||||
undoLastUpdate: () => updateManagerRef.current?.undoLastUpdate(),
|
||||
redoLastUpdate: () => updateManagerRef.current?.redoLastUpdate(),
|
||||
};
|
||||
};
|
||||
|
||||
export default UpdateManager;
|
||||
@@ -0,0 +1,651 @@
|
||||
import {
|
||||
DesignModeMessage,
|
||||
IframeToParentMessage,
|
||||
ParentToIframeMessage,
|
||||
RequestResponseMessage,
|
||||
MessageValidator,
|
||||
BridgeInterface,
|
||||
BridgeConfig,
|
||||
MessageUtilsInterface,
|
||||
MessageValidationResult,
|
||||
AcknowledgementMessage,
|
||||
HealthCheckMessage,
|
||||
HealthCheckResponseMessage,
|
||||
HeartbeatMessage
|
||||
} from '@xagi/design-mode-shared/messages';
|
||||
|
||||
/**
|
||||
* postMessage bridge: optional request/response, validation hooks, heartbeats.
|
||||
*/
|
||||
export class EnhancedBridge implements BridgeInterface {
|
||||
private listeners: Map<string, Set<Function>> = new Map();
|
||||
private pendingRequests: Map<string, {
|
||||
resolve: Function;
|
||||
reject: Function;
|
||||
timeout: NodeJS.Timeout;
|
||||
responseType: string;
|
||||
}> = new Map();
|
||||
|
||||
private config: BridgeConfig;
|
||||
private _isConnected = false;
|
||||
private lastHeartbeat = 0;
|
||||
private heartbeatTimer?: NodeJS.Timeout;
|
||||
private connectionCheckTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: Partial<BridgeConfig> = {}) {
|
||||
this.config = {
|
||||
timeout: 10000,
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
debug: false,
|
||||
...config
|
||||
};
|
||||
|
||||
this.initializeMessageHandling();
|
||||
this.initializeHeartbeat();
|
||||
this.initializeConnectionCheck();
|
||||
}
|
||||
|
||||
/** Listen for `message` and mark connected after a short delay (iframe vs top). */
|
||||
private initializeMessageHandling() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('message', this.handleMessage.bind(this));
|
||||
if (this.isIframeEnvironment()) {
|
||||
setTimeout(() => {
|
||||
this._isConnected = true;
|
||||
this.log('Bridge initialized and connected (iframe mode)');
|
||||
|
||||
this.sendReadyMessage();
|
||||
}, 200);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this._isConnected = true;
|
||||
this.log('Bridge initialized and connected (main window mode)');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify parent that the child iframe bridge is ready. */
|
||||
private sendReadyMessage() {
|
||||
try {
|
||||
const readyMessage = {
|
||||
type: 'BRIDGE_READY',
|
||||
payload: {
|
||||
timestamp: this.createTimestamp(),
|
||||
environment: 'iframe'
|
||||
},
|
||||
timestamp: this.createTimestamp()
|
||||
};
|
||||
this.getTargetWindow().postMessage(readyMessage, '*');
|
||||
this.log('Sent ready message to parent');
|
||||
} catch (error) {
|
||||
this.log('Failed to send ready message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic HEARTBEAT posts when running inside an iframe.
|
||||
*/
|
||||
private initializeHeartbeat() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (this.isIframeEnvironment()) {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodically infer disconnect from stale heartbeats. */
|
||||
private initializeConnectionCheck() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
this.connectionCheckTimer = setInterval(() => {
|
||||
this.checkConnection();
|
||||
}, this.config.heartbeatInterval * 2);
|
||||
}
|
||||
|
||||
/** True when `window.self !== window.top`. */
|
||||
private isIframeEnvironment(): boolean {
|
||||
return typeof window !== 'undefined' && window.self !== window.top;
|
||||
}
|
||||
|
||||
/** Snapshot for diagnostics / health. */
|
||||
public getEnvironmentInfo(): {
|
||||
isIframe: boolean;
|
||||
isConnected: boolean;
|
||||
origin: string;
|
||||
userAgent: string;
|
||||
location: string;
|
||||
} {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
isIframe: false,
|
||||
isConnected: false,
|
||||
origin: '',
|
||||
userAgent: '',
|
||||
location: ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isIframe: this.isIframeEnvironment(),
|
||||
isConnected: this._isConnected,
|
||||
origin: window.location.origin,
|
||||
userAgent: navigator.userAgent.substring(0, 100),
|
||||
location: window.location.href
|
||||
};
|
||||
}
|
||||
|
||||
/** Log bridge state to the console (dev aid). */
|
||||
public diagnose(): void {
|
||||
// Diagnose output is explicitly gated by debug mode to avoid noisy runtime logs.
|
||||
if (!this.config.debug) return;
|
||||
|
||||
const env = this.getEnvironmentInfo();
|
||||
|
||||
console.group('[EnhancedBridge] Bridge Diagnosis');
|
||||
console.log('Environment:', env);
|
||||
console.log('Connection Status:', this._isConnected);
|
||||
console.log('Pending Requests:', this.pendingRequests.size);
|
||||
console.log('Message Listeners:', Array.from(this.listeners.keys()));
|
||||
console.log('Config:', this.config);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/** Parent when iframe; `window` when top-level. */
|
||||
private getTargetWindow(): Window {
|
||||
return this.isIframeEnvironment() ? window.parent : window;
|
||||
}
|
||||
|
||||
/** Route incoming postMessage payloads. */
|
||||
private handleMessage(event: MessageEvent) {
|
||||
// Optional origin filter (disabled by default):
|
||||
// if (event.origin !== window.location.origin && window.location.origin !== 'null') {
|
||||
// if (!event.origin.startsWith('http') && !event.origin.startsWith('https')) {
|
||||
// this.log('Received message from different origin, allowing:', event.origin);
|
||||
// } else {
|
||||
// this.log('Skipping message from different origin:', event.origin);
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
const message = event.data;
|
||||
// Drop malformed payloads
|
||||
if (!this.isValidMessage(message)) {
|
||||
this.log('Invalid message received:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Child handshake
|
||||
if (message.type === 'BRIDGE_READY') {
|
||||
this.log('Received ready message from child');
|
||||
this._isConnected = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 父窗口 -> iframe 的控制类消息:必须优先走业务分发,不能误判为 RPC response。
|
||||
// 否则像 TOGGLE_DESIGN_MODE 这类同样带 requestId/timestamp 的消息会被 isResponseMessage
|
||||
// 吞掉,导致 bridge.on('TOGGLE_DESIGN_MODE') 永远不触发,父页面一直等不到 DESIGN_MODE_CHANGED。
|
||||
if (this.isIframeEnvironment() && this.isParentToIframeCommand(message)) {
|
||||
this.dispatchMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Completes a pending sendWithResponse(仅处理真正的响应类型,避免与父到子命令冲突)
|
||||
if (this.isResponseMessage(message)) {
|
||||
this.handleResponseMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fan-out to on(type) handlers
|
||||
this.dispatchMessage(message);
|
||||
}
|
||||
|
||||
/** Resolve or reject pendingRequests entry. */
|
||||
private handleResponseMessage(message: RequestResponseMessage) {
|
||||
const { requestId } = message;
|
||||
|
||||
if (this.pendingRequests.has(requestId)) {
|
||||
const request = this.pendingRequests.get(requestId)!;
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
|
||||
if (message.type === 'ACKNOWLEDGEMENT') {
|
||||
request.resolve({ success: true, acknowledged: true });
|
||||
} else {
|
||||
request.resolve(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape check before dispatch. */
|
||||
private isValidMessage(message: any): message is DesignModeMessage {
|
||||
return (
|
||||
message &&
|
||||
typeof message.type === 'string' &&
|
||||
this.isSupportedMessageType(message.type)
|
||||
);
|
||||
}
|
||||
|
||||
/** Known DesignModeMessage union tags. */
|
||||
private isSupportedMessageType(type: string): boolean {
|
||||
const supportedTypes = [
|
||||
// Iframe to Parent
|
||||
'ELEMENT_SELECTED', 'ELEMENT_DESELECTED', 'CONTENT_UPDATED', 'STYLE_UPDATED',
|
||||
'DESIGN_MODE_CHANGED', 'ELEMENT_STATE_RESPONSE', 'ERROR', 'ACKNOWLEDGEMENT',
|
||||
'HEARTBEAT', 'HEALTH_CHECK_RESPONSE', 'BRIDGE_READY', 'ADD_TO_CHAT',
|
||||
// Parent to Iframe
|
||||
'TOGGLE_DESIGN_MODE', 'UPDATE_STYLE', 'UPDATE_CONTENT', 'BATCH_UPDATE',
|
||||
'HEALTH_CHECK'
|
||||
];
|
||||
return supportedTypes.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为 sendWithResponse / ACK 通道的响应消息。
|
||||
* 注意:不能仅凭 requestId + timestamp 判断,否则父页面发来的 TOGGLE_DESIGN_MODE
|
||||
*(同样带 requestId)会被误判为 response,从而跳过 dispatchMessage。
|
||||
*/
|
||||
private isResponseMessage(message: any): message is RequestResponseMessage {
|
||||
if (!message || typeof message.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (message.requestId === undefined || message.timestamp === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
message.type === 'ACKNOWLEDGEMENT' ||
|
||||
message.type === 'HEALTH_CHECK_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
/** 父窗口发往 iframe 的控制命令(在 iframe 内必须由业务 handler 处理) */
|
||||
private isParentToIframeCommand(message: DesignModeMessage): boolean {
|
||||
const t = (message as { type?: string }).type;
|
||||
return (
|
||||
t === 'TOGGLE_DESIGN_MODE' ||
|
||||
t === 'UPDATE_STYLE' ||
|
||||
t === 'UPDATE_CONTENT' ||
|
||||
t === 'BATCH_UPDATE' ||
|
||||
t === 'HEALTH_CHECK'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget postMessage (may no-op if not connected).
|
||||
*/
|
||||
public async send<T extends DesignModeMessage>(message: T): Promise<void> {
|
||||
if (!this._isConnected && this.isIframeEnvironment()) {
|
||||
this.log('Bridge not connected, attempting to reconnect...');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (!this._isConnected) return;
|
||||
}
|
||||
|
||||
if (!this._isConnected && !this.isIframeEnvironment()) {
|
||||
this.log('Running in main window, bridge connection not applicable');
|
||||
return;
|
||||
}
|
||||
|
||||
const enhancedMessage = this.enhanceMessage(message);
|
||||
try {
|
||||
this.log('Sending message:', enhancedMessage);
|
||||
|
||||
this.getTargetWindow().postMessage(enhancedMessage, '*');
|
||||
|
||||
if (this.isIframeEnvironment() && enhancedMessage.requestId) {
|
||||
this.sendAcknowledgement(enhancedMessage.requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Error sending message:', error);
|
||||
|
||||
// Dev: surface send failures
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** RPC-style: wait until matching response or timeout. */
|
||||
public async sendWithResponse<T extends DesignModeMessage, R extends DesignModeMessage>(
|
||||
message: T,
|
||||
responseType: R['type']
|
||||
): Promise<R> {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Bridge is not connected');
|
||||
}
|
||||
|
||||
const enhancedMessage = this.enhanceMessage(message);
|
||||
const requestId = enhancedMessage.requestId!;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Request timeout after ${this.config.timeout}ms`));
|
||||
}, this.config.timeout);
|
||||
|
||||
// Pending request map
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
responseType
|
||||
});
|
||||
|
||||
try {
|
||||
this.log('Sending request with response:', enhancedMessage);
|
||||
this.getTargetWindow().postMessage(enhancedMessage, '*');
|
||||
} catch (error) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure requestId + timestamp exist. */
|
||||
private enhanceMessage<T extends DesignModeMessage>(message: T): T & { requestId?: string; timestamp: number } {
|
||||
const requestId = (message as any).requestId || this.generateRequestId();
|
||||
const timestamp = (message as any).timestamp || this.createTimestamp();
|
||||
|
||||
return {
|
||||
...message,
|
||||
requestId,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/** Unique id for correlating responses. */
|
||||
private generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/** ms since epoch */
|
||||
private createTimestamp(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/** Best-effort ACK (messageType not wired through yet). */
|
||||
private sendAcknowledgement(requestId: string) {
|
||||
const acknowledgement: AcknowledgementMessage = {
|
||||
type: 'ACKNOWLEDGEMENT',
|
||||
payload: {
|
||||
messageType: 'UNKNOWN', // TODO: propagate originating type
|
||||
},
|
||||
requestId,
|
||||
timestamp: this.createTimestamp()
|
||||
};
|
||||
|
||||
this.getTargetWindow().postMessage(acknowledgement, '*');
|
||||
}
|
||||
|
||||
/** HEARTBEAT post to parent. */
|
||||
private sendHeartbeat() {
|
||||
const heartbeat: HeartbeatMessage = {
|
||||
type: 'HEARTBEAT',
|
||||
payload: {
|
||||
timestamp: this.createTimestamp()
|
||||
},
|
||||
timestamp: this.createTimestamp()
|
||||
};
|
||||
|
||||
this.getTargetWindow().postMessage(heartbeat, '*');
|
||||
this.lastHeartbeat = Date.now();
|
||||
}
|
||||
|
||||
/** Mark disconnected if no heartbeats for 3× interval. */
|
||||
private checkConnection() {
|
||||
const now = Date.now();
|
||||
const timeSinceLastHeartbeat = now - this.lastHeartbeat;
|
||||
|
||||
if (timeSinceLastHeartbeat > this.config.heartbeatInterval * 3) {
|
||||
this.log('Connection appears to be lost');
|
||||
this._isConnected = false;
|
||||
|
||||
// Optimistic reconnect
|
||||
setTimeout(() => {
|
||||
this._isConnected = true;
|
||||
this.log('Connection restored');
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe; returns unsubscribe. */
|
||||
public on<T extends DesignModeMessage>(type: T['type'], handler: (message: T) => void): () => void {
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
|
||||
this.listeners.get(type)!.add(handler);
|
||||
|
||||
// Unsubscribe
|
||||
return () => {
|
||||
this.listeners.get(type)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove one handler for `type`. */
|
||||
public off(type: string, handler: Function): void {
|
||||
this.listeners.get(type)?.delete(handler);
|
||||
}
|
||||
|
||||
/** Invoke all listeners for message.type */
|
||||
private dispatchMessage(message: DesignModeMessage) {
|
||||
const handlers = this.listeners.get(message.type);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
this.log('Error in message handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** `_isConnected` flag */
|
||||
public isConnected(): boolean {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
/** Same as `isConnected` (legacy name). */
|
||||
public isConnectedToTarget(): boolean {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
/** Clear timers, reject pendings, remove listener. */
|
||||
public disconnect(): void {
|
||||
this._isConnected = false;
|
||||
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
}
|
||||
|
||||
if (this.connectionCheckTimer) {
|
||||
clearInterval(this.connectionCheckTimer);
|
||||
}
|
||||
|
||||
// Reject pending RPCs
|
||||
this.pendingRequests.forEach(request => {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Connection disconnected'));
|
||||
});
|
||||
this.pendingRequests.clear();
|
||||
|
||||
// Remove global listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('message', this.handleMessage.bind(this));
|
||||
}
|
||||
this.listeners.clear();
|
||||
|
||||
this.log('Bridge disconnected');
|
||||
}
|
||||
|
||||
/** Summarize connectivity; may issue HEALTH_CHECK RPC in iframe. */
|
||||
public async healthCheck(): Promise<{ status: string; details: any }> {
|
||||
try {
|
||||
const env = this.getEnvironmentInfo();
|
||||
|
||||
// Top-level window
|
||||
if (!env.isIframe && this._isConnected) {
|
||||
return {
|
||||
status: 'healthy',
|
||||
details: {
|
||||
...env,
|
||||
message: 'Bridge is healthy and running in main window'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (env.isIframe && this._isConnected) {
|
||||
// Iframe: ping parent
|
||||
try {
|
||||
const response = await this.sendWithResponse<HealthCheckMessage, HealthCheckResponseMessage>(
|
||||
{
|
||||
type: 'HEALTH_CHECK',
|
||||
requestId: '',
|
||||
timestamp: this.createTimestamp()
|
||||
},
|
||||
'HEALTH_CHECK_RESPONSE'
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
details: {
|
||||
...env,
|
||||
response: response.payload,
|
||||
message: 'Bridge is healthy and responsive'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
details: {
|
||||
...env,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Bridge is connected but not responding to health checks'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: env.isIframe ? 'connecting' : 'unnecessary',
|
||||
details: {
|
||||
...env,
|
||||
message: env.isIframe ? 'Bridge is initializing' : 'Bridge not needed in main window'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
details: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
environment: this.getEnvironmentInfo()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Guarded by config.debug */
|
||||
private log(...args: any[]) {
|
||||
// Keep debug logging capability for explicit troubleshooting sessions.
|
||||
if (this.config.debug) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [EnhancedBridge]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias for disconnect(). */
|
||||
public destroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Small helpers for request ids / timestamps. */
|
||||
export const MessageUtils: MessageUtilsInterface = {
|
||||
generateRequestId: (): string => {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
},
|
||||
|
||||
createTimestamp: (): number => {
|
||||
return Date.now();
|
||||
},
|
||||
|
||||
isValidMessage: (message: any): boolean => {
|
||||
return message &&
|
||||
typeof message.type === 'string' &&
|
||||
typeof message.timestamp === 'number';
|
||||
},
|
||||
|
||||
createResponse: function<T extends IframeToParentMessage>(
|
||||
originalMessage: ParentToIframeMessage,
|
||||
payload: T extends { payload: infer P } ? P : never,
|
||||
success: boolean = true,
|
||||
error?: string
|
||||
): T {
|
||||
return {
|
||||
payload,
|
||||
type: (payload as any)?.type,
|
||||
requestId: (originalMessage as any).requestId || '',
|
||||
timestamp: this.createTimestamp()
|
||||
} as unknown as T;
|
||||
}
|
||||
};
|
||||
|
||||
/** Singleton used by the design-mode client bundle. */
|
||||
export const bridge = new EnhancedBridge();
|
||||
|
||||
/** Structural validation for a subset of message types. */
|
||||
export class MessageValidatorImpl implements MessageValidator {
|
||||
validate(message: any): MessageValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!message) {
|
||||
errors.push('Message is null or undefined');
|
||||
return { isValid: false, errors };
|
||||
}
|
||||
|
||||
if (typeof message.type !== 'string') {
|
||||
errors.push('Message type must be a string');
|
||||
}
|
||||
|
||||
if (typeof message.timestamp !== 'number') {
|
||||
errors.push('Message timestamp must be a number');
|
||||
}
|
||||
|
||||
// Per-type payload checks
|
||||
switch (message.type) {
|
||||
case 'ELEMENT_SELECTED':
|
||||
if (!message.payload?.elementInfo) {
|
||||
errors.push('ELEMENT_SELECTED must have elementInfo in payload');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'UPDATE_STYLE':
|
||||
case 'UPDATE_CONTENT':
|
||||
if (!message.payload?.sourceInfo) {
|
||||
errors.push(`${message.type} must have sourceInfo in payload`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BATCH_UPDATE':
|
||||
if (!Array.isArray(message.payload?.updates)) {
|
||||
errors.push('BATCH_UPDATE must have updates array in payload');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const messageValidator = new MessageValidatorImpl();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { DesignModeProvider } from './DesignModeContext';
|
||||
import { DesignModeManager } from './DesignModeManager';
|
||||
|
||||
const init = () => {
|
||||
const containerId = '__vite_plugin_design_mode__';
|
||||
if (document.getElementById(containerId)) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = containerId;
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Use Shadow DOM to isolate styles
|
||||
const shadowRoot = container.attachShadow({ mode: 'open' });
|
||||
const rootElement = document.createElement('div');
|
||||
shadowRoot.appendChild(rootElement);
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DesignModeProvider>
|
||||
<DesignModeManager />
|
||||
</DesignModeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
function isInIframe() {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { UpdateState, UpdateResult, UpdateOperation, UpdateManagerConfig } from '../types/UpdateTypes';
|
||||
import { SourceInfo } from '@xagi/design-mode-shared/messages';
|
||||
import { extractSourceInfo, hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
|
||||
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver';
|
||||
|
||||
export class EditManager {
|
||||
constructor(
|
||||
private processUpdate: (update: UpdateState) => Promise<UpdateResult>,
|
||||
private config: UpdateManagerConfig
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Handle direct edit (double click or mutation)
|
||||
*/
|
||||
public handleDirectEdit(element: HTMLElement, type: 'content' | 'style') {
|
||||
if (type === 'content') {
|
||||
this.editTextContent(element);
|
||||
} else {
|
||||
this.editStyle(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit text content using contentEditable
|
||||
*/
|
||||
public async editTextContent(element: HTMLElement) {
|
||||
const sourceInfo = resolveSourceInfo(element);
|
||||
if (!sourceInfo) return;
|
||||
|
||||
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
|
||||
if (!hasStaticContent) {
|
||||
console.warn('[EditManager] Cannot edit: element does not have static-content attribute. This might be a component definition, not a usage site.');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = element.innerText;
|
||||
const originalContentEditable = element.contentEditable;
|
||||
|
||||
element.contentEditable = 'true';
|
||||
|
||||
element.setAttribute('data-ignore-mutation', 'true');
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
const handleSave = () => {
|
||||
const newText = element.innerText.trim();
|
||||
|
||||
element.contentEditable = 'false';
|
||||
|
||||
element.removeAttribute('data-ignore-mutation');
|
||||
|
||||
element.removeEventListener('blur', handleSave);
|
||||
element.removeEventListener('input', handleInput);
|
||||
element.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleClickOutside, true);
|
||||
|
||||
if (newText !== originalText.trim()) {
|
||||
element.innerText = newText;
|
||||
|
||||
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
|
||||
relatedElements.forEach(el => {
|
||||
if (el !== element) {
|
||||
el.innerText = newText;
|
||||
}
|
||||
});
|
||||
|
||||
this.notifyContentChanged(element, newText, sourceInfo, originalText);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
element.innerText = originalText;
|
||||
|
||||
element.contentEditable = 'false';
|
||||
|
||||
element.removeAttribute('data-ignore-mutation');
|
||||
|
||||
element.removeEventListener('blur', handleSave);
|
||||
element.removeEventListener('input', handleInput);
|
||||
element.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleClickOutside, true);
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!element.contains(e.target as Node)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const currentText = element.innerText.trim();
|
||||
|
||||
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
|
||||
relatedElements.forEach(el => {
|
||||
if (el !== element) {
|
||||
el.innerText = currentText;
|
||||
}
|
||||
});
|
||||
|
||||
this.notifyContentChangedRealtime(element, currentText, sourceInfo, originalText);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
} else {
|
||||
e.preventDefault();
|
||||
element.blur();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('blur', handleSave);
|
||||
element.addEventListener('input', handleInput);
|
||||
element.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleClickOutside, true);
|
||||
|
||||
element.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content
|
||||
* @param element Target DOM node
|
||||
* @param newValue New text
|
||||
* @param sourceInfo Optional; extracted from element when omitted
|
||||
* @param oldValue Optional; uses current innerText when omitted
|
||||
*/
|
||||
public async updateContent(
|
||||
element: HTMLElement,
|
||||
newValue: string,
|
||||
sourceInfo?: SourceInfo,
|
||||
oldValue?: string
|
||||
): Promise<UpdateResult> {
|
||||
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
|
||||
if (!finalSourceInfo) {
|
||||
throw new Error('Cannot update content: no source info available');
|
||||
}
|
||||
|
||||
const finalOldValue = oldValue ?? element.innerText;
|
||||
|
||||
const update: UpdateState = {
|
||||
id: this.generateUpdateId(),
|
||||
operation: 'content_update',
|
||||
sourceInfo: finalSourceInfo,
|
||||
element,
|
||||
oldValue: finalOldValue,
|
||||
newValue,
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
persist: false,
|
||||
};
|
||||
|
||||
const relatedElements = this.findAllElementsWithSameSource(element, finalSourceInfo);
|
||||
relatedElements.forEach(el => {
|
||||
if (el !== element) {
|
||||
el.innerText = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
return this.processUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update style (class)
|
||||
*/
|
||||
public async updateStyle(
|
||||
element: HTMLElement,
|
||||
newClass: string,
|
||||
sourceInfo: SourceInfo
|
||||
): Promise<UpdateResult> {
|
||||
const oldClass = element.className;
|
||||
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
|
||||
if (!finalSourceInfo) {
|
||||
throw new Error('Cannot update style: no source info available');
|
||||
}
|
||||
|
||||
const update: UpdateState = {
|
||||
id: this.generateUpdateId(),
|
||||
operation: 'class_update',
|
||||
sourceInfo,
|
||||
element,
|
||||
oldValue: oldClass,
|
||||
newValue: newClass,
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
persist: false,
|
||||
};
|
||||
|
||||
const relatedElements = this.findAllElementsWithSameSource(element, finalSourceInfo);
|
||||
relatedElements.forEach(el => {
|
||||
if (el !== element) {
|
||||
el.className = newClass;
|
||||
}
|
||||
});
|
||||
|
||||
return this.processUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit style (trigger UI)
|
||||
*/
|
||||
public editStyle(element: HTMLElement) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attribute
|
||||
*/
|
||||
public async updateAttribute(
|
||||
element: HTMLElement,
|
||||
attributeName: string,
|
||||
newValue: string,
|
||||
sourceInfo: SourceInfo
|
||||
): Promise<UpdateResult> {
|
||||
const oldValue = element.getAttribute(attributeName) || '';
|
||||
|
||||
const update: UpdateState = {
|
||||
id: this.generateUpdateId(),
|
||||
operation: 'attribute_update',
|
||||
sourceInfo,
|
||||
element,
|
||||
oldValue,
|
||||
newValue,
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
persist: false,
|
||||
};
|
||||
|
||||
const relatedElements = this.findAllElementsWithSameSource(element, sourceInfo);
|
||||
relatedElements.forEach(el => {
|
||||
if (el !== element) {
|
||||
el.setAttribute(attributeName, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
return this.processUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit attributes (trigger UI)
|
||||
*/
|
||||
public editAttributes(element: HTMLElement) {
|
||||
}
|
||||
|
||||
private generateUpdateId(): string {
|
||||
return `update-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify parent of final text (iframe only); does not write source files.
|
||||
*/
|
||||
private notifyContentChanged(
|
||||
element: HTMLElement,
|
||||
newValue: string,
|
||||
sourceInfo?: SourceInfo,
|
||||
oldValue?: string
|
||||
): void {
|
||||
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
|
||||
if (!finalSourceInfo) {
|
||||
console.warn('[EditManager] Cannot notify: no source info available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.self !== window.top) {
|
||||
window.parent.postMessage({
|
||||
type: 'CONTENT_UPDATED',
|
||||
payload: {
|
||||
sourceInfo: finalSourceInfo,
|
||||
oldValue: oldValue || '',
|
||||
newValue: newValue,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
private lastRealtimeNotify: number = 0;
|
||||
private readonly REALTIME_THROTTLE_MS = 300;
|
||||
|
||||
/**
|
||||
* Throttled CONTENT_UPDATED while typing (realtime: true).
|
||||
*/
|
||||
private notifyContentChangedRealtime(
|
||||
element: HTMLElement,
|
||||
newValue: string,
|
||||
sourceInfo?: SourceInfo,
|
||||
oldValue?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastRealtimeNotify < this.REALTIME_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastRealtimeNotify = now;
|
||||
|
||||
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
|
||||
if (!finalSourceInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.self !== window.top) {
|
||||
window.parent.postMessage({
|
||||
type: 'CONTENT_UPDATED',
|
||||
payload: {
|
||||
sourceInfo: finalSourceInfo,
|
||||
oldValue: oldValue || '',
|
||||
newValue: newValue,
|
||||
realtime: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List peers that share the same logical instance (lists): same `element-id`,
|
||||
* same static-content presence, same mapped file.
|
||||
*
|
||||
* @param sourceInfo Deprecated; kept for signature compatibility
|
||||
*/
|
||||
private findAllElementsWithSameSource(element: HTMLElement, sourceInfo?: SourceInfo): HTMLElement[] {
|
||||
const elementId = element.getAttribute(AttributeNames.elementId);
|
||||
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
|
||||
const refSourceInfo = extractSourceInfo(element);
|
||||
const refFileName = refSourceInfo?.fileName;
|
||||
|
||||
if (!elementId) {
|
||||
console.warn('[EditManager] Element missing element-id attribute:', element);
|
||||
return [element];
|
||||
}
|
||||
|
||||
const allElementsWithId = Array.from(
|
||||
document.querySelectorAll(`[${AttributeNames.elementId}]`)
|
||||
) as HTMLElement[];
|
||||
|
||||
return allElementsWithId.filter(el => {
|
||||
const elId = el.getAttribute(AttributeNames.elementId);
|
||||
const elHasStaticContent = el.hasAttribute(AttributeNames.staticContent);
|
||||
const elSourceInfo = extractSourceInfo(el);
|
||||
const elFileName = elSourceInfo?.fileName;
|
||||
|
||||
if (elId !== elementId) return false;
|
||||
|
||||
if (elHasStaticContent !== hasStaticContent) return false;
|
||||
|
||||
if (elFileName !== refFileName) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
|
||||
|
||||
export class ObserverManager {
|
||||
private observer: MutationObserver | null = null;
|
||||
|
||||
constructor(
|
||||
private onEdit: (target: HTMLElement, type: 'content' | 'style') => void,
|
||||
private onNodeAdded: (node: HTMLElement) => void
|
||||
) {}
|
||||
|
||||
public enable() {
|
||||
if (this.observer) return;
|
||||
|
||||
this.observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
// Check if the mutation should be ignored
|
||||
const targetNode = mutation.target;
|
||||
const targetElement = targetNode.nodeType === Node.ELEMENT_NODE
|
||||
? targetNode as HTMLElement
|
||||
: targetNode.parentElement;
|
||||
|
||||
if (targetElement && targetElement.hasAttribute('data-ignore-mutation')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mutation.type === 'childList') {
|
||||
// Handle element addition
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.onNodeAdded(node as HTMLElement);
|
||||
}
|
||||
});
|
||||
} else if (mutation.type === 'characterData') {
|
||||
// Handle text content change
|
||||
const target = mutation.target.parentElement as HTMLElement;
|
||||
if (target && hasSourceMapping(target)) {
|
||||
if (target.hasAttribute('data-ignore-mutation')) {
|
||||
return;
|
||||
}
|
||||
this.onEdit(target, 'content');
|
||||
}
|
||||
} else if (mutation.type === 'attributes') {
|
||||
// Handle attribute change (style, class)
|
||||
const target = mutation.target as HTMLElement;
|
||||
if (target && hasSourceMapping(target)) {
|
||||
const attributeName = mutation.attributeName;
|
||||
const newValue = target.getAttribute(attributeName!);
|
||||
const oldValue = mutation.oldValue;
|
||||
|
||||
if (newValue === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attributeName === 'class') {
|
||||
this.onEdit(target, 'style');
|
||||
} else if (attributeName?.startsWith('style')) {
|
||||
this.onEdit(target, 'style');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
}
|
||||
|
||||
public disable() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SourceInfo } from '@xagi/design-mode-shared/messages';
|
||||
|
||||
/** High-level update kind tracked by `UpdateManager`. */
|
||||
export type UpdateOperation =
|
||||
| 'style_update'
|
||||
| 'content_update'
|
||||
| 'attribute_update'
|
||||
| 'class_update'
|
||||
| 'batch_update';
|
||||
|
||||
/** One in-flight or historical mutation. */
|
||||
export interface UpdateState {
|
||||
id: string;
|
||||
operation: UpdateOperation;
|
||||
sourceInfo: SourceInfo;
|
||||
element: HTMLElement;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'reverted';
|
||||
timestamp: number;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
/** Result of persisting / previewing an update. */
|
||||
export interface UpdateResult {
|
||||
success: boolean;
|
||||
element: HTMLElement;
|
||||
updateId: string;
|
||||
error?: string;
|
||||
serverResponse?: any;
|
||||
}
|
||||
|
||||
/** One row in a batch POST body. */
|
||||
export interface BatchUpdateItem {
|
||||
element: HTMLElement;
|
||||
type: 'style' | 'content' | 'attribute';
|
||||
sourceInfo: SourceInfo;
|
||||
newValue: string;
|
||||
originalValue?: string;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
/** `UpdateManager` runtime options. */
|
||||
export interface UpdateManagerConfig {
|
||||
enableDirectEdit: boolean;
|
||||
enableBatching: boolean;
|
||||
batchDebounceMs: number;
|
||||
maxRetries: number;
|
||||
autoSave: boolean;
|
||||
saveDelay: number;
|
||||
validation: {
|
||||
validateSource: boolean;
|
||||
validateValue: boolean;
|
||||
maxLength: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { extractSourceInfo } from '@xagi/design-mode-shared/sourceInfo';
|
||||
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show a context menu at the specified position
|
||||
*/
|
||||
export function showContextMenu(
|
||||
element: HTMLElement,
|
||||
x: number,
|
||||
y: number,
|
||||
menuItems: MenuItem[]
|
||||
): HTMLElement {
|
||||
// Remove existing menu
|
||||
const existingMenu = document.querySelector(`[${AttributeNames.contextMenu}="true"]`) as HTMLElement;
|
||||
if (existingMenu) {
|
||||
document.body.removeChild(existingMenu);
|
||||
}
|
||||
|
||||
// Preserve hover when context menu opened from a hovered static node
|
||||
const hadHoverState = element.hasAttribute(AttributeNames.contextMenuHover);
|
||||
|
||||
// Create context menu
|
||||
const menu = document.createElement('div');
|
||||
menu.setAttribute(AttributeNames.contextMenu, 'true');
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
menu.style.background = 'white';
|
||||
menu.style.border = '0.5px solid #ccc';
|
||||
menu.style.borderRadius = '6px';
|
||||
menu.style.padding = '0';
|
||||
menu.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)';
|
||||
menu.style.zIndex = '10000';
|
||||
menu.style.minWidth = '150px';
|
||||
menu.style.fontSize = '14px';
|
||||
|
||||
// Create menu items
|
||||
menuItems.forEach(item => {
|
||||
// Separator row
|
||||
if (item.label === '---' || item.disabled) {
|
||||
const separator = document.createElement('div');
|
||||
separator.style.height = '1px';
|
||||
separator.style.background = '#e5e7eb';
|
||||
separator.style.margin = '0';
|
||||
separator.style.padding = '0';
|
||||
menu.appendChild(separator);
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItem = document.createElement('div');
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = '8px 16px';
|
||||
menuItem.style.borderRadius = '4px';
|
||||
menuItem.style.margin = '0';
|
||||
|
||||
if (item.disabled) {
|
||||
menuItem.style.color = '#999';
|
||||
menuItem.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
menuItem.style.cursor = 'pointer';
|
||||
menuItem.style.color = '#333';
|
||||
}
|
||||
|
||||
menuItem.style.background = 'transparent';
|
||||
|
||||
menuItem.addEventListener('mouseenter', () => {
|
||||
if (!item.disabled) {
|
||||
menuItem.style.background = '#f0f0f0';
|
||||
}
|
||||
});
|
||||
|
||||
menuItem.addEventListener('mouseleave', () => {
|
||||
menuItem.style.background = 'transparent';
|
||||
});
|
||||
|
||||
menuItem.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (item.disabled) return;
|
||||
item.action();
|
||||
closeContextMenu(menu);
|
||||
});
|
||||
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Ensure menu stays within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (rect.right > viewportWidth) {
|
||||
menu.style.left = (viewportWidth - rect.width - 10) + 'px';
|
||||
}
|
||||
if (rect.bottom > viewportHeight) {
|
||||
menu.style.top = (viewportHeight - rect.height - 10) + 'px';
|
||||
}
|
||||
|
||||
// Setup close handlers
|
||||
setupContextMenuCloseHandlers(menu, element, hadHoverState);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the context menu
|
||||
*/
|
||||
export function closeContextMenu(menu: HTMLElement) {
|
||||
// Execute cleanup function
|
||||
if ((menu as any).__cleanupHandlers) {
|
||||
(menu as any).__cleanupHandlers();
|
||||
delete (menu as any).__cleanupHandlers;
|
||||
}
|
||||
|
||||
// Restore hover outline after menu closes
|
||||
const targetElement = (menu as any).__targetElement as HTMLElement | null;
|
||||
const hadHoverState = (menu as any).__hadHoverState as boolean;
|
||||
|
||||
// Remove menu element
|
||||
if (menu.parentNode) {
|
||||
menu.parentNode.removeChild(menu);
|
||||
}
|
||||
|
||||
if (targetElement && hadHoverState) {
|
||||
targetElement.removeAttribute(AttributeNames.contextMenuHover);
|
||||
|
||||
setTimeout(() => {
|
||||
const mouseX = (window as any).__lastMouseX || 0;
|
||||
const mouseY = (window as any).__lastMouseY || 0;
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
const isMouseOver =
|
||||
mouseX >= rect.left &&
|
||||
mouseX <= rect.right &&
|
||||
mouseY >= rect.top &&
|
||||
mouseY <= rect.bottom;
|
||||
|
||||
if (!isMouseOver) {
|
||||
targetElement.removeAttribute('data-design-hover');
|
||||
} else {
|
||||
const mouseEnterEvent = new MouseEvent('mouseenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: mouseX,
|
||||
clientY: mouseY
|
||||
});
|
||||
targetElement.dispatchEvent(mouseEnterEvent);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup context menu close handlers (clickoutside and ESC key)
|
||||
*/
|
||||
function setupContextMenuCloseHandlers(
|
||||
menu: HTMLElement,
|
||||
targetElement: HTMLElement,
|
||||
hadHoverState: boolean
|
||||
) {
|
||||
(menu as any).__targetElement = targetElement;
|
||||
(menu as any).__hadHoverState = hadHoverState;
|
||||
|
||||
const trackMouse = (e: MouseEvent) => {
|
||||
(window as any).__lastMouseX = e.clientX;
|
||||
(window as any).__lastMouseY = e.clientY;
|
||||
};
|
||||
document.addEventListener('mousemove', trackMouse);
|
||||
|
||||
const closeMenu = () => {
|
||||
document.removeEventListener('mousemove', trackMouse);
|
||||
closeContextMenu(menu);
|
||||
};
|
||||
|
||||
// Click outside to close
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!menu.contains(e.target as Node)) {
|
||||
// Prevent event from bubbling to avoid deselecting element
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Right-click outside to close
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (!menu.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC key to close
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to close
|
||||
const handleScroll = () => {
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
// Add listeners with slight delay to avoid immediate triggering
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
document.addEventListener('contextmenu', handleContextMenu, true);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll, true);
|
||||
}, 0);
|
||||
|
||||
// Store cleanup function
|
||||
(menu as any).__cleanupHandlers = () => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll, true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface EditOptions {
|
||||
element: HTMLElement;
|
||||
initialValue: string;
|
||||
onSave: (newValue: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode for the given element
|
||||
*/
|
||||
export function enterEditMode(options: EditOptions): HTMLTextAreaElement {
|
||||
const { element, initialValue, onSave, onCancel } = options;
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
|
||||
// Set textarea styles to match element
|
||||
textArea.value = initialValue;
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.zIndex = '9999';
|
||||
textArea.style.width = element.offsetWidth + 'px';
|
||||
textArea.style.height = element.offsetHeight + 'px';
|
||||
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
textArea.style.fontSize = computedStyle.fontSize;
|
||||
textArea.style.fontFamily = computedStyle.fontFamily;
|
||||
textArea.style.color = computedStyle.color;
|
||||
textArea.style.lineHeight = computedStyle.lineHeight;
|
||||
textArea.style.letterSpacing = computedStyle.letterSpacing;
|
||||
textArea.style.textAlign = computedStyle.textAlign;
|
||||
|
||||
textArea.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
textArea.style.border = '2px solid #007acc';
|
||||
textArea.style.padding = '4px';
|
||||
textArea.style.margin = '0';
|
||||
textArea.style.resize = 'none';
|
||||
textArea.style.outline = 'none';
|
||||
|
||||
// Get element bounding rect
|
||||
const rect = element.getBoundingClientRect();
|
||||
textArea.style.left = rect.left + 'px';
|
||||
textArea.style.top = rect.top + 'px';
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
|
||||
// Handle save and cancel
|
||||
const handleSave = () => {
|
||||
onSave(textArea.value);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// Event listeners
|
||||
textArea.addEventListener('blur', handleSave);
|
||||
textArea.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
});
|
||||
|
||||
return textArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit edit mode
|
||||
*/
|
||||
export function exitEditMode(editor: HTMLElement) {
|
||||
if (editor.parentNode) {
|
||||
editor.parentNode.removeChild(editor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Simple Toast Notification Utility
|
||||
*/
|
||||
export class Toast {
|
||||
private static container: HTMLElement | null = null;
|
||||
private static styleId = 'appdev-design-mode-toast-styles';
|
||||
|
||||
private static ensureContainer() {
|
||||
if (this.container && document.body.contains(this.container)) return;
|
||||
|
||||
// Inject styles
|
||||
if (!document.getElementById(this.styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = this.styleId;
|
||||
style.textContent = `
|
||||
.design-mode-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.design-mode-toast {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: design-mode-toast-in 0.3s ease-out forwards;
|
||||
border: 1px solid #eee;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.design-mode-toast.error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
.design-mode-toast.success {
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
.design-mode-toast.info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
@keyframes design-mode-toast-in {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes design-mode-toast-out {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-20px); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Create container
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'design-mode-toast-container';
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
public static show(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
|
||||
this.ensureContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `design-mode-toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
this.container!.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'design-mode-toast-out 0.3s ease-in forwards';
|
||||
toast.addEventListener('animationend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}, duration);
|
||||
}
|
||||
|
||||
public static error(message: string, duration = 4000) {
|
||||
this.show(message, 'error', duration);
|
||||
}
|
||||
|
||||
public static success(message: string, duration = 3000) {
|
||||
this.show(message, 'success', duration);
|
||||
}
|
||||
|
||||
public static info(message: string, duration = 3000) {
|
||||
this.show(message, 'info', duration);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user