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

817 lines
21 KiB
TypeScript

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;