Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/pr/site-explorer-hover-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion src/__tests__/publisher/previewOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(')
})
})
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/site-explorer/siteExplorerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<!doctype html><html><body><main>Hover preview</main></body></html>',
assets: [],
runtimeAssets: { scripts: [] },
diagnostics: [],
}), { status: 200 })
}

try {
loadSite()
render(<SiteExplorerPanel sectionGroup="site" />)

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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>
onMouseLeave: MouseEventHandler<HTMLElement>
onRequestClose: () => void
}

export function SiteExplorerHoverPreview({
anchor,
preview,
onMouseEnter,
onMouseLeave,
onRequestClose,
}: SiteExplorerHoverPreviewProps) {
const previewRef = useRef<HTMLElement>(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(
<aside
ref={previewRef}
className={styles.hoverPreview}
style={previewStyle}
data-side={position.side}
data-testid="site-explorer-hover-preview"
aria-label={`${preview.kindLabel} preview: ${preview.page.title}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className={styles.hoverPreviewViewportFrame}>
{templateContextLoading ? (
<HoverPreviewStatus label="Resolving preview data…" />
) : (
<HoverPreviewDocument
site={site}
preview={preview}
templatePreviewContext={templatePreviewContext}
/>
)}
</div>
</aside>,
document.body,
)
}

interface HoverPreviewDocumentProps {
site: SiteDocument
preview: SiteExplorerItemPreview
templatePreviewContext: TemplateRenderDataContext | undefined
}

function HoverPreviewDocument({
site,
preview,
templatePreviewContext,
}: HoverPreviewDocumentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { html, loading, error, refresh } = useRuntimePreviewDocument(
site,
preview.page,
templatePreviewContext,
)

if (error) {
return (
<div className={styles.hoverPreviewStatus} role="alert">
<span>Preview unavailable</span>
<Button variant="secondary" size="xs" onClick={refresh}>
Retry
</Button>
</div>
)
}

if (loading || !html) return <HoverPreviewStatus label="Building desktop preview…" />

function handlePreviewWheel(event: WheelEvent<HTMLDivElement>) {
const previewWindow = iframeRef.current?.contentWindow
if (!previewWindow) return
event.preventDefault()
previewWindow.scrollBy({
top: event.deltaY,
left: event.deltaX,
behavior: 'auto',
})
}

return (
<div className={styles.hoverPreviewScrollSurface} onWheel={handlePreviewWheel}>
<iframe
ref={iframeRef}
srcDoc={html}
sandbox="allow-same-origin"
tabIndex={-1}
title={`Desktop preview of ${preview.page.title}`}
className={styles.hoverPreviewIframe}
data-testid="site-explorer-hover-preview-iframe"
/>
</div>
)
}

function HoverPreviewStatus({ label }: { label: string }) {
return (
<div className={styles.hoverPreviewStatus} role="status">
<span className={styles.hoverPreviewPulse} aria-hidden="true" />
<span>{label}</span>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,89 @@
min-width: 0;
}

.hoverPreview {
--site-preview-x: 0px;
--site-preview-y: 0px;
--site-preview-scale: 1;
--site-preview-shell-padding: var(--space-3xs);

position: fixed;
left: 0;
top: 0;
z-index: var(--spotlight-z-index);
box-sizing: content-box;
width: 320px;
overflow: hidden;
padding: var(--site-preview-shell-padding);
border-radius: var(--panel-radius);
background: var(--scrim);
box-shadow: var(--shadow-panel);
color: var(--text);
transform: translate3d(var(--site-preview-x), var(--site-preview-y), 0);
animation: hoverPreviewIn 160ms cubic-bezier(0.16, 1, 0.3, 1);
}

.hoverPreviewViewportFrame {
position: relative;
width: 320px;
height: 200px;
overflow: hidden;
border-radius: calc(var(--panel-radius) - var(--site-preview-shell-padding));
background: var(--bg-surface-3);
}

.hoverPreviewIframe {
display: block;
width: 1440px;
height: 900px;
border: 0;
background: var(--overlay);
transform: scale(var(--site-preview-scale));
transform-origin: left top;
pointer-events: none;
}

.hoverPreviewScrollSurface {
width: 100%;
height: 100%;
overflow: hidden;
}

.hoverPreviewStatus {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
color: var(--text-subtle);
font-size: var(--text-xs);
}

.hoverPreviewPulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-disabled);
animation: hoverPreviewPulse 900ms ease-in-out infinite alternate;
}

@keyframes hoverPreviewIn {
from {
opacity: 0;
filter: blur(3px);
transform: translate3d(var(--site-preview-x), var(--site-preview-y), 0)
scale(0.985);
}
}

@keyframes hoverPreviewPulse {
to {
opacity: 0.35;
transform: scale(0.72);
}
}

.dragOverlayRow {
width: min(240px, 70vw);
border-radius: var(--panel-radius);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SiteFile } from '@core/files/schemas'
import type { ExplorerPathChangePlan, Page, SiteExplorerSectionId, StructuralSiteExplorerSectionId } from '@core/page-tree'
import { createUniquePageSlug, pagePublicPath, isHomePage } from '@core/page-tree'
import { templateTargetLabel } from '@core/templates'
import { flattenVCToVirtualPage } from '@core/visualComponents'
import { SkeletonBlock } from '@ui/components/Skeleton'
import { FileTextSolidIcon } from 'pixel-art-icons/icons/file-text-solid'
import { FolderGlyphIcon } from 'pixel-art-icons/icons/folder-glyph'
Expand Down Expand Up @@ -536,6 +537,7 @@ export function SiteExplorerPanel({
pinned: isHomePage(page),
ariaLabel: `Open page ${page.title}`,
target: { kind: 'page', id: page.id, title: page.title, slug: page.slug },
preview: { page, kindLabel: 'Page' },
})),
)
: null
Expand All @@ -552,6 +554,7 @@ export function SiteExplorerPanel({
active: page.id === activePageId && activeDocument?.kind !== 'visualComponent',
ariaLabel: `Open template ${page.title}`,
target: { kind: 'page', id: page.id, title: page.title, slug: page.slug },
preview: { page, kindLabel: 'Template' },
})),
)
: null
Expand All @@ -568,6 +571,7 @@ export function SiteExplorerPanel({
active: activeDocument?.kind === 'visualComponent' && activeDocument.vcId === component.id,
ariaLabel: `Open component ${component.name}`,
target: { kind: 'component', id: component.id, name: component.name },
preview: { page: flattenVCToVirtualPage(component), kindLabel: 'Component' },
})),
)
: null
Expand Down
Loading
Loading