diff --git a/packages/next/src/views/LivePreview/Device/index.tsx b/packages/next/src/views/LivePreview/Device/index.tsx index 4be8357b0..f2d209fbc 100644 --- a/packages/next/src/views/LivePreview/Device/index.tsx +++ b/packages/next/src/views/LivePreview/Device/index.tsx @@ -10,16 +10,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) @@ -34,13 +38,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' + } + } } } @@ -48,21 +73,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/ui/src/hooks/useIntersect.ts b/packages/ui/src/hooks/useIntersect.ts index ff6ff6b8e..7f75dbddf 100644 --- a/packages/ui/src/hooks/useIntersect.ts +++ b/packages/ui/src/hooks/useIntersect.ts @@ -2,7 +2,11 @@ /* eslint-disable no-shadow */ import { useEffect, useRef, useState } from 'react' -type Intersect = [setNode: React.Dispatch, entry: IntersectionObserverEntry] +type Intersect = [ + setNode: React.Dispatch, + entry: IntersectionObserverEntry, + node: HTMLElement, +] export const useIntersect = ( { root = null, rootMargin = '0px', threshold = 0 } = {}, @@ -33,5 +37,5 @@ export const useIntersect = ( return () => currentObserver.disconnect() }, [node, disable]) - return [setNode, entry] + return [setNode, entry, node] } diff --git a/packages/ui/src/hooks/useResize.ts b/packages/ui/src/hooks/useResize.ts index 7a85da996..228a8e586 100644 --- a/packages/ui/src/hooks/useResize.ts +++ b/packages/ui/src/hooks/useResize.ts @@ -12,15 +12,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 { @@ -53,15 +51,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/live-preview/config.ts b/test/live-preview/config.ts index f7e2510bd..c0873bebc 100644 --- a/test/live-preview/config.ts +++ b/test/live-preview/config.ts @@ -11,6 +11,7 @@ import { Footer } from './globals/Footer.js' import { Header } from './globals/Header.js' import { seed } from './seed/index.js' import { + desktopBreakpoint, mobileBreakpoint, pagesSlug, postsSlug, @@ -25,7 +26,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: [pagesSlug, postsSlug, ssrPagesSlug, ssrAutosavePagesSlug], globals: ['header', 'footer'], }, diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts index f28fc2e0b..f6c738c28 100644 --- a/test/live-preview/e2e.spec.ts +++ b/test/live-preview/e2e.spec.ts @@ -8,19 +8,29 @@ import { ensureAutoLoginAndCompilationIsDone, exactText, initPageConsoleErrorCatch, - navigateToListCellLink, saveDocAndAssert, } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { + ensureDeviceIsCentered, + ensureDeviceIsLeftAligned, + goToCollectionLivePreview, + goToDoc, + goToGlobalLivePreview, + selectLivePreviewBreakpoint, + selectLivePreviewZoom, +} from './helpers.js' +import { + desktopBreakpoint, mobileBreakpoint, pagesSlug, renderedPageTitleID, ssrAutosavePagesSlug, ssrPagesSlug, } from './shared.js' + const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -34,25 +44,6 @@ describe('Live Preview', () => { let ssrPagesURLUtil: AdminUrlUtil let ssrAutosavePostsURLUtil: AdminUrlUtil - const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => { - await page.goto(urlUtil.list) - await page.waitForURL(urlUtil.list) - await navigateToListCellLink(page) - } - - const goToCollectionPreview = async (page: Page, urlUtil: AdminUrlUtil): Promise => { - await goToDoc(page, urlUtil) - await page.goto(`${page.url()}/preview`) - await page.waitForURL(`**/preview`) - } - - const goToGlobalPreview = async (page: Page, slug: string): Promise => { - const global = new AdminUrlUtil(serverURL, slug) - const previewURL = `${global.global(slug)}/preview` - await page.goto(previewURL) - await page.waitForURL(previewURL) - } - beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) @@ -88,18 +79,18 @@ describe('Live Preview', () => { }) test('collection — has route', async () => { - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) await expect(page.locator('.live-preview')).toBeVisible() }) test('collection — renders iframe', async () => { - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) const iframe = page.locator('iframe.live-preview-iframe') await expect(iframe).toBeVisible() }) test('collection — re-renders iframe client-side when form state changes', async () => { - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) const titleField = page.locator('#field-title') const frame = page.frameLocator('iframe.live-preview-iframe').first() @@ -129,7 +120,7 @@ describe('Live Preview', () => { }) test('collection ssr — re-render iframe when save is made', async () => { - await goToCollectionPreview(page, ssrPagesURLUtil) + await goToCollectionLivePreview(page, ssrPagesURLUtil) const titleField = page.locator('#field-title') const frame = page.frameLocator('iframe.live-preview-iframe').first() @@ -159,7 +150,7 @@ describe('Live Preview', () => { }) test('collection ssr — re-render iframe when autosave is made', async () => { - await goToCollectionPreview(page, ssrAutosavePostsURLUtil) + await goToCollectionLivePreview(page, ssrAutosavePostsURLUtil) const titleField = page.locator('#field-title') const frame = page.frameLocator('iframe.live-preview-iframe').first() @@ -189,12 +180,12 @@ describe('Live Preview', () => { }) test('collection — should show live-preview view-level action in live-preview view', async () => { - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) await expect(page.locator('.app-header .collection-live-preview-button')).toHaveCount(1) }) test('global — should show live-preview view-level action in live-preview view', async () => { - await goToGlobalPreview(page, 'footer') + await goToGlobalLivePreview(page, 'footer', serverURL) await expect(page.locator('.app-header .global-live-preview-button')).toHaveCount(1) }) @@ -220,7 +211,7 @@ describe('Live Preview', () => { test('global — has route', async () => { const url = page.url() - await goToGlobalPreview(page, 'header') + await goToGlobalLivePreview(page, 'header', serverURL) await expect(() => expect(page.url()).toBe(`${url}/preview`)).toPass({ timeout: POLL_TOPASS_TIMEOUT, @@ -228,13 +219,13 @@ describe('Live Preview', () => { }) test('global — renders iframe', async () => { - await goToGlobalPreview(page, 'header') + await goToGlobalLivePreview(page, 'header', serverURL) const iframe = page.locator('iframe.live-preview-iframe') await expect(iframe).toBeVisible() }) test('global — can edit fields', async () => { - await goToGlobalPreview(page, 'header') + await goToGlobalLivePreview(page, 'header', serverURL) const field = page.locator('input#field-navItems__0__link__newTab') //field-navItems__0__link__newTab await expect(field).toBeVisible() await expect(field).toBeEnabled() @@ -242,14 +233,14 @@ describe('Live Preview', () => { await saveDocAndAssert(page) }) - test('properly measures iframe and displays size', async () => { + test('device — properly measures size', async () => { await page.goto(pagesURLUtil.create) await page.waitForURL(pagesURLUtil.create) await page.locator('#field-title').fill('Title 3') await page.locator('#field-slug').fill('slug-3') await saveDocAndAssert(page) - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) const iframe = page.locator('iframe') @@ -291,37 +282,16 @@ describe('Live Preview', () => { }) }) - test('resizes iframe to specified breakpoint', async () => { + test('device — resizes to specified breakpoint', async () => { await page.goto(pagesURLUtil.create) await page.waitForURL(pagesURLUtil.create) await page.locator('#field-title').fill('Title 4') await page.locator('#field-slug').fill('slug-4') await saveDocAndAssert(page) - await goToCollectionPreview(page, pagesURLUtil) + await goToCollectionLivePreview(page, pagesURLUtil) - // Check that the breakpoint select is present - const breakpointSelector = page.locator( - '.live-preview-toolbar-controls__breakpoint button.popup-button', - ) - - await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) - - // Select the mobile breakpoint - await breakpointSelector.first().click() - await page - .locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`) - .filter({ hasText: mobileBreakpoint.label }) - .click() - - // Make sure the value has been set - await expect(breakpointSelector).toContainText(mobileBreakpoint.label) - const option = page.locator( - '.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected', - ) - await expect(option).toHaveText(mobileBreakpoint.label) + await selectLivePreviewBreakpoint(page, mobileBreakpoint.label) // Measure the size of the iframe against the specified breakpoint const iframe = page.locator('iframe') @@ -382,4 +352,34 @@ describe('Live Preview', () => { timeout: POLL_TOPASS_TIMEOUT, }) }) + + test('device — centers device when smaller than frame despite zoom', async () => { + await goToCollectionLivePreview(page, pagesURLUtil) + 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, pagesURLUtil) + 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 000000000..2b1ad7ae3 --- /dev/null +++ b/test/live-preview/helpers.ts @@ -0,0 +1,122 @@ +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +import { exactText, navigateToListCellLink } from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js' + +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 16278989c..5e84b3afe 100644 --- a/test/live-preview/shared.ts +++ b/test/live-preview/shared.ts @@ -15,4 +15,11 @@ export const mobileBreakpoint = { height: 667, } +export const desktopBreakpoint = { + label: 'Desktop', + name: 'desktop', + width: 1920, + height: 1080, +} + export const renderedPageTitleID = 'rendered-page-title'