import React, { createContext, useContext, useState, useCallback, useEffect, } from 'react'; import { twMerge } from 'tailwind-merge'; import { DesignModeMessage, ParentToIframeMessage, IframeToParentMessage, BridgeConfig, ElementInfo, SourceInfo, ToggleDesignModeMessage, UpdateStyleMessage, UpdateContentMessage, BatchUpdateMessage, HeartbeatMessage, HealthCheckMessage, HealthCheckResponseMessage, } from '@xagi/design-mode-shared/messages'; import { bridge, messageValidator } from './bridge'; import { AttributeNames } from '@xagi/design-mode-shared/attributeNames'; import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils'; import { extractSourceInfo as extractSourceInfoFromAttributes } from '@xagi/design-mode-shared/sourceInfo'; import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver'; export interface Modification { id: string; element: string; type: 'class' | 'style'; oldValue: string; newValue: string; timestamp: number; } export interface DesignModeConfig { enabled?: boolean; iframeMode?: { enabled: boolean; hideUI: boolean; enableSelection: boolean; enableDirectEdit: boolean; }; batchUpdate?: { enabled: boolean; debounceMs: number; }; bridge?: Partial; } interface DesignModeContextType { // State isDesignMode: boolean; selectedElement: HTMLElement | null; modifications: Modification[]; isConnected: boolean; bridgeStatus: 'connected' | 'disconnected' | 'connecting' | 'error'; // Config config: DesignModeConfig; // Actions toggleDesignMode: () => void; selectElement: (element: HTMLElement | null) => void; modifyElementClass: (element: HTMLElement, newClass: string) => Promise; updateElementContent: ( element: HTMLElement, newContent: string ) => Promise; batchUpdateElements: ( updates: Array<{ element: HTMLElement; type: 'style' | 'content'; newValue: string; originalValue?: string; }> ) => Promise; resetModifications: () => void; // Bridge helpers sendMessage: (message: T) => Promise; sendMessageWithResponse: < T extends DesignModeMessage, R extends DesignModeMessage, >( message: T, responseType: R['type'] ) => Promise; healthCheck: () => Promise; } const DesignModeContext = createContext( undefined ); export const DesignModeProvider: React.FC<{ children: React.ReactNode; config?: DesignModeConfig; }> = ({ children, config: userConfig = {} }) => { // Defaults const defaultConfig: DesignModeConfig = { enabled: true, iframeMode: { enabled: true, hideUI: false, enableSelection: true, enableDirectEdit: true, }, batchUpdate: { enabled: true, debounceMs: 300, }, bridge: { timeout: 10000, retryAttempts: 3, heartbeatInterval: 30000, debug: process.env.NODE_ENV === 'development', }, ...userConfig, }; // State const [isDesignMode, setIsDesignMode] = useState(false); const [selectedElement, setSelectedElement] = useState( null ); const [modifications, setModifications] = useState([]); const [isConnected, setIsConnected] = useState(false); const [bridgeStatus, setBridgeStatus] = useState< 'connected' | 'disconnected' | 'connecting' | 'error' >('connecting'); const [config] = useState(defaultConfig); // Batch debounce state const [batchUpdateTimer, setBatchUpdateTimer] = useState(null); const [pendingBatchUpdates, setPendingBatchUpdates] = useState< Array<{ element: HTMLElement; type: 'style' | 'content'; newValue: string; originalValue?: string; }> >([]); /** * Wire iframe bridge listeners */ useEffect(() => { // Bridge setup if (config.iframeMode?.enabled) { setBridgeStatus('connecting'); // Poll isConnected const connectionCheck = setInterval(() => { const connected = bridge.isConnected(); setIsConnected(connected); setBridgeStatus(connected ? 'connected' : 'disconnected'); }, 1000); // Subscriptions const unsubscribeHandlers: (() => void)[] = []; // TOGGLE_DESIGN_MODE unsubscribeHandlers.push( bridge.on('TOGGLE_DESIGN_MODE', message => { const newState = message.enabled; setIsDesignMode(newState); // Echo DESIGN_MODE_CHANGED if (window.self !== window.top) { sendToParent({ type: 'DESIGN_MODE_CHANGED', enabled: newState, requestId: message.requestId, timestamp: Date.now(), }); } }) ); // UPDATE_STYLE unsubscribeHandlers.push( bridge.on('UPDATE_STYLE', async message => { await handleExternalStyleUpdate(message); }) ); // UPDATE_CONTENT unsubscribeHandlers.push( bridge.on('UPDATE_CONTENT', async message => { await handleExternalContentUpdate(message); }) ); // BATCH_UPDATE unsubscribeHandlers.push( bridge.on('BATCH_UPDATE', async message => { await handleExternalBatchUpdate(message); }) ); // HEARTBEAT unsubscribeHandlers.push( bridge.on('HEARTBEAT', _ => { // Echo HEARTBEAT bridge.send({ type: 'HEARTBEAT', payload: { timestamp: Date.now() }, timestamp: Date.now(), }); }) ); // HEALTH_CHECK unsubscribeHandlers.push( bridge.on('HEALTH_CHECK', async message => { const healthStatus = await bridge.healthCheck(); // Use a type assertion or construct the object carefully to match HealthCheckResponseMessage const response: HealthCheckResponseMessage = { type: 'HEALTH_CHECK_RESPONSE', payload: { status: healthStatus.status === 'healthy' ? 'healthy' : 'unhealthy', version: '2.0.0', uptime: Date.now() - ((window as any).__startTime || 0), }, requestId: message.requestId || '', // Provide requestId timestamp: Date.now(), }; bridge.send(response); }) ); // Initial health probe const initialHealthCheck = setTimeout(async () => { try { const health = await bridge.healthCheck(); setBridgeStatus(health.status === 'healthy' ? 'connected' : 'error'); } catch (error) { setBridgeStatus('error'); } }, 1000); return () => { clearInterval(connectionCheck); clearTimeout(initialHealthCheck); unsubscribeHandlers.forEach(unsubscribe => unsubscribe()); }; } }, [config]); /** * postMessage when iframe + connected */ const sendToParent = useCallback( (message: IframeToParentMessage) => { if (!config.iframeMode?.enabled) { return; } // 优先走 bridge;bridge 未连通或发送失败时兜底直发 parent,避免关键回执丢失。 if (bridge.isConnected()) { bridge.send(message).catch(error => { if (window.self !== window.top) { window.parent.postMessage(message, '*'); } }); return; } if (window.self !== window.top) { window.parent.postMessage(message, '*'); } }, [config.iframeMode?.enabled] ); /** * findElementBySourceInfo */ const findElementBySourceInfo = useCallback( (sourceInfo: SourceInfo): HTMLElement | null => { // 1) element-id if (sourceInfo.elementId) { const element = document.querySelector(`[${AttributeNames.elementId}="${sourceInfo.elementId}"]`); if (element) return element as HTMLElement; } // 2) legacy file/line/column attrs const selector = `[${AttributeNames.file}="${sourceInfo.fileName}"][${AttributeNames.line}="${sourceInfo.lineNumber}"][${AttributeNames.column}="${sourceInfo.columnNumber}"]`; const element = document.querySelector(selector); if (element) return element as HTMLElement; // 3) children-source const childrenSourceValue = `${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`; const elementByChildrenSource = document.querySelector(`[${AttributeNames.childrenSource}="${childrenSourceValue}"]`); if (elementByChildrenSource) return elementByChildrenSource as HTMLElement; // 4) scan all -info const allElements = document.querySelectorAll(`[${AttributeNames.info}]`); for (let i = 0; i < allElements.length; i++) { const el = allElements[i] as HTMLElement; try { const infoStr = el.getAttribute(AttributeNames.info); if (infoStr) { const info = JSON.parse(infoStr); if ( info.fileName === sourceInfo.fileName && info.lineNumber === sourceInfo.lineNumber && info.columnNumber === sourceInfo.columnNumber ) { return el; } } } catch (e) { // ignore parse error } } return null; }, [] ); /** * Parent-driven style patch */ const handleExternalStyleUpdate = useCallback( async (message: UpdateStyleMessage) => { if (!config.iframeMode?.enabled) return; const updateMessage = message; const { sourceInfo, newClass } = updateMessage.payload; try { // validate const validation = messageValidator.validate(updateMessage); if (!validation.isValid) { console.error( '[DesignMode] Invalid style update message:', validation.errors ); return; } // resolve element const element = findElementBySourceInfo(sourceInfo); if (!element) { console.error( '[DesignMode] Element not found for sourceInfo:', sourceInfo ); sendToParent({ type: 'ERROR', payload: { code: 'ELEMENT_NOT_FOUND', message: `Element not found: ${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`, details: { sourceInfo }, }, timestamp: Date.now(), }); return; } const oldClass = element.className; // Same element-id → list sync const elementId = element.getAttribute(AttributeNames.elementId); let relatedElements: HTMLElement[] = [element]; if (elementId) { // query all [element-id] const allElementsWithId = Array.from( document.querySelectorAll(`[${AttributeNames.elementId}]`) ) as HTMLElement[]; relatedElements = allElementsWithId.filter(el => { const elId = el.getAttribute(AttributeNames.elementId); return elId === elementId; }); } else { console.warn('[DesignMode] Element missing element-id attribute, only updating current element'); } // Apply class to all peers relatedElements.forEach(el => { el.setAttribute('data-ignore-mutation', 'true'); el.className = newClass; // Use setTimeout to ensure MutationObserver sees the attribute setTimeout(() => { el.removeAttribute('data-ignore-mutation'); }, 0); }); // Refresh selection ref if (selectedElement === element) { setSelectedElement(element); } // (persist via API — commented) // try { // const response = await fetch('/__appdev_design_mode/update', { // method: 'POST', // headers: { // 'Content-Type': 'application/json', // }, // body: JSON.stringify({ // filePath: sourceInfo.fileName, // line: sourceInfo.lineNumber, // column: sourceInfo.columnNumber, // newValue: newClass, // type: 'style', // originalValue: oldClass, // }), // }); // if (!response.ok) { // throw new Error('Failed to update source'); // } // } catch (error) { // console.error('[DesignMode] Error updating source:', error); // throw error; // } // STYLE_UPDATED sendToParent({ type: 'STYLE_UPDATED', payload: { sourceInfo, oldClass, newClass, }, timestamp: Date.now(), }); } catch (error) { console.error( '[DesignMode] Error handling external style update:', error ); // ERROR sendToParent({ type: 'ERROR', payload: { code: 'STYLE_UPDATE_FAILED', message: error instanceof Error ? error.message : 'Unknown error', details: { sourceInfo: updateMessage.payload.sourceInfo }, }, timestamp: Date.now(), }); } }, [ selectedElement, config.iframeMode?.enabled, sendToParent, setSelectedElement, findElementBySourceInfo ] ); /** * Parent-driven content patch */ const handleExternalContentUpdate = useCallback( async (message: UpdateContentMessage) => { if (!config.iframeMode?.enabled) return; const updateMessage = message; const { sourceInfo, newContent } = updateMessage.payload; try { // validate const validation = messageValidator.validate(updateMessage); if (!validation.isValid) { console.error( '[DesignMode] Invalid content update message:', validation.errors ); return; } // resolve element const element = findElementBySourceInfo(sourceInfo); if (!element) { console.error( '[DesignMode] Element not found for sourceInfo:', sourceInfo ); sendToParent({ type: 'ERROR', payload: { code: 'ELEMENT_NOT_FOUND', message: `Element not found: ${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`, details: { sourceInfo }, }, timestamp: Date.now(), }); return; } const originalContent = element.innerText || element.textContent || ''; // Same element-id → list sync const elementId = element.getAttribute(AttributeNames.elementId); let relatedElements: HTMLElement[] = [element]; if (elementId) { // query all [element-id] const allElementsWithId = Array.from( document.querySelectorAll(`[${AttributeNames.elementId}]`) ) as HTMLElement[]; relatedElements = allElementsWithId.filter(el => { const elId = el.getAttribute(AttributeNames.elementId); return elId === elementId; }); } else { console.warn('[DesignMode] Element missing element-id attribute, only updating current element'); } // Apply text to all peers (list sync) relatedElements.forEach(el => { el.setAttribute('data-ignore-mutation', 'true'); el.innerText = newContent; // Use setTimeout to ensure MutationObserver sees the attribute setTimeout(() => { el.removeAttribute('data-ignore-mutation'); }, 0); }); // Refresh selection ref if (selectedElement === element) { setSelectedElement(element); } // persist flag (commented) // if (updateMessage.payload.persist !== false) { // // Persist via API using extractSourceInfo (commented) // const elementSourceInfo = extractSourceInfo(element); // if (elementSourceInfo) { // try { // const response = await fetch('/__appdev_design_mode/update', { // method: 'POST', // headers: { // 'Content-Type': 'application/json', // }, // body: JSON.stringify({ // filePath: elementSourceInfo.fileName, // line: elementSourceInfo.lineNumber, // column: elementSourceInfo.columnNumber, // newValue: newContent, // type: 'content', // originalValue: originalContent, // }), // }); // if (!response.ok) { // throw new Error('Failed to update source'); // } // } catch (error) { // console.error('[DesignMode] Error updating source:', error); // throw error; // } // } // } // STYLE_UPDATED sendToParent({ type: 'CONTENT_UPDATED_CALLBACK', payload: { sourceInfo, oldValue: originalContent, newValue: newContent, }, timestamp: Date.now(), }); } catch (error) { console.error( '[DesignMode] Error handling external content update:', error ); // ERROR sendToParent({ type: 'ERROR', payload: { code: 'CONTENT_UPDATE_FAILED', message: error instanceof Error ? error.message : 'Unknown error', details: { sourceInfo: updateMessage.payload.sourceInfo }, }, timestamp: Date.now(), }); } }, [ selectedElement, config.iframeMode?.enabled, sendToParent, setSelectedElement, findElementBySourceInfo ] ); /** * updateSource (HTTP) */ const updateSource = useCallback( async ( element: HTMLElement, newValue: string, type: 'style' | 'content', originalValue?: string ) => { const sourceInfo = extractSourceInfo(element); if (!sourceInfo) { throw new Error('Element does not have source mapping data'); } try { const response = await fetch('/__appdev_design_mode/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ filePath: sourceInfo.fileName, line: sourceInfo.lineNumber, column: sourceInfo.columnNumber, newValue, type, originalValue, }), }); if (!response.ok) { throw new Error('Failed to update source'); } } catch (error) { console.error('[DesignMode] Error updating source:', error); throw error; } }, [] ); /** * Parent BATCH_UPDATE */ const handleExternalBatchUpdate = useCallback( async (message: BatchUpdateMessage) => { const updateMessage = message; const { updates } = updateMessage.payload; try { // validate const validation = messageValidator.validate(updateMessage); if (!validation.isValid) { console.error( '[DesignMode] Invalid batch update message:', validation.errors ); return; } // Promise.allSettled items const results = await Promise.allSettled( updates.map(async (update: any) => { const element = findElementBySourceInfo(update.sourceInfo); if (!element) { throw new Error( `Element not found: ${update.sourceInfo.fileName}:${update.sourceInfo.lineNumber}` ); } if (update.type === 'style') { element.setAttribute('data-ignore-mutation', 'true'); element.className = update.newValue; setTimeout(() => element.removeAttribute('data-ignore-mutation'), 0); } else if (update.type === 'content') { element.setAttribute('data-ignore-mutation', 'true'); element.innerText = update.newValue; setTimeout(() => element.removeAttribute('data-ignore-mutation'), 0); } await updateSource( element, update.newValue, update.type, update.originalValue ); return { success: true, sourceInfo: update.sourceInfo }; }) ); } catch (error) { console.error('[DesignMode] Error handling batch update:', error); // ERROR sendToParent({ type: 'ERROR', payload: { code: 'BATCH_UPDATE_FAILED', message: error instanceof Error ? error.message : 'Unknown error', details: { updatesCount: updates?.length || 0 }, }, timestamp: Date.now(), }); } }, [findElementBySourceInfo, sendToParent, updateSource] ); /** * selectElement */ const selectElement = useCallback( async (element: HTMLElement | null) => { if (element && (element.hasAttribute(AttributeNames.staticContent) || element.hasAttribute(AttributeNames.staticClass))) { setSelectedElement(element); } else { setSelectedElement(null); } // iframe: ELEMENT_SELECTED if (element && config.iframeMode?.enabled) { // resolveSourceInfo for usage site const sourceInfo = resolveSourceInfo(element); // console.log('[DesignModeContext] selectElement - resolveSourceInfo:', sourceInfo); if (sourceInfo) { // Static text if attr + pure text const hasStaticContentAttr = element.hasAttribute(AttributeNames.staticContent); const isActuallyPureText = isPureStaticText(element); const isStaticText = hasStaticContentAttr && isActuallyPureText; // static-class attr const isStaticClass = element.hasAttribute(AttributeNames.staticClass); let textContent = ''; if (isStaticText) { textContent = element.textContent || element.innerText || ''; } else { textContent = element.innerText || element.textContent || ''; } const elementInfo: ElementInfo = { tagName: element.tagName.toLowerCase(), className: element.className, textContent: textContent, sourceInfo, isStaticText: isStaticText || false, isStaticClass: isStaticClass, }; sendToParent({ type: 'ELEMENT_SELECTED', payload: { elementInfo }, timestamp: Date.now(), }); } else { console.warn( `[DesignMode] Element selected but could not resolve source info:`, element ); } } else if (!element && config.iframeMode?.enabled) { sendToParent({ type: 'ELEMENT_DESELECTED', timestamp: Date.now(), }); } }, [config.iframeMode?.enabled] ); /** * toggleDesignMode */ const toggleDesignMode = useCallback(() => { setIsDesignMode(prev => { const next = !prev; if (!next) { setSelectedElement(null); } return next; }); }, []); /** * Local extractSourceInfo helper */ const extractSourceInfo = useCallback( (element: HTMLElement): SourceInfo | null => { return extractSourceInfoFromAttributes(element); }, [] ); /** * modifyElementClass */ const modifyElementClass = useCallback( async (element: HTMLElement, newClass: string) => { const oldClasses = element.className; const mergedClasses = twMerge(oldClasses, newClass); // twMerge + DOM element.className = mergedClasses; // PATCH /update await updateSource(element, mergedClasses, 'style', oldClasses); // modifications list const modification: Modification = { id: Date.now().toString(), element: element.id || 'unknown', type: 'class', oldValue: oldClasses, newValue: mergedClasses, timestamp: Date.now(), }; setModifications(prev => [modification, ...prev]); // STYLE_UPDATED to parent if (config.iframeMode?.enabled) { const sourceInfo = extractSourceInfo(element); if (sourceInfo) { sendToParent({ type: 'STYLE_UPDATED', payload: { sourceInfo, oldClass: oldClasses, newClass: mergedClasses, }, timestamp: Date.now(), }); } } }, [updateSource, config.iframeMode?.enabled] ); /** * updateElementContent */ const updateElementContent = useCallback( async (element: HTMLElement, newContent: string) => { const sourceInfo = extractSourceInfo(element); const originalContent = element.innerText; // twMerge + DOM element.innerText = newContent; // PATCH /update await updateSource(element, newContent, 'content', originalContent); // STYLE_UPDATED to parent if (config.iframeMode?.enabled) { const sourceInfo = extractSourceInfo(element); if (sourceInfo) { sendToParent({ type: 'CONTENT_UPDATED', payload: { sourceInfo, oldValue: originalContent, newValue: newContent, }, timestamp: Date.now(), }); } } }, [updateSource, config.iframeMode?.enabled] ); /** * batchUpdateElements */ const batchUpdateElements = useCallback( async ( updates: Array<{ element: HTMLElement; type: 'style' | 'content'; newValue: string; originalValue?: string; }> ) => { if (!config.batchUpdate?.enabled) { // Sequential fallback await Promise.all( updates.map(update => { if (update.type === 'style') { return modifyElementClass(update.element, update.newValue); } else { return updateElementContent(update.element, update.newValue); } }) ); return; } // Debounced queue const newUpdates = [...pendingBatchUpdates, ...updates]; setPendingBatchUpdates(newUpdates); // Reset debounce timer if (batchUpdateTimer) { clearTimeout(batchUpdateTimer); } // Schedule flush const timer = setTimeout(async () => { try { // Build payload const batchUpdateItems = newUpdates.map(update => { const sourceInfo = extractSourceInfo(update.element); if (!sourceInfo) { throw new Error('Element missing source mapping'); } return { type: update.type, sourceInfo, newValue: update.newValue, originalValue: update.originalValue, }; }); // iframe: bridge BATCH_UPDATE if (config.iframeMode?.enabled) { await bridge.send({ type: 'BATCH_UPDATE', payload: { updates: batchUpdateItems }, timestamp: Date.now(), }); } else { // top: fetch batch endpoint await fetch('/__appdev_design_mode/batch-update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: batchUpdateItems, }), }); } // Clear queue setPendingBatchUpdates([]); } catch (error) { console.error('[DesignMode] Batch update failed:', error); setPendingBatchUpdates([]); throw error; } }, config.batchUpdate.debounceMs); setBatchUpdateTimer(timer); }, [ config.batchUpdate, pendingBatchUpdates, batchUpdateTimer, config.iframeMode?.enabled, modifyElementClass, updateElementContent, ] ); /** * resetModifications */ const resetModifications = useCallback(() => { window.location.reload(); }, []); /** * postMessage when iframe + connected */ const sendMessage = useCallback( async (message: T) => { if (config.iframeMode?.enabled) { await bridge.send(message); } }, [config.iframeMode?.enabled] ); /** * sendMessageWithResponse */ const sendMessageWithResponse = useCallback( async ( message: T, responseType: R['type'] ): Promise => { if (config.iframeMode?.enabled) { return await bridge.sendWithResponse(message, responseType); } throw new Error('Iframe mode is not enabled'); }, [config.iframeMode?.enabled] ); /** * healthCheck */ const healthCheck = useCallback(async () => { if (config.iframeMode?.enabled) { return await bridge.healthCheck(); } return { status: 'healthy', details: { mode: 'standalone' } }; }, [config.iframeMode?.enabled]); return ( {children} ); }; export const useDesignMode = () => { const context = useContext(DesignModeContext); if (context === undefined) { throw new Error('useDesignMode must be used within a DesignModeProvider'); } return context; };