'use client';
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { VirtualServer } from '@/lib/preview/virtual-server';
import {
CompiledProject,
PreviewMessage,
FocusContextPayload,
PreviewHostMessage
} from '@/lib/preview/types';
import { vfs } from '@/lib/vfs';
import { Button } from '@/components/ui/button';
import {
RefreshCw,
Smartphone,
Tablet,
Monitor,
ChevronLeft,
ChevronRight,
Home,
Eye,
Crosshair,
Camera,
Loader2,
Maximize,
Minimize,
LayoutGrid,
} from 'lucide-react';
import { PanelHeader } from '@/components/ui/panel';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn, logger } from '@/lib/utils';
import { captureIframeScreenshot } from '@/lib/utils/screenshot';
import type { ProjectRuntime } from '@/lib/vfs/types';
import type { PlacementResult, PlacementBlockInfo } from '@/lib/preview/types';
import { pushRuntimeError, clearRuntimeErrors } from '@/lib/preview/runtime-errors';
import { PalettePanel } from '@/components/semantic-blocks/palette-panel';
import type { SemanticBlock } from '@/lib/semantic-blocks/types';
import { useWorkspaceStore } from '@/lib/stores/workspace';
export interface MultipagePreviewHandle {
captureScreenshot: (waitForContent?: boolean) => Promise;
startBlockDrag: (block: PlacementBlockInfo) => void;
getActivePath: () => string;
removePlaceholder: (placementId: string) => void;
}
interface MultipagePreviewProps {
projectId: string;
refreshTrigger?: number;
onFocusSelection?: (selection: FocusContextPayload | null) => void;
hasFocusTarget?: boolean;
onClose?: () => void;
deploymentId?: string | null;
onCaptureScreenshot?: (screenshot: string) => void;
entryPoint?: string;
runtime?: ProjectRuntime;
onFullscreen?: () => void;
isFullscreen?: boolean;
placementActive?: boolean;
onPlacementToggle?: () => void;
onPlacementComplete?: (payload: PlacementResult) => void;
}
type DeviceSize = 'mobile' | 'tablet' | 'desktop' | 'responsive';
const DEVICE_SIZES: Record = {
mobile: { width: '375px', height: '100%', maxHeight: '667px' },
tablet: { width: '768px', height: '100%', maxHeight: '1024px' },
desktop: { width: '100%', height: '100%', maxHeight: '900px', maxWidth: '1440px' },
responsive: { width: '100%', height: '100%' }
};
function generatePlacementScript(): string {
return `
`;
const placementScript = generatePlacementScript();
const injectedScripts = navigationScript + placementScript;
if (processedHtml.includes('
')) {
processedHtml = processedHtml.replace('', injectedScripts + '');
} else {
processedHtml += injectedScripts;
}
// Expose the complete blob-URL map to the iframe so the runtime fetch/XHR
// interceptor can resolve any VFS path (e.g. fetch('/components/nav.html')),
// not just the partial map baked into the page during compilation.
const vfsMapJson = JSON.stringify(Object.fromEntries(projectToUse.blobUrls)).replace(/window.__oswVfsBlobUrls = ${vfsMapJson};`;
if (processedHtml.includes('
')) {
processedHtml = processedHtml.replace('
', '
' + vfsMapScript);
} else {
processedHtml = vfsMapScript + processedHtml;
}
// Clear stale runtime errors before loading new content —
// only errors from this compilation should be in the buffer.
clearRuntimeErrors();
iframeRef.current.srcdoc = processedHtml;
setActivePath(normalizedPath);
activePathRef.current = normalizedPath;
setHistoryIndex(currentIndex => {
setNavigationHistory(currentHistory => {
const newHistory = [...currentHistory.slice(0, currentIndex + 1), normalizedPath];
return newHistory;
});
return currentIndex + 1;
});
};
const handleNavigation = useCallback((path: string) => {
loadPage(path);
}, [compiledProject]);
const handleBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
loadPage(navigationHistory[newIndex]);
}
};
const handleForward = () => {
if (historyIndex < navigationHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
loadPage(navigationHistory[newIndex]);
}
};
const handleHome = () => {
loadPage('/');
};
const handleRefresh = () => {
compileAndLoad(true, false);
};
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only handle messages from our own iframe
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) {
return;
}
const data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'console') {
window.dispatchEvent(new CustomEvent('previewConsole', {
detail: { level: data.level, args: data.args },
}));
if (data.level === 'error') {
pushRuntimeError(data.args.join(' '));
}
return;
}
if (data.type === 'navigate' && data.path) {
handleNavigation(data.path);
return;
}
if (data.type === 'selector-selection' && data.payload) {
setSelectorActive(false);
onFocusSelection?.(data.payload);
return;
}
if (data.type === 'selector-cancelled') {
setSelectorActive(false);
return;
}
if (data.type === 'placement-complete' && data.payload) {
setPaletteVisible(true);
onPlacementComplete?.(data.payload);
return;
}
if (data.type === 'placement-cancelled') {
setDraggingBlock(null);
setPaletteVisible(true);
return;
}
if (data.type === 'iframe-click') {
const ps = paletteStateRef.current;
if (ps.localPaletteOpen && ps.paletteVisible && !ps.draggingBlock) {
setLocalPaletteOpen(false);
setTimeout(() => onPlacementToggle?.(), 0);
}
return;
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleNavigation, onFocusSelection, onPlacementComplete]);
useEffect(() => {
return () => {
if (serverRef.current) {
serverRef.current.cleanupBlobUrls();
}
};
}, []);
useEffect(() => {
if (placementActive && selectorActive) {
setSelectorActive(false);
}
}, [placementActive, selectorActive]);
useEffect(() => {
if (!draggingBlock) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
postMessageToIframe({ type: 'placement-cancel' });
setDraggingBlock(null);
setPaletteVisible(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [draggingBlock, postMessageToIframe]);
if (loading) {
return (
);
}
if (error) {
return (
);
}
return (
{/* Mobile Layout - Single row with navigation and page selector */}
= navigationHistory.length - 1}
>
{
const next = !selectorActive;
setSelectorActive(next);
if (next && localPaletteOpen) {
setLocalPaletteOpen(false);
setTimeout(() => onPlacementToggle?.(), 0);
}
}}
disabled={!iframeReady}
style={crosshairButtonStyle}
title={selectorActive ? 'Cancel element selection' : hasFocusTarget ? 'Replace focused element' : 'Select element'}
data-tour-id="focus-crosshair-button"
>
{
setLocalPaletteOpen(prev => !prev);
setTimeout(() => onPlacementToggle?.(), 0);
}}
disabled={!iframeReady}
title="Semantic blocks"
style={localPaletteOpen ? { backgroundColor: 'var(--button-preview-active)', color: 'white' } : {}}
>
{onCaptureScreenshot && (
{isCapturing ? : }
)}
{/* Page selector takes remaining space */}
{compiledProject && compiledProject.routes.length > 1 && (
{compiledProject.routes.map(route => (
{route.title || route.path}
))}
)}
{/* Desktop Layout - Single row */}
= navigationHistory.length - 1}
>
{
const next = !selectorActive;
setSelectorActive(next);
if (next && localPaletteOpen) {
setLocalPaletteOpen(false);
setTimeout(() => onPlacementToggle?.(), 0);
}
}}
disabled={!iframeReady}
style={crosshairButtonStyle}
title={selectorActive ? 'Cancel element selection' : hasFocusTarget ? 'Replace focused element' : 'Select element'}
data-tour-id="focus-crosshair-button"
>
{
setLocalPaletteOpen(prev => !prev);
setTimeout(() => onPlacementToggle?.(), 0);
}}
disabled={!iframeReady}
title="Semantic blocks"
style={localPaletteOpen ? { backgroundColor: 'var(--button-preview-active)', color: 'white' } : {}}
>
{onCaptureScreenshot && (
{isCapturing ? : }
)}
{activePath}
{compiledProject && compiledProject.routes.length > 1 && (
{compiledProject.routes.map(route => (
{route.title || route.path}
))}
)}
handleSetDeviceSize('mobile')}
>
handleSetDeviceSize('tablet')}
>
handleSetDeviceSize('desktop')}
>
{isFullscreen ? (
) : onFullscreen ? (
) : null}
{/* Preview Frame */}
{
if (localPaletteOpen && paletteVisible && !draggingBlock) {
setLocalPaletteOpen(false);
setTimeout(() => onPlacementToggle?.(), 0);
}
}}
>
{ setLocalPaletteOpen(false); setTimeout(() => onPlacementToggle?.(), 0); }}
collapsed={!localPaletteOpen || !paletteVisible}
/>
);
});
MultipagePreviewComponent.displayName = 'MultipagePreview';
export const MultipagePreview = React.memo(MultipagePreviewComponent);