diff --git a/docs/assets/pr/site-explorer-hover-preview.png b/docs/assets/pr/site-explorer-hover-preview.png new file mode 100644 index 00000000..78d2a0b4 Binary files /dev/null and b/docs/assets/pr/site-explorer-hover-preview.png differ diff --git a/src/__tests__/publisher/previewOverlay.test.tsx b/src/__tests__/publisher/previewOverlay.test.tsx index 4949aca8..e54e9a04 100644 --- a/src/__tests__/publisher/previewOverlay.test.tsx +++ b/src/__tests__/publisher/previewOverlay.test.tsx @@ -305,7 +305,11 @@ describe('PreviewOverlay — source enforcement', () => { }) it('uses the CMS runtime preview boundary instead of bypassing server prefetch', () => { - expect(overlaySrc).toContain('buildCmsRuntimePreview(') + const previewHookSrc = readFileSync( + new URL('../../admin/pages/site/preview/useRuntimePreviewDocument.ts', import.meta.url), + 'utf-8', + ) + expect(previewHookSrc).toContain('buildCmsRuntimePreview(') expect(overlaySrc).not.toContain('publishPage(') }) }) diff --git a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx index d6a5fd88..5a25d89f 100644 --- a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx +++ b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx @@ -195,6 +195,39 @@ describe('SiteExplorerPanel', () => { expect(rows[0].getAttribute('draggable')).not.toBe('true') }) + it('shows a server-built desktop preview when a page row is hovered', async () => { + const originalFetch = globalThis.fetch + const previewRequests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + globalThis.fetch = async (input, init) => { + previewRequests.push({ input, init }) + return new Response(JSON.stringify({ + html: '
Hover preview
', + assets: [], + runtimeAssets: { scripts: [] }, + diagnostics: [], + }), { status: 200 }) + } + + try { + loadSite() + render() + + fireEvent.mouseEnter(rowForButton(/open page home/i)) + const preview = await screen.findByTestId('site-explorer-hover-preview') + const iframe = await screen.findByTestId('site-explorer-hover-preview-iframe') + + expect(preview.textContent).not.toContain('Desktop · 1440 px') + expect(iframe.getAttribute('srcdoc')).toContain('Hover preview') + expect(previewRequests).toHaveLength(1) + expect(previewRequests[0]?.input).toBe('/admin/api/cms/runtime/preview') + expect(JSON.parse(String(previewRequests[0]?.init?.body))).toMatchObject({ + pageId: 'page-home', + }) + } finally { + globalThis.fetch = originalFetch + } + }) + it('renders nested page and script paths as recursive folders', () => { loadSite() useEditorStore.setState((state) => { diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx new file mode 100644 index 00000000..94b24858 --- /dev/null +++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx @@ -0,0 +1,176 @@ +import { + useEffect, + useRef, + type CSSProperties, + type MouseEventHandler, + type WheelEvent, +} from 'react' +import { createPortal } from 'react-dom' +import { Button } from '@ui/components/Button' +import { computeFloatingPosition } from '@ui/lib/floatingPosition' +import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext' +import { useRuntimePreviewDocument } from '@site/preview/useRuntimePreviewDocument' +import { useEditorStore } from '@site/store/store' +import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' +import type { SiteDocument } from '@core/page-tree' +import type { SiteExplorerItemPreview } from './siteExplorerModel' +import styles from './SiteExplorerPanel.module.css' + +const DESKTOP_VIEWPORT_WIDTH = 1440 +const DESKTOP_VIEWPORT_HEIGHT = 900 +const PREVIEW_WIDTH = 320 +const PREVIEW_SHELL_PADDING = 4 +const PREVIEW_SCALE = PREVIEW_WIDTH / DESKTOP_VIEWPORT_WIDTH +const PREVIEW_HEIGHT = DESKTOP_VIEWPORT_HEIGHT * PREVIEW_SCALE +const PREVIEW_CARD_WIDTH = PREVIEW_WIDTH + PREVIEW_SHELL_PADDING * 2 +const PREVIEW_CARD_HEIGHT = PREVIEW_HEIGHT + PREVIEW_SHELL_PADDING * 2 + +interface SiteExplorerHoverPreviewProps { + anchor: HTMLElement + preview: SiteExplorerItemPreview + onMouseEnter: MouseEventHandler + onMouseLeave: MouseEventHandler + onRequestClose: () => void +} + +export function SiteExplorerHoverPreview({ + anchor, + preview, + onMouseEnter, + onMouseLeave, + onRequestClose, +}: SiteExplorerHoverPreviewProps) { + const previewRef = useRef(null) + const site = useEditorStore((state) => state.site) + const { context: templatePreviewContext, loading: templateContextLoading } = + useTemplatePreviewContext(preview.page) + + useEffect(() => { + const closeOnScroll = () => { + // Wheel scrolling inside the scaled iframe is part of the feature. The + // parent receives that scroll during capture, so keep the card open while + // the pointer is anywhere over it; scrolling the explorer itself still + // dismisses the now-stale anchor position. + if (previewRef.current?.matches(':hover')) return + onRequestClose() + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onRequestClose() + } + + window.addEventListener('scroll', closeOnScroll, { capture: true, passive: true }) + window.addEventListener('keydown', closeOnEscape) + return () => { + window.removeEventListener('scroll', closeOnScroll, { capture: true }) + window.removeEventListener('keydown', closeOnEscape) + } + }, [onRequestClose]) + + if (!site) return null + + const position = computeFloatingPosition(anchor.getBoundingClientRect(), { + floatingWidth: PREVIEW_CARD_WIDTH, + floatingHeight: PREVIEW_CARD_HEIGHT, + side: 'right', + align: 'center', + offset: 10, + viewportMargin: 10, + }) + const previewStyle = { + '--site-preview-x': `${position.x}px`, + '--site-preview-y': `${position.y}px`, + '--site-preview-scale': PREVIEW_SCALE, + } as CSSProperties + + return createPortal( + , + document.body, + ) +} + +interface HoverPreviewDocumentProps { + site: SiteDocument + preview: SiteExplorerItemPreview + templatePreviewContext: TemplateRenderDataContext | undefined +} + +function HoverPreviewDocument({ + site, + preview, + templatePreviewContext, +}: HoverPreviewDocumentProps) { + const iframeRef = useRef(null) + const { html, loading, error, refresh } = useRuntimePreviewDocument( + site, + preview.page, + templatePreviewContext, + ) + + if (error) { + return ( +
+ Preview unavailable + +
+ ) + } + + if (loading || !html) return + + function handlePreviewWheel(event: WheelEvent) { + const previewWindow = iframeRef.current?.contentWindow + if (!previewWindow) return + event.preventDefault() + previewWindow.scrollBy({ + top: event.deltaY, + left: event.deltaX, + behavior: 'auto', + }) + } + + return ( +
+