diff --git a/docs/live-preview/frontend.mdx b/docs/live-preview/frontend.mdx index 0cc93ba22d..71951d0a57 100644 --- a/docs/live-preview/frontend.mdx +++ b/docs/live-preview/frontend.mdx @@ -1,6 +1,6 @@ --- title: Implementing Live Preview in your app -label: Frontend Implementation +label: Frontend order: 20 desc: Learn how to implement Live Preview in your front-end application. keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview @@ -275,7 +275,7 @@ const { data } = useLivePreview({ }) ``` -### Iframe refuses to connect +#### Iframe refuses to connect If your front-end application has set a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (CSP) that blocks the Admin Panel from loading your front-end application, the iframe will not be able to load your site. To resolve this, you can whitelist the Admin Panel's domain in your CSP by setting the `frame-ancestors` directive: diff --git a/packages/payload/src/admin/components/views/LivePreview/Device/index.tsx b/packages/payload/src/admin/components/views/LivePreview/Device/index.tsx index 84298e1cfa..6653aa8c35 100644 --- a/packages/payload/src/admin/components/views/LivePreview/Device/index.tsx +++ b/packages/payload/src/admin/components/views/LivePreview/Device/index.tsx @@ -9,16 +9,20 @@ export const DeviceContainer: React.FC<{ const { children } = props const deviceFrameRef = React.useRef(null) + const outerFrameRef = React.useRef(null) - const { breakpoint, setMeasuredDeviceSize, size, zoom } = useLivePreviewContext() + const { breakpoint, setMeasuredDeviceSize, size: desiredSize, zoom } = useLivePreviewContext() // Keep an accurate measurement of the actual device size as it is truly rendered // This is helpful when `sizes` are non-number units like percentages, etc. - const { size: measuredDeviceSize } = useResize(deviceFrameRef) + const { size: measuredDeviceSize } = useResize(deviceFrameRef.current) + const { size: outerFrameSize } = useResize(outerFrameRef.current) + + let deviceIsLargerThanFrame: boolean = false // Sync the measured device size with the context so that other components can use it // This happens from the bottom up so that as this component mounts and unmounts, - // Its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe + // its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe useEffect(() => { if (measuredDeviceSize) { setMeasuredDeviceSize(measuredDeviceSize) @@ -33,13 +37,34 @@ export const DeviceContainer: React.FC<{ if ( typeof zoom === 'number' && - typeof size.width === 'number' && - typeof size.height === 'number' + typeof desiredSize.width === 'number' && + typeof desiredSize.height === 'number' && + typeof measuredDeviceSize.width === 'number' && + typeof measuredDeviceSize.height === 'number' ) { - const scaledWidth = size.width / zoom - const difference = scaledWidth - size.width - x = `${difference / 2}px` margin = '0 auto' + const scaledDesiredWidth = desiredSize.width / zoom + const scaledDeviceWidth = measuredDeviceSize.width * zoom + const scaledDeviceDifferencePixels = scaledDesiredWidth - desiredSize.width + deviceIsLargerThanFrame = scaledDeviceWidth > outerFrameSize.width + + if (deviceIsLargerThanFrame) { + if (zoom > 1) { + const differenceFromDeviceToFrame = measuredDeviceSize.width - outerFrameSize.width + if (differenceFromDeviceToFrame < 0) x = `${differenceFromDeviceToFrame / 2}px` + else x = '0' + } else { + x = '0' + } + } else { + if (zoom >= 1) { + x = `${scaledDeviceDifferencePixels / 2}px` + } else { + const differenceFromDeviceToFrame = outerFrameSize.width - scaledDeviceWidth + x = `${differenceFromDeviceToFrame / 2}px` + margin = '0' + } + } } } @@ -47,21 +72,29 @@ export const DeviceContainer: React.FC<{ let height = zoom ? `${100 / zoom}%` : '100%' if (breakpoint !== 'responsive') { - width = `${size?.width / (typeof zoom === 'number' ? zoom : 1)}px` - height = `${size?.height / (typeof zoom === 'number' ? zoom : 1)}px` + width = `${desiredSize?.width / (typeof zoom === 'number' ? zoom : 1)}px` + height = `${desiredSize?.height / (typeof zoom === 'number' ? zoom : 1)}px` } return (
- {children} +
+ {children} +
) } diff --git a/packages/payload/src/admin/utilities/useResize.ts b/packages/payload/src/admin/utilities/useResize.ts index 132a346c83..49e5e11fc7 100644 --- a/packages/payload/src/admin/utilities/useResize.ts +++ b/packages/payload/src/admin/utilities/useResize.ts @@ -11,15 +11,13 @@ interface Resize { size?: Size } -export const useResize = (ref: React.MutableRefObject): Resize => { +export const useResize = (element: HTMLElement): Resize => { const [size, setSize] = useState() useEffect(() => { let observer: any // eslint-disable-line - const { current: currentRef } = ref - - if (currentRef) { + if (element) { observer = new ResizeObserver((entries) => { entries.forEach((entry) => { const { @@ -52,15 +50,15 @@ export const useResize = (ref: React.MutableRefObject): Resize => { }) }) - observer.observe(currentRef) + observer.observe(element) } return () => { if (observer) { - observer.unobserve(currentRef) + observer.unobserve(element) } } - }, [ref]) + }, [element]) return { size, diff --git a/test/helpers.ts b/test/helpers.ts index eaf2cb5827..50df07f4c8 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -101,6 +101,13 @@ export const selectTableRow = async (page: Page, title: string): Promise = expect(await page.locator(selector).isChecked()).toBe(true) } +export async function navigateToListCellLink(page: Page, selector = '.cell-id') { + const cellLink = page.locator(`${selector} a`).first() + const linkURL = await cellLink.getAttribute('href') + await cellLink.click() + await page.waitForURL(`**${linkURL}`) +} + export const findTableCell = async ( page: Page, fieldName: string, diff --git a/test/live-preview/config.ts b/test/live-preview/config.ts index 5d941c970d..6f0a2e3bf7 100644 --- a/test/live-preview/config.ts +++ b/test/live-preview/config.ts @@ -10,7 +10,7 @@ import { Users } from './collections/Users' import { Footer } from './globals/Footer' import { Header } from './globals/Header' import { seed } from './seed' -import { mobileBreakpoint } from './shared' +import { desktopBreakpoint, mobileBreakpoint } from './shared' import { formatLivePreviewURL } from './utilities/formatLivePreviewURL' const mockModulePath = path.resolve(__dirname, './mocks/mockFSModule.js') @@ -21,7 +21,7 @@ export default buildConfigWithDefaults({ // You can define any of these properties on a per collection or global basis // The Live Preview config cascades from the top down, properties are inherited from here url: formatLivePreviewURL, - breakpoints: [mobileBreakpoint], + breakpoints: [mobileBreakpoint, desktopBreakpoint], collections: ['pages', 'posts'], globals: ['header', 'footer'], }, diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts index 065041e00f..fcd22a3363 100644 --- a/test/live-preview/e2e.spec.ts +++ b/test/live-preview/e2e.spec.ts @@ -5,7 +5,14 @@ import { expect, test } from '@playwright/test' import { exactText, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' -import { mobileBreakpoint } from './shared' +import { + ensureDeviceIsCentered, + ensureDeviceIsLeftAligned, + goToCollectionLivePreview, + selectLivePreviewBreakpoint, + selectLivePreviewZoom, +} from './helpers' +import { desktopBreakpoint, mobileBreakpoint } from './shared' import { startLivePreviewDemo } from './startLivePreviewDemo' const { beforeAll, describe } = test @@ -216,4 +223,34 @@ describe('Live Preview', () => { const height = parseInt(heightInputValue) expect(height).toBe(mobileBreakpoint.height) }) + + test('device — centers device when smaller than frame despite zoom', async () => { + await goToCollectionLivePreview(page, url) + await selectLivePreviewBreakpoint(page, mobileBreakpoint.label) + await ensureDeviceIsCentered(page) + await selectLivePreviewZoom(page, '75%') + await ensureDeviceIsCentered(page) + await selectLivePreviewZoom(page, '50%') + await ensureDeviceIsCentered(page) + await selectLivePreviewZoom(page, '125%') + await ensureDeviceIsCentered(page) + await selectLivePreviewZoom(page, '200%') + await ensureDeviceIsCentered(page) + expect(true).toBeTruthy() + }) + + test('device — left-aligns device when larger than frame despite zoom', async () => { + await goToCollectionLivePreview(page, url) + await selectLivePreviewBreakpoint(page, desktopBreakpoint.label) + await ensureDeviceIsLeftAligned(page) + await selectLivePreviewZoom(page, '75%') + await ensureDeviceIsLeftAligned(page) + await selectLivePreviewZoom(page, '50%') + await ensureDeviceIsLeftAligned(page) + await selectLivePreviewZoom(page, '125%') + await ensureDeviceIsLeftAligned(page) + await selectLivePreviewZoom(page, '200%') + await ensureDeviceIsLeftAligned(page) + expect(true).toBeTruthy() + }) }) diff --git a/test/live-preview/helpers.ts b/test/live-preview/helpers.ts new file mode 100644 index 0000000000..a069eb7f0b --- /dev/null +++ b/test/live-preview/helpers.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +import { exactText, navigateToListCellLink } from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' + +export const EXPECT_TIMEOUT = 8000 +export const POLL_TOPASS_TIMEOUT = EXPECT_TIMEOUT * 4 + +export const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => { + await page.goto(urlUtil.list) + await page.waitForURL(urlUtil.list) + await navigateToListCellLink(page) +} + +export const goToCollectionLivePreview = async ( + page: Page, + urlUtil: AdminUrlUtil, +): Promise => { + await goToDoc(page, urlUtil) + await page.goto(`${page.url()}/preview`) + await page.waitForURL(`**/preview`) +} + +export const goToGlobalLivePreview = async ( + page: Page, + slug: string, + serverURL: string, +): Promise => { + const global = new AdminUrlUtil(serverURL, slug) + const previewURL = `${global.global(slug)}/preview` + await page.goto(previewURL) + await page.waitForURL(previewURL) +} + +export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => { + const breakpointSelector = page.locator( + '.live-preview-toolbar-controls__breakpoint button.popup-button', + ) + + await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + + await breakpointSelector.first().click() + + await page + .locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`) + .filter({ hasText: breakpointLabel }) + .click() + + await expect(breakpointSelector).toContainText(breakpointLabel) + + const option = page.locator( + '.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected', + ) + + await expect(option).toHaveText(breakpointLabel) +} + +export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => { + const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button') + + await expect(() => expect(zoomSelector).toBeTruthy()).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + + await zoomSelector.first().click() + + const zoomOption = page.locator( + '.live-preview-toolbar-controls__zoom button.popup-button-list__button', + { + hasText: exactText(zoomLabel), + }, + ) + + expect(zoomOption).toBeTruthy() + await zoomOption.click() + + await expect(zoomSelector).toContainText(zoomLabel) + + const option = page.locator( + '.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected', + ) + + await expect(option).toHaveText(zoomLabel) +} + +export const ensureDeviceIsCentered = async (page: Page) => { + const main = page.locator('.live-preview-window__main') + const iframe = page.locator('iframe.live-preview-iframe') + const mainBoxAfterZoom = await main.boundingBox() + const iframeBoxAfterZoom = await iframe.boundingBox() + const distanceFromIframeLeftToMainLeftAfterZoom = Math.abs( + mainBoxAfterZoom?.x - iframeBoxAfterZoom?.x, + ) + const distanceFromIFrameRightToMainRightAfterZoom = Math.abs( + mainBoxAfterZoom?.x + + mainBoxAfterZoom?.width - + iframeBoxAfterZoom?.x - + iframeBoxAfterZoom?.width, + ) + await expect(() => + expect(distanceFromIframeLeftToMainLeftAfterZoom).toBe( + distanceFromIFrameRightToMainRightAfterZoom, + ), + ).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) +} + +export const ensureDeviceIsLeftAligned = async (page: Page) => { + const main = page.locator('.live-preview-window__main > div') + const iframe = page.locator('iframe.live-preview-iframe') + const mainBoxAfterZoom = await main.boundingBox() + const iframeBoxAfterZoom = await iframe.boundingBox() + const distanceFromIframeLeftToMainLeftAfterZoom = Math.abs( + mainBoxAfterZoom?.x - iframeBoxAfterZoom?.x, + ) + await expect(() => expect(distanceFromIframeLeftToMainLeftAfterZoom).toBe(0)).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) +} diff --git a/test/live-preview/shared.ts b/test/live-preview/shared.ts index 1ced8ab600..7b2b6281f9 100644 --- a/test/live-preview/shared.ts +++ b/test/live-preview/shared.ts @@ -8,3 +8,10 @@ export const mobileBreakpoint = { width: 375, height: 667, } + +export const desktopBreakpoint = { + label: 'Desktop', + name: 'desktop', + width: 1920, + height: 1024, +}