fix(next): live preview device position when using zoom (#6665)

This commit is contained in:
Jacob Fletcher
2024-06-07 10:17:49 -04:00
committed by GitHub
parent 11c3a65e63
commit 7c8d562f03
7 changed files with 247 additions and 82 deletions

View File

@@ -10,16 +10,20 @@ export const DeviceContainer: React.FC<{
const { children } = props
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const outerFrameRef = React.useRef<HTMLDivElement>(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 (
<div
ref={deviceFrameRef}
ref={outerFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
height: '100%',
width: '100%',
}}
>
{children}
<div
ref={deviceFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
}}
>
{children}
</div>
</div>
)
}

View File

@@ -2,7 +2,11 @@
/* eslint-disable no-shadow */
import { useEffect, useRef, useState } from 'react'
type Intersect = [setNode: React.Dispatch<Element>, entry: IntersectionObserverEntry]
type Intersect = [
setNode: React.Dispatch<HTMLElement>,
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]
}

View File

@@ -12,15 +12,13 @@ interface Resize {
size?: Size
}
export const useResize = (ref: React.MutableRefObject<HTMLElement>): Resize => {
export const useResize = (element: HTMLElement): Resize => {
const [size, setSize] = useState<Size>()
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<HTMLElement>): Resize => {
})
})
observer.observe(currentRef)
observer.observe(element)
}
return () => {
if (observer) {
observer.unobserve(currentRef)
observer.unobserve(element)
}
}
}, [ref])
}, [element])
return {
size,

View File

@@ -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'],
},

View File

@@ -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<void> => {
await goToDoc(page, urlUtil)
await page.goto(`${page.url()}/preview`)
await page.waitForURL(`**/preview`)
}
const goToGlobalPreview = async (page: Page, slug: string): Promise<void> => {
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()
})
})

View File

@@ -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<void> => {
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<void> => {
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,
})
}

View File

@@ -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'