"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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