builder / components /preview /multipage-preview.tsx
Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
56.4 kB
'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<string | null>;
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<DeviceSize, { width?: string; height?: string; maxHeight?: string; maxWidth?: string }> = {
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 `<script>(function() {
var state = {
active: false,
block: null,
indicator: null,
currentTarget: null,
currentPosition: 'after',
lastX: 0,
lastY: 0,
scrollRaf: null,
scrollSpeed: 0
};
function createIndicator() {
if (state.indicator) return state.indicator;
var el = document.createElement('div');
el.setAttribute('data-semantic-indicator', 'true');
el.style.cssText = 'position:fixed;height:0;border:2px solid rgba(99,102,241,0.95);border-radius:12px;box-shadow:0 0 0 4px rgba(99,102,241,0.32);pointer-events:none;transition:top 0.15s ease-out,left 0.15s ease-out,width 0.15s ease-out,opacity 0.15s;opacity:0;z-index:2147483646;box-sizing:border-box;';
document.body.appendChild(el);
state.indicator = el;
return el;
}
function removeIndicator() {
if (state.indicator) {
state.indicator.style.opacity = '0';
}
}
function isPlaceholderOrIndicator(el) {
if (!el || !el.getAttribute) return false;
return el.getAttribute('data-semantic-placeholder') === 'true' ||
el.getAttribute('data-semantic-indicator') === 'true';
}
function getInsertPosition(el, x, y) {
var rect = el.getBoundingClientRect();
var style = window.getComputedStyle(el.parentNode || el);
var isHorizontal = style.display === 'flex' && (style.flexDirection === 'row' || style.flexDirection === 'row-reverse');
if (isHorizontal) {
return (x - rect.left) < (rect.width / 2) ? 'before' : 'after';
}
return (y - rect.top) < (rect.height / 2) ? 'before' : 'after';
}
function findDropTarget(x, y) {
var el = document.elementFromPoint(x, y);
if (!el || el === document.documentElement || el === document.body) {
var children = document.body.children;
for (var i = children.length - 1; i >= 0; i--) {
if (!isPlaceholderOrIndicator(children[i]) && children[i].tagName !== 'SCRIPT') {
return children[i];
}
}
return null;
}
while (el && (isPlaceholderOrIndicator(el) || el.tagName === 'SCRIPT')) {
el = el.parentElement;
}
if (el && el !== document.body && el !== document.documentElement) {
var pos = window.getComputedStyle(el).position;
if (pos === 'absolute' || pos === 'fixed') {
el = el.parentElement;
}
}
return el && el !== document.body && el !== document.documentElement ? el : null;
}
function buildDomPath(el) {
var parts = [];
while (el && el !== document.body && el !== document.documentElement) {
var tag = el.tagName.toLowerCase();
if (el.id) {
parts.unshift(tag + '#' + el.id);
break;
}
var parent = el.parentElement;
if (parent) {
var siblings = Array.prototype.filter.call(parent.children, function(c) {
return c.tagName === el.tagName && !isPlaceholderOrIndicator(c);
});
if (siblings.length > 1) {
var idx = siblings.indexOf(el) + 1;
tag += ':nth-of-type(' + idx + ')';
}
}
parts.unshift(tag);
el = parent;
}
return 'body > ' + parts.join(' > ');
}
function showIndicator(target, position) {
var indicator = createIndicator();
var rect = target.getBoundingClientRect();
var y = position === 'before' ? rect.top : rect.bottom;
indicator.style.top = y + 'px';
indicator.style.left = rect.left + 'px';
indicator.style.width = rect.width + 'px';
indicator.offsetHeight;
indicator.style.opacity = '1';
state.currentTarget = target;
state.currentPosition = position;
}
function startAutoScroll() {
if (state.scrollRaf) return;
function tick() {
if (state.scrollSpeed !== 0) {
window.scrollBy(0, state.scrollSpeed);
}
state.scrollRaf = requestAnimationFrame(tick);
}
state.scrollRaf = requestAnimationFrame(tick);
}
function stopAutoScroll() {
if (state.scrollRaf) {
cancelAnimationFrame(state.scrollRaf);
state.scrollRaf = null;
}
state.scrollSpeed = 0;
}
function updateAutoScroll(y) {
var vh = window.innerHeight;
var edgeZone = vh * 0.08;
var maxSpeed = 12;
if (y < edgeZone) {
// Top edge — scroll up, faster closer to edge
state.scrollSpeed = -maxSpeed * (1 - y / edgeZone);
startAutoScroll();
} else if (y > vh - edgeZone) {
// Bottom edge — scroll down
state.scrollSpeed = maxSpeed * (1 - (vh - y) / edgeZone);
startAutoScroll();
} else {
state.scrollSpeed = 0;
}
}
function handleHover(x, y) {
if (!state.active) return;
state.lastX = x;
state.lastY = y;
updateAutoScroll(y);
var target = findDropTarget(x, y);
if (!target) {
removeIndicator();
state.currentTarget = null;
return;
}
var position = getInsertPosition(target, x, y);
if (target === state.currentTarget && position === state.currentPosition) return;
showIndicator(target, position);
}
function buildHtmlContext(target, position, blockName) {
// Get the parent element that contains the insertion point
var parent = target.parentNode;
if (!parent || parent === document.body || parent === document.documentElement) {
parent = target; // use target itself if parent is body
}
// Clone the parent to insert a marker comment without modifying the real DOM
var clone = parent.cloneNode(true);
// Find the corresponding target in the clone
var children = Array.prototype.slice.call(parent.children);
var cloneChildren = Array.prototype.slice.call(clone.children);
var targetIndex = -1;
for (var i = 0; i < children.length; i++) {
if (children[i] === target) { targetIndex = i; break; }
}
if (targetIndex >= 0 && targetIndex < cloneChildren.length) {
var marker = document.createComment(' INSERT ' + blockName + ' HERE ');
if (position === 'before') {
clone.insertBefore(marker, cloneChildren[targetIndex]);
} else {
clone.insertBefore(marker, cloneChildren[targetIndex].nextSibling);
}
}
// Remove any semantic placeholders/indicators from the clone
var placeholders = clone.querySelectorAll('[data-semantic-placeholder],[data-semantic-indicator]');
for (var j = placeholders.length - 1; j >= 0; j--) {
placeholders[j].parentNode.removeChild(placeholders[j]);
}
// Remove script tags from clone
var scripts = clone.querySelectorAll('script');
for (var k = scripts.length - 1; k >= 0; k--) {
scripts[k].parentNode.removeChild(scripts[k]);
}
return clone.outerHTML;
}
function handleDrop() {
stopAutoScroll();
if (!state.active || !state.currentTarget || !state.block) return;
var domPath = buildDomPath(state.currentTarget);
var position = state.currentPosition;
// Capture HTML context BEFORE inserting placeholder
var htmlContext = buildHtmlContext(state.currentTarget, position, state.block.name);
removeIndicator();
var wrapper = document.createElement('div');
var placementId = 'sb-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
wrapper.innerHTML = state.block.wireframeHtml;
var placeholder = wrapper.firstChild;
if (placeholder) {
// Tag with placementId so it can be removed later
if (placeholder.setAttribute) placeholder.setAttribute('data-placement-id', placementId);
if (position === 'before') {
state.currentTarget.parentNode.insertBefore(placeholder, state.currentTarget);
} else {
state.currentTarget.parentNode.insertBefore(placeholder, state.currentTarget.nextSibling);
}
}
window.parent.postMessage({
type: 'placement-complete',
payload: {
blockId: state.block.id,
placementId: placementId,
domPath: domPath,
position: position,
htmlContext: htmlContext
}
}, '*');
state.currentTarget = null;
state.currentPosition = 'after';
}
function activate(block) {
state.active = true;
state.block = block;
document.body.style.cursor = 'crosshair';
}
function deactivate(cancelled) {
stopAutoScroll();
state.active = false;
state.block = null;
state.currentTarget = null;
removeIndicator();
document.body.style.cursor = '';
if (cancelled) {
window.parent.postMessage({ type: 'placement-cancelled' }, '*');
}
}
document.addEventListener('click', function() {
window.parent.postMessage({ type: 'iframe-click' }, '*');
});
window.addEventListener('message', function(event) {
var data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'placement-start') {
activate(data.block);
} else if (data.type === 'placement-hover') {
handleHover(data.x, data.y);
} else if (data.type === 'placement-drop') {
handleDrop();
} else if (data.type === 'placement-cancel') {
deactivate(true);
} else if (data.type === 'placement-remove') {
var pid = data.placementId;
if (typeof pid === 'string' && /^sb-\d+-[a-z0-9]+$/.test(pid)) {
var el = document.querySelector('[data-placement-id="' + pid + '"]');
if (el && el.parentNode) el.parentNode.removeChild(el);
}
}
});
})();<\/script>`;
}
const MultipagePreviewComponent = forwardRef<MultipagePreviewHandle, MultipagePreviewProps>(({
projectId,
refreshTrigger,
onFocusSelection,
hasFocusTarget = false,
onClose,
deploymentId,
onCaptureScreenshot,
entryPoint,
runtime,
onFullscreen,
isFullscreen = false,
placementActive,
onPlacementToggle,
onPlacementComplete
}, ref) => {
const [compiledProject, setCompiledProject] = useState<CompiledProject | null>(null);
const [activePath, setActivePath] = useState('/');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deviceSize, setDeviceSize] = useState<DeviceSize>(() => {
try {
const stored = localStorage.getItem('osw-preview-device-size');
if (stored && stored in DEVICE_SIZES) return stored as DeviceSize;
} catch {}
return 'tablet';
});
const handleSetDeviceSize = useCallback((size: DeviceSize) => {
setDeviceSize(size);
try { localStorage.setItem('osw-preview-device-size', size); } catch {}
}, []);
const [navigationHistory, setNavigationHistory] = useState<string[]>(['/']);
const [historyIndex, setHistoryIndex] = useState(0);
const [iframeReady, setIframeReady] = useState(false);
const [selectorActive, setSelectorActive] = useState(false);
const [draggingBlock, setDraggingBlock] = useState<PlacementBlockInfo | null>(null);
const [paletteVisible, setPaletteVisible] = useState(true);
const [localPaletteOpen, setLocalPaletteOpen] = useState(false);
const paletteStateRef = useRef({ localPaletteOpen: false, paletteVisible: true, draggingBlock: null as PlacementBlockInfo | null });
const [isCapturing, setIsCapturing] = useState(false);
useEffect(() => {
paletteStateRef.current = { localPaletteOpen, paletteVisible, draggingBlock };
}, [localPaletteOpen, paletteVisible, draggingBlock]);
const handleCaptureClick = useCallback(async () => {
if (!iframeRef.current || !iframeReady || !onCaptureScreenshot) return;
setIsCapturing(true);
try {
const screenshot = await captureIframeScreenshot(
iframeRef.current,
undefined, undefined, undefined, undefined, undefined, undefined,
false, 1500
);
if (screenshot) onCaptureScreenshot(screenshot);
} finally {
setIsCapturing(false);
}
}, [iframeReady, onCaptureScreenshot]);
const crosshairButtonStyle = useMemo(() => {
if (selectorActive) {
return { backgroundColor: 'var(--button-preview-active)', color: 'white' };
}
if (hasFocusTarget) {
return { backgroundColor: 'rgba(99, 102, 241, 0.12)', color: 'var(--button-preview-active)' };
}
return {};
}, [selectorActive, hasFocusTarget]);
useEffect(() => {
if (placementActive) {
setPaletteVisible(true);
}
}, [placementActive]);
const iframeRef = useRef<HTMLIFrameElement>(null);
const serverRef = useRef<VirtualServer | null>(null);
const compiledProjectRef = useRef<CompiledProject | null>(null);
const activePathRef = useRef<string>('/');
const pendingLoadPath = useRef<string | null>(null);
const selectorActiveRef = useRef(false);
const postMessageToIframe = useCallback((message: PreviewHostMessage) => {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
return;
}
try {
iframeRef.current.contentWindow.postMessage(message, '*');
} catch (err) {
logger.warn('Failed to communicate with preview iframe', err);
}
}, []);
const handlePlacementDragOver = useCallback((e: React.DragEvent) => {
if (!draggingBlock) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
const iframe = iframeRef.current;
if (!iframe) return;
const iframeRect = iframe.getBoundingClientRect();
const computedStyle = window.getComputedStyle(iframe);
const transform = computedStyle.transform;
let scale = 1;
if (transform && transform !== 'none') {
const match = transform.match(/matrix\(([^,]+)/);
if (match) scale = parseFloat(match[1]) || 1;
}
const x = (e.clientX - iframeRect.left) / scale;
const y = (e.clientY - iframeRect.top) / scale;
postMessageToIframe({ type: 'placement-hover', x, y });
}, [draggingBlock, postMessageToIframe]);
const handlePlacementDrop = useCallback((e: React.DragEvent) => {
if (!draggingBlock) return;
e.preventDefault();
postMessageToIframe({ type: 'placement-drop' });
setDraggingBlock(null);
setPaletteVisible(true);
}, [draggingBlock, postMessageToIframe]);
const handlePlacementDragLeave = useCallback((e: React.DragEvent) => {
if (!draggingBlock) return;
const related = e.relatedTarget as Node | null;
const leaving = !related || !e.currentTarget.contains(related);
if (leaving) {
postMessageToIframe({ type: 'placement-cancel' });
}
}, [draggingBlock, postMessageToIframe]);
const startBlockDrag = useCallback((block: PlacementBlockInfo) => {
setDraggingBlock(block);
postMessageToIframe({ type: 'placement-start', block });
}, [postMessageToIframe]);
const handleBlockDragStart = useCallback((block: SemanticBlock) => {
// Defer state updates — synchronous re-render during dragstart
// repositions the drag source and browsers cancel the drag.
setTimeout(() => {
setPaletteVisible(false);
startBlockDrag({ id: block.id, name: block.name, wireframeHtml: block.wireframeHtml });
}, 0);
}, [startBlockDrag]);
// Expose captureScreenshot method via ref
useImperativeHandle(ref, () => ({
captureScreenshot: async (waitForContent?: boolean) => {
if (!iframeRef.current || !iframeReady) {
logger.warn('Cannot capture screenshot: iframe not ready');
return null;
}
return await captureIframeScreenshot(
iframeRef.current,
undefined, undefined, undefined, undefined, undefined, undefined,
waitForContent ?? false,
1500
);
},
startBlockDrag,
getActivePath: () => activePath || '/index.html',
removePlaceholder: (placementId: string) => {
postMessageToIframe({ type: 'placement-remove', placementId });
},
}), [iframeReady, startBlockDrag, activePath, postMessageToIframe]);
const compilingRef = useRef(false);
const pendingCompileOptionsRef = useRef<{ preserve: boolean; showLoading: boolean } | null>(null);
const compileTimeoutRef = useRef<number | null>(null);
const scheduledCompileOptionsRef = useRef<{ preserve: boolean; showLoading: boolean } | null>(null);
const compileGeneration = useRef(0);
const Header = () => (
isFullscreen ? null : <PanelHeader icon={Eye} title="Live Preview" color="var(--button-preview-active)" onClose={onClose} panelKey="preview" />
);
useEffect(() => {
compiledProjectRef.current = compiledProject;
}, [compiledProject]);
useEffect(() => {
selectorActiveRef.current = selectorActive;
if (iframeReady) {
postMessageToIframe({ type: 'selector-toggle', active: selectorActive });
}
}, [selectorActive, iframeReady, postMessageToIframe]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const handleLoad = () => {
postMessageToIframe({ type: 'selector-toggle', active: selectorActiveRef.current });
};
iframe.addEventListener('load', handleLoad);
return () => {
iframe.removeEventListener('load', handleLoad);
};
}, [iframeReady, postMessageToIframe]);
useEffect(() => {
activePathRef.current = activePath;
}, [activePath]);
useEffect(() => {
if (iframeReady && pendingLoadPath.current && compiledProjectRef.current) {
const pathToLoad = pendingLoadPath.current;
pendingLoadPath.current = null;
loadPage(pathToLoad, compiledProjectRef.current);
}
}, [iframeReady]);
// Listen for previewNavigate event (dispatched by AI preview command)
useEffect(() => {
const handler = (e: Event) => {
const path = (e as CustomEvent).detail?.path;
if (!path) return;
if (compiledProjectRef.current) {
loadPage(path, compiledProjectRef.current);
} else {
pendingLoadPath.current = path;
}
};
window.addEventListener('previewNavigate', handler);
return () => window.removeEventListener('previewNavigate', handler);
}, []);
useEffect(() => {
return () => {
if (compileTimeoutRef.current) {
window.clearTimeout(compileTimeoutRef.current);
}
};
}, []);
const compileAndLoadInternal = useCallback(async (preserveCurrentPath = false, showLoading = true) => {
const gen = ++compileGeneration.current;
if (showLoading) {
setLoading(true);
}
setError(null);
try {
await vfs.init();
const currentPath = preserveCurrentPath ? activePathRef.current : null;
if (serverRef.current) {
serverRef.current.cleanupBlobUrls();
}
const server = new VirtualServer(vfs, projectId, { deploymentId: deploymentId || undefined, entryPoint, runtime });
serverRef.current = server;
const compiled = await server.compileProject();
// A newer compile started while we were awaiting — discard this result
if (gen !== compileGeneration.current) return;
setCompiledProject(compiled);
compiledProjectRef.current = compiled;
let pathToLoad = currentPath;
if (!pathToLoad) {
const ep = compiled.entryPoint || '/index.html';
if (ep !== '/index.html' && compiled.blobUrls.has(ep)) {
// Non-default entry point — navigate directly to it
pathToLoad = ep;
} else {
pathToLoad = compiled.blobUrls.has(ep) ? '/' :
(compiled.routes.length > 0 ? compiled.routes[0].path : '/');
}
}
loadPage(pathToLoad, compiled);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to compile project');
logger.error('Compilation error:', err);
} finally {
if (showLoading) {
setLoading(false);
}
}
}, [projectId, deploymentId, entryPoint, runtime]);
const compileAndLoad = useCallback((preserveCurrentPath: boolean = false, showLoading: boolean = true) => {
if (compilingRef.current) {
const pending = pendingCompileOptionsRef.current;
pendingCompileOptionsRef.current = {
preserve: (pending?.preserve ?? false) || preserveCurrentPath,
showLoading: (pending?.showLoading ?? false) || showLoading
};
return;
}
const run = async (preserve: boolean, loadingFlag: boolean) => {
compilingRef.current = true;
try {
await compileAndLoadInternal(preserve, loadingFlag);
} finally {
compilingRef.current = false;
const pending = pendingCompileOptionsRef.current;
pendingCompileOptionsRef.current = null;
if (pending) {
compileAndLoad(pending.preserve, pending.showLoading);
}
}
};
void run(preserveCurrentPath, showLoading);
}, [compileAndLoadInternal]);
const scheduleCompile = useCallback((preserveCurrentPath = false, showLoading = false) => {
const pending = scheduledCompileOptionsRef.current;
scheduledCompileOptionsRef.current = {
preserve: (pending?.preserve ?? false) || preserveCurrentPath,
showLoading: (pending?.showLoading ?? false) || showLoading
};
if (compileTimeoutRef.current) {
window.clearTimeout(compileTimeoutRef.current);
}
compileTimeoutRef.current = window.setTimeout(() => {
const options = scheduledCompileOptionsRef.current;
scheduledCompileOptionsRef.current = null;
compileTimeoutRef.current = null;
if (options) {
compileAndLoad(options.preserve, options.showLoading);
}
}, 150);
}, [compileAndLoad]);
const workspaceReady = useWorkspaceStore(s => s.workspaceReady);
const workspaceReadyRef = useRef(workspaceReady);
workspaceReadyRef.current = workspaceReady;
useEffect(() => {
if (!workspaceReady) return;
compileAndLoad();
}, [projectId, workspaceReady, compileAndLoad]);
// refreshTrigger bumps coalesce with concurrent filesChanged events through
// the same debounce so a bulk operation produces one compile, not two.
const isFirstRefreshTrigger = useRef(true);
useEffect(() => {
if (isFirstRefreshTrigger.current) {
isFirstRefreshTrigger.current = false;
return;
}
if (!workspaceReadyRef.current) return;
scheduleCompile(true);
}, [refreshTrigger, scheduleCompile]);
useEffect(() => {
const handleFileChange = () => {
if (!workspaceReadyRef.current) return;
scheduleCompile(true);
};
const handleFileContentChange = (event: Event) => {
if (!workspaceReadyRef.current) return;
const customEvent = event as CustomEvent<{ projectId?: string }>;
if (!customEvent.detail || customEvent.detail.projectId === projectId) {
scheduleCompile(true);
}
};
window.addEventListener('filesChanged', handleFileChange as EventListener);
window.addEventListener('fileContentChanged', handleFileContentChange as EventListener);
return () => {
window.removeEventListener('filesChanged', handleFileChange as EventListener);
window.removeEventListener('fileContentChanged', handleFileContentChange as EventListener);
};
}, [projectId, scheduleCompile]);
const loadPage = (path: string, compiled?: CompiledProject) => {
const projectToUse = compiled || compiledProjectRef.current || compiledProject;
if (!projectToUse) {
logger.warn('No compiled project available');
return;
}
if (selectorActiveRef.current) {
setSelectorActive(false);
} else {
postMessageToIframe({ type: 'selector-toggle', active: false });
}
if (!iframeRef.current || !iframeReady) {
pendingLoadPath.current = path;
return;
}
let normalizedPath = path;
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath;
}
const route = projectToUse.routes.find(r => r.path === normalizedPath);
let filePath: string;
if (route) {
filePath = route.file;
} else if (normalizedPath === '/') {
filePath = '/index.html';
} else if (normalizedPath.endsWith('.html')) {
// Already a full file path (e.g., entry point like /.renderer/index.html)
filePath = normalizedPath;
} else if (normalizedPath.endsWith('/')) {
// Directory path - look for index.html
filePath = normalizedPath + 'index.html';
} else {
filePath = normalizedPath + '.html';
}
let htmlFile = projectToUse.files.find(f => f.path === filePath);
// If not found and path doesn't end with /, try directory index as fallback
if (!htmlFile && !normalizedPath.endsWith('/')) {
const dirIndexPath = normalizedPath + '/index.html';
htmlFile = projectToUse.files.find(f => f.path === dirIndexPath);
if (htmlFile) {
filePath = dirIndexPath;
}
}
if (!htmlFile) {
setError(`Page not found: ${path}`);
const indexFile = projectToUse.files.find(f => f.path === '/index.html' || f.path === 'index.html');
if (indexFile && path !== '/') {
loadPage('/', compiled);
}
return;
}
let processedHtml = typeof htmlFile.content === 'string'
? htmlFile.content
: new TextDecoder().decode(htmlFile.content as ArrayBuffer);
processedHtml = processedHtml.replace(/href="([^"]+)"/g, (match, href) => {
// Skip if not a CSS file or if it's an external URL
if (!href.endsWith('.css') || href.startsWith('http') || href.startsWith('//')) {
return match;
}
const normalizedHref = href.startsWith('/') ? href : '/' + href;
const blobUrl = projectToUse.blobUrls.get(normalizedHref);
if (blobUrl) {
return `href="${blobUrl}"`;
}
return match;
});
// Replace JavaScript sources
processedHtml = processedHtml.replace(/src="([^"]+)"/g, (match, src) => {
if (!src.endsWith('.js') || src.startsWith('http') || src.startsWith('//')) {
return match;
}
const normalizedSrc = src.startsWith('/') ? src : '/' + src;
const blobUrl = projectToUse.blobUrls.get(normalizedSrc);
if (blobUrl) {
return `src="${blobUrl}"`;
}
return match;
});
processedHtml = processedHtml.replace(/src="([^"]+\.(png|jpg|jpeg|gif|svg|webp))"/gi, (match, imgPath) => {
const normalizedImgPath = imgPath.startsWith('/') ? imgPath : '/' + imgPath;
const blobUrl = projectToUse.blobUrls.get(normalizedImgPath);
return blobUrl ? `src="${blobUrl}"` : match;
});
const navigationScript = `
<script>
(function() {
const isInIframe = window !== window.parent;
function resolveInternalPath(href) {
let path = href;
if (!path.startsWith('/')) {
const currentPath = '${normalizedPath}';
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'));
path = currentDir + '/' + path;
}
if (path.endsWith('.html')) {
path = path.slice(0, -5);
}
if (path === '/index') {
path = '/';
}
return path;
}
document.addEventListener('click', function(e) {
const target = e.target && e.target.closest ? e.target.closest('a') : null;
if (target && target.getAttribute) {
const href = target.getAttribute('href');
if (!href) {
return;
}
if (href.startsWith('#')) {
e.preventDefault();
const targetId = href.substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
return;
}
const isExternal = href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
if (!isExternal) {
if (isInIframe) {
e.preventDefault();
window.parent.postMessage({
type: 'navigate',
path: resolveInternalPath(href)
}, '*');
}
} else {
e.preventDefault();
window.open(href, '_blank');
}
}
});
const selectorState = {
active: false,
overlay: null,
lastTarget: null,
previousCursor: ''
};
function isElement(node) {
return node && node.nodeType === 1;
}
function ensureOverlay() {
if (selectorState.overlay) {
return selectorState.overlay;
}
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.pointerEvents = 'none';
overlay.style.border = '2px solid rgba(99, 102, 241, 0.95)';
overlay.style.background = 'rgba(99, 102, 241, 0.08)';
overlay.style.boxShadow = '0 0 0 4px rgba(99, 102, 241, 0.32), 0 20px 40px rgba(15, 23, 42, 0.28)';
overlay.style.borderRadius = '12px';
overlay.style.zIndex = '2147483647';
overlay.style.transition = 'top 0.12s ease-out, left 0.12s ease-out, width 0.12s ease-out, height 0.12s ease-out';
overlay.style.willChange = 'top, left, width, height';
selectorState.overlay = overlay;
document.body.appendChild(overlay);
return overlay;
}
function positionOverlay(target) {
if (!isElement(target)) {
return;
}
const overlay = ensureOverlay();
const rect = target.getBoundingClientRect();
overlay.style.top = (rect.top + window.scrollY) + 'px';
overlay.style.left = (rect.left + window.scrollX) + 'px';
overlay.style.width = Math.max(rect.width, 1) + 'px';
overlay.style.height = Math.max(rect.height, 1) + 'px';
overlay.style.opacity = '1';
}
function clearOverlay() {
if (selectorState.overlay && selectorState.overlay.parentElement) {
selectorState.overlay.parentElement.removeChild(selectorState.overlay);
}
selectorState.overlay = null;
}
function buildDomPath(element) {
if (!isElement(element)) {
return '';
}
const segments = [];
let current = element;
while (current && current.nodeType === 1) {
let segment = current.tagName.toLowerCase();
if (current.id) {
segment += '#' + current.id;
segments.unshift(segment);
break;
}
const parent = current.parentElement;
if (parent) {
const siblings = parent.children;
let index = 0;
for (let i = 0; i < siblings.length; i++) {
if (siblings[i].tagName === current.tagName) {
index++;
}
if (siblings[i] === current) {
if (index > 1) {
segment += ':nth-of-type(' + index + ')';
} else {
const hasSame = Array.from(siblings).some(function(child, childIndex) {
return childIndex !== i && child.tagName === current.tagName;
});
if (hasSame) {
segment += ':nth-of-type(' + index + ')';
}
}
break;
}
}
}
segments.unshift(segment);
current = parent;
}
return segments.join(' > ');
}
function gatherAttributes(element) {
const attributes = {};
if (!isElement(element) || !element.attributes) {
return attributes;
}
const maxAttributes = 25;
for (let i = 0; i < element.attributes.length && i < maxAttributes; i++) {
const attr = element.attributes[i];
if (!attr) continue;
const name = attr.name;
if (!name || name === 'style' || name.startsWith('on')) {
continue;
}
attributes[name] = attr.value;
}
return attributes;
}
function handleMouseMove(event) {
if (!selectorState.active) {
return;
}
const target = isElement(event.target) ? event.target : (event.target && event.target.parentElement);
if (!isElement(target) || target === selectorState.lastTarget) {
return;
}
selectorState.lastTarget = target;
positionOverlay(target);
}
function handleClick(event) {
if (!selectorState.active) {
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
const target = isElement(event.target) ? event.target : (event.target && event.target.parentElement);
if (!isElement(target)) {
disableSelector(false);
return;
}
const payload = {
domPath: buildDomPath(target),
tagName: target.tagName.toLowerCase(),
attributes: gatherAttributes(target),
outerHTML: target.outerHTML || ''
};
if (isInIframe) {
window.parent.postMessage({ type: 'selector-selection', payload: payload }, '*');
}
disableSelector(false);
}
function handleContextMenu(event) {
if (!selectorState.active) {
return;
}
event.preventDefault();
event.stopPropagation();
}
function handleKeyDown(event) {
if (!selectorState.active) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
disableSelector(true);
}
}
function enableSelector() {
if (selectorState.active) {
return;
}
selectorState.active = true;
selectorState.previousCursor = document.body.style.cursor;
const overlay = ensureOverlay();
overlay.style.opacity = '0';
document.body.style.cursor = 'crosshair';
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
document.addEventListener('contextmenu', handleContextMenu, true);
document.addEventListener('keydown', handleKeyDown, true);
}
function disableSelector(notifyCancel) {
if (!selectorState.active) {
return;
}
selectorState.active = false;
selectorState.lastTarget = null;
if (selectorState.overlay) {
selectorState.overlay.style.opacity = '0';
window.setTimeout(clearOverlay, 120);
} else {
clearOverlay();
}
document.body.style.cursor = selectorState.previousCursor || '';
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
document.removeEventListener('contextmenu', handleContextMenu, true);
document.removeEventListener('keydown', handleKeyDown, true);
if (notifyCancel && isInIframe) {
window.parent.postMessage({ type: 'selector-cancelled' }, '*');
}
}
window.addEventListener('message', function(event) {
const data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'selector-toggle') {
if (data.active) {
enableSelector();
} else {
disableSelector(false);
}
}
});
})();
</script>
`;
const placementScript = generatePlacementScript();
const injectedScripts = navigationScript + placementScript;
if (processedHtml.includes('</body>')) {
processedHtml = processedHtml.replace('</body>', injectedScripts + '</body>');
} 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(/</g, '\\u003c');
const vfsMapScript = `<script>window.__oswVfsBlobUrls = ${vfsMapJson};</script>`;
if (processedHtml.includes('<head>')) {
processedHtml = processedHtml.replace('<head>', '<head>' + 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<PreviewMessage>) => {
// 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 (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-2">
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-primary" />
<p className="text-muted-foreground">Compiling project...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-destructive space-y-2">
<p className="font-medium">Error</p>
<p className="text-sm mt-2">{error}</p>
<Button onClick={handleRefresh} className="mt-4">
Try Again
</Button>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
<Header />
{/* Mobile Layout - Single row with navigation and page selector */}
<div className="border-b p-2 flex items-center gap-2 md:hidden">
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleBack}
disabled={historyIndex === 0}
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleForward}
disabled={historyIndex >= navigationHistory.length - 1}
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleHome}
>
<Home className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleRefresh}
>
<RefreshCw className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => {
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"
>
<Crosshair className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => {
setLocalPaletteOpen(prev => !prev);
setTimeout(() => onPlacementToggle?.(), 0);
}}
disabled={!iframeReady}
title="Semantic blocks"
style={localPaletteOpen ? { backgroundColor: 'var(--button-preview-active)', color: 'white' } : {}}
>
<LayoutGrid className="h-3 w-3" />
</Button>
{onCaptureScreenshot && (
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleCaptureClick}
disabled={!iframeReady || isCapturing}
title="Capture screenshot as thumbnail"
>
{isCapturing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Camera className="h-3 w-3" />}
</Button>
)}
</div>
{/* Page selector takes remaining space */}
{compiledProject && compiledProject.routes.length > 1 && (
<Select value={activePath} onValueChange={handleNavigation}>
<SelectTrigger className="flex-1 h-8 min-w-0 max-w-full">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
{compiledProject.routes.map(route => (
<SelectItem key={route.path} value={route.path}>
{route.title || route.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Desktop Layout - Single row */}
<div className="border-b p-2 hidden md:flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleBack}
disabled={historyIndex === 0}
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleForward}
disabled={historyIndex >= navigationHistory.length - 1}
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleHome}
>
<Home className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleRefresh}
>
<RefreshCw className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => {
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"
>
<Crosshair className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => {
setLocalPaletteOpen(prev => !prev);
setTimeout(() => onPlacementToggle?.(), 0);
}}
disabled={!iframeReady}
title="Semantic blocks"
style={localPaletteOpen ? { backgroundColor: 'var(--button-preview-active)', color: 'white' } : {}}
>
<LayoutGrid className="h-3 w-3" />
</Button>
{onCaptureScreenshot && (
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={handleCaptureClick}
disabled={!iframeReady || isCapturing}
title="Capture screenshot as thumbnail"
>
{isCapturing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Camera className="h-3 w-3" />}
</Button>
)}
</div>
<div className="flex-1 px-3 py-1 bg-muted rounded text-sm">
{activePath}
</div>
{compiledProject && compiledProject.routes.length > 1 && (
<Select value={activePath} onValueChange={handleNavigation}>
<SelectTrigger className="w-[200px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{compiledProject.routes.map(route => (
<SelectItem key={route.path} value={route.path}>
{route.title || route.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex items-center gap-1 border-l pl-2">
<Button
size="icon"
variant="ghost"
className="h-5 w-5 rounded-sm"
style={{
backgroundColor: deviceSize === 'mobile' ? 'var(--button-preview-active)' : undefined,
color: deviceSize === 'mobile' ? 'white' : undefined
}}
onClick={() => handleSetDeviceSize('mobile')}
>
<Smartphone className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 rounded-sm"
style={{
backgroundColor: deviceSize === 'tablet' ? 'var(--button-preview-active)' : undefined,
color: deviceSize === 'tablet' ? 'white' : undefined
}}
onClick={() => handleSetDeviceSize('tablet')}
>
<Tablet className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 rounded-sm"
style={{
backgroundColor: deviceSize === 'desktop' ? 'var(--button-preview-active)' : undefined,
color: deviceSize === 'desktop' ? 'white' : undefined
}}
onClick={() => handleSetDeviceSize('desktop')}
>
<Monitor className="h-3 w-3" />
</Button>
{isFullscreen ? (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 rounded-sm"
onClick={onClose}
title="Exit full size preview"
>
<Minimize className="h-3 w-3" />
</Button>
) : onFullscreen ? (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 rounded-sm"
onClick={onFullscreen}
title="Full size preview"
>
<Maximize className="h-3 w-3" />
</Button>
) : null}
</div>
</div>
{/* Preview Frame */}
<div
className={cn("flex-1 bg-muted/20 dark:bg-muted/10 overflow-auto min-h-0 relative", !isFullscreen && "p-4")}
onClick={() => {
if (localPaletteOpen && paletteVisible && !draggingBlock) {
setLocalPaletteOpen(false);
setTimeout(() => onPlacementToggle?.(), 0);
}
}}
>
<PalettePanel
onDragStart={handleBlockDragStart}
onClose={() => { setLocalPaletteOpen(false); setTimeout(() => onPlacementToggle?.(), 0); }}
collapsed={!localPaletteOpen || !paletteVisible}
/>
<div
className={cn(
"bg-white mx-auto transition-all duration-300",
!isFullscreen && "shadow-2xl",
!isFullscreen && deviceSize !== 'responsive' && "rounded-lg"
)}
style={{
width: DEVICE_SIZES[deviceSize].width || '100%',
height: DEVICE_SIZES[deviceSize].height || '100%',
maxHeight: DEVICE_SIZES[deviceSize].maxHeight || '100%',
maxWidth: DEVICE_SIZES[deviceSize].maxWidth || '100%',
...(draggingBlock ? { cursor: 'crosshair' } : {}),
}}
onDragOver={handlePlacementDragOver}
onDrop={handlePlacementDrop}
onDragLeave={handlePlacementDragLeave}
>
<iframe
ref={(el) => {
iframeRef.current = el;
if (el && !iframeReady) {
// Use setTimeout to ensure iframe document is ready
setTimeout(() => {
setIframeReady(true);
}, 0);
} else if (!el && iframeReady) {
setIframeReady(false);
}
}}
className={cn("w-full h-full", !isFullscreen && "rounded-lg")}
sandbox="allow-scripts allow-same-origin allow-forms"
title="Preview"
style={draggingBlock ? { pointerEvents: 'none' } : undefined}
/>
</div>
</div>
</div>
);
});
MultipagePreviewComponent.displayName = 'MultipagePreview';
export const MultipagePreview = React.memo(MultipagePreviewComponent);