chore: dynamic device sizing (#3426)

This commit is contained in:
Jacob Fletcher
2023-10-03 13:47:41 -04:00
committed by GitHub
parent f5d7ff2eee
commit fdbb61fc43
15 changed files with 286 additions and 188 deletions

View File

@@ -11,6 +11,10 @@ export interface LivePreviewContextType {
deviceFrameRef: React.RefObject<HTMLDivElement>
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement>
measuredDeviceSize: {
height: number
width: number
}
setBreakpoint: (breakpoint: LivePreview['breakpoints'][number]['name']) => void
setHeight: (height: number) => void
setIframeHasLoaded: (loaded: boolean) => void
@@ -35,6 +39,10 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
deviceFrameRef: undefined,
iframeHasLoaded: false,
iframeRef: undefined,
measuredDeviceSize: {
height: 0,
width: 0,
},
setBreakpoint: () => {},
setHeight: () => {},
setIframeHasLoaded: () => {},

View File

@@ -39,23 +39,6 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const [breakpoint, setBreakpoint] =
React.useState<LivePreview['breakpoints'][0]['name']>('responsive')
const foundBreakpoint = breakpoint && breakpoints.find((bp) => bp.name === breakpoint)
let margin = '0'
if (foundBreakpoint && breakpoint !== 'responsive') {
margin = '0 auto'
if (
typeof zoom === 'number' &&
typeof foundBreakpoint.width === 'number' &&
typeof foundBreakpoint.height === 'number'
) {
// keep it centered horizontally
margin = `0 ${foundBreakpoint.width / 2 / zoom}px`
}
}
// The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area
@@ -87,19 +70,31 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
[setSize],
)
const { size: actualDeviceSize } = useResize(deviceFrameRef)
// explicitly set new width and height when as new breakpoints are selected
// exclude `custom` breakpoint as it is handled by the `setWidth` and `setHeight` directly
useEffect(() => {
if (actualDeviceSize) {
const foundBreakpoint = breakpoints?.find((bp) => bp.name === breakpoint)
if (
foundBreakpoint &&
breakpoint !== 'responsive' &&
breakpoint !== 'custom' &&
typeof foundBreakpoint?.width === 'number' &&
typeof foundBreakpoint?.height === 'number'
) {
setSize({
type: 'reset',
value: {
height: Number(actualDeviceSize.height.toFixed(0)),
width: Number(actualDeviceSize.width.toFixed(0)),
height: foundBreakpoint.height,
width: foundBreakpoint.width,
},
})
}
}, [actualDeviceSize])
}, [breakpoint, breakpoints])
// 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)
return (
<LivePreviewContext.Provider
@@ -109,6 +104,7 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
deviceFrameRef,
iframeHasLoaded,
iframeRef,
measuredDeviceSize,
setBreakpoint,
setHeight,
setIframeHasLoaded,

View File

@@ -0,0 +1,51 @@
import React from 'react'
import { useLivePreviewContext } from '../Context/context'
export const DeviceContainer: React.FC<{
children: React.ReactNode
}> = (props) => {
const { children } = props
const { breakpoint, deviceFrameRef, size, zoom } = useLivePreviewContext()
let x = '0'
let margin = '0'
if (breakpoint && breakpoint !== 'responsive') {
x = '-50%'
if (
typeof zoom === 'number' &&
typeof size.width === 'number' &&
typeof size.height === 'number'
) {
const scaledWidth = size.width / zoom
const difference = scaledWidth - size.width
x = `${difference / 2}px`
margin = 'auto'
}
}
let width = zoom ? `${100 / zoom}%` : '100%'
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`
}
return (
<div
ref={deviceFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
}}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import React from 'react'
import { useLivePreviewContext } from '../Context/context'
export const DeviceContainer: React.FC<{
children: React.ReactNode
}> = (props) => {
const { children } = props
const { breakpoint, breakpoints, deviceFrameRef, size, zoom } = useLivePreviewContext()
const foundBreakpoint = breakpoint && breakpoints.find((bp) => bp.name === breakpoint)
let x = '0'
let margin = '0'
if (foundBreakpoint && breakpoint !== 'responsive') {
x = '-50%'
if (
typeof zoom === 'number' &&
typeof size.width === 'number' &&
typeof size.height === 'number'
) {
const scaledWidth = size.width / zoom
const difference = scaledWidth - size.width
x = `${difference / 2}px`
margin = 'auto'
}
}
return (
<div
ref={deviceFrameRef}
style={{
height:
foundBreakpoint && foundBreakpoint?.name !== 'responsive'
? `${size?.height / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
margin,
transform: `translate3d(${x}, 0, 0)`,
width:
foundBreakpoint && foundBreakpoint?.name !== 'responsive'
? `${size?.width / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
}}
>
{children}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import React, { forwardRef } from 'react'
import { useLivePreviewContext } from '../PreviewContext/context'
import { useLivePreviewContext } from '../Context/context'
import './index.scss'
const baseClass = 'live-preview-iframe'

View File

@@ -8,7 +8,7 @@
overflow: hidden;
&--has-breakpoint {
padding-top: var(--base);
padding: var(--base);
.live-preview-iframe {
border: 1px solid var(--theme-elevation-100);

View File

@@ -1,69 +1,21 @@
import React, { useEffect } from 'react'
import type { LivePreview } from '../../../../../exports/config'
import type { LivePreview as LivePreviewType } from '../../../../../exports/config'
import type { EditViewProps } from '../../types'
import type { usePopupWindow } from '../usePopupWindow'
import { useAllFormFields } from '../../../forms/Form/context'
import reduceFieldsToValues from '../../../forms/Form/reduceFieldsToValues'
import { LivePreviewProvider } from '../PreviewContext'
import { useLivePreviewContext } from '../PreviewContext/context'
import { IFrame } from '../PreviewIFrame'
import { LivePreviewProvider } from '../Context'
import { useLivePreviewContext } from '../Context/context'
import { DeviceContainer } from '../Device'
import { IFrame } from '../IFrame'
import { LivePreviewToolbar } from '../Toolbar'
import { ToolbarArea } from '../ToolbarArea'
import './index.scss'
const baseClass = 'live-preview-window'
const ResponsiveWindow: React.FC<{
children: React.ReactNode
}> = (props) => {
const { children } = props
const { breakpoint, breakpoints, deviceFrameRef, zoom } = useLivePreviewContext()
const foundBreakpoint = breakpoint && breakpoints.find((bp) => bp.name === breakpoint)
let x = '0'
if (foundBreakpoint && breakpoint !== 'responsive') {
x = '-50%'
if (
typeof zoom === 'number' &&
typeof foundBreakpoint.width === 'number' &&
typeof foundBreakpoint.height === 'number'
) {
// keep it centered horizontally
x = `${foundBreakpoint.width / 2}px`
}
}
return (
<div
className={`${baseClass}__responsive-window`}
ref={deviceFrameRef}
style={{
height:
foundBreakpoint && typeof foundBreakpoint?.height === 'number'
? `${foundBreakpoint?.height / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
transform: `translate3d(${x}, 0, 0)`,
width:
foundBreakpoint && typeof foundBreakpoint?.width === 'number'
? `${foundBreakpoint?.width / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
}}
>
{children}
</div>
)
}
const Preview: React.FC<
EditViewProps & {
popupState: ReturnType<typeof usePopupWindow>
@@ -77,7 +29,7 @@ const Preview: React.FC<
const { iframeHasLoaded, iframeRef, setIframeHasLoaded } = useLivePreviewContext()
const { breakpoint, toolbarPosition } = useLivePreviewContext()
const { breakpoint } = useLivePreviewContext()
const [fields] = useAllFormFields()
@@ -129,26 +81,18 @@ const Preview: React.FC<
>
<ToolbarArea>
<div className={`${baseClass}__wrapper`}>
<ResponsiveWindow>
<DeviceContainer>
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
</ResponsiveWindow>
</DeviceContainer>
</div>
<LivePreviewToolbar
{...props}
iframeRef={iframeRef}
style={{
left: `${toolbarPosition.x}px`,
top: `${toolbarPosition.y}px`,
}}
url={url}
/>
<LivePreviewToolbar {...props} iframeRef={iframeRef} url={url} />
</ToolbarArea>
</div>
)
}
}
export const PreviewWindow: React.FC<
export const LivePreview: React.FC<
EditViewProps & {
popupState: ReturnType<typeof usePopupWindow>
url?: string
@@ -156,7 +100,7 @@ export const PreviewWindow: React.FC<
> = (props) => {
let url
let breakpoints: LivePreview['breakpoints'] = [
let breakpoints: LivePreviewType['breakpoints'] = [
{
name: 'responsive',
height: '100%',

View File

@@ -1,14 +1,12 @@
@import '../../../../../scss/styles.scss';
.live-preview-toolbar {
&__size {
width: 50px;
height: var(--base);
display: flex;
align-items: center;
border: 1px solid var(--theme-elevation-200);
background: var(--theme-elevation-100);
border-radius: 2px;
font-size: small;
}
.toolbar-input {
width: 50px;
height: var(--base);
display: flex;
align-items: center;
border: 1px solid var(--theme-elevation-200);
background: var(--theme-elevation-100);
border-radius: 2px;
font-size: small;
}

View File

@@ -1,53 +1,67 @@
import React, { useCallback } from 'react'
import React, { useCallback, useEffect } from 'react'
import { useLivePreviewContext } from '../../PreviewContext/context'
import { useLivePreviewContext } from '../../Context/context'
import './index.scss'
const baseClass = 'live-preview-toolbar'
const baseClass = 'toolbar-input'
export const PreviewFrameSizeInput: React.FC<{
axis?: 'x' | 'y'
}> = (props) => {
const { axis } = props
const { setHeight, setWidth, size } = useLivePreviewContext()
const { breakpoint, measuredDeviceSize, setBreakpoint, setSize, size, zoom } =
useLivePreviewContext()
// const [size, setSize] = React.useState<string>(() => {
// if (sizeToUse === 'width') {
// return deviceSize?.width.toFixed(0)
// }
// return deviceSize?.height.toFixed(0)
// })
// useEffect(() => {
// if (sizeToUse === 'width') {
// setSize(deviceSize?.width.toFixed(0))
// } else {
// setSize(deviceSize?.height.toFixed(0))
// }
// }, [deviceSize])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (axis === 'x') {
setWidth(Number(e.target.value))
} else {
setHeight(Number(e.target.value))
}
},
[axis, setWidth, setHeight],
const [internalState, setInternalState] = React.useState<number>(
(axis === 'x' ? measuredDeviceSize?.width : measuredDeviceSize?.height) || 0,
)
const sizeValue = axis === 'x' ? size?.width : size?.height
// when the input is changed manually, we need to set the breakpoint as `custom`
// this will then allow us to set an explicit width and height
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = Number(e.target.value)
if (newValue < 0) newValue = 0
setInternalState(newValue)
setBreakpoint('custom')
// be sure to set _both_ axis values to so that the other axis doesn't fallback to 0 on initial change
// this is because the `responsive` size is '100%' in CSS, and `0` in initial state
setSize({
type: 'reset',
value: {
height: axis === 'y' ? newValue : Number(measuredDeviceSize?.height.toFixed(0)) * zoom,
width: axis === 'x' ? newValue : Number(measuredDeviceSize?.width.toFixed(0)) * zoom,
},
})
},
[axis, setBreakpoint, measuredDeviceSize, setSize, zoom],
)
// if the breakpoint is `responsive` then the device's div will have `100%` width and height
// so we need to take the measurements provided by `actualDeviceSize` and sync internal state
useEffect(() => {
if (breakpoint === 'responsive' && measuredDeviceSize) {
if (axis === 'x') setInternalState(Number(measuredDeviceSize.width.toFixed(0)) * zoom)
else setInternalState(Number(measuredDeviceSize.height.toFixed(0)) * zoom)
}
if (breakpoint !== 'responsive' && size) {
setInternalState(axis === 'x' ? size.width : size.height)
}
}, [breakpoint, axis, measuredDeviceSize, size, zoom])
return (
<input
className={`${baseClass}__size`}
disabled // enable this once its wired up properly
className={baseClass}
min={0}
onChange={handleChange}
step={1}
type="number"
value={sizeValue}
value={internalState || 0}
/>
)
}

View File

@@ -1,12 +1,12 @@
import { useDraggable } from '@dnd-kit/core'
import React from 'react'
import type { ToolbarProviderProps } from '../PreviewContext'
import type { ToolbarProviderProps } from '../Context'
import { X } from '../../..'
import { ExternalLinkIcon } from '../../../graphics/ExternalLink'
import DragHandle from '../../../icons/Drag'
import { useLivePreviewContext } from '../PreviewContext/context'
import { useLivePreviewContext } from '../Context/context'
import { PreviewFrameSizeInput } from './SizeInput'
import './index.scss'
@@ -15,16 +15,15 @@ const baseClass = 'live-preview-toolbar'
export const LivePreviewToolbar: React.FC<
Omit<ToolbarProviderProps, 'children'> & {
iframeRef: React.RefObject<HTMLIFrameElement>
style?: React.CSSProperties
}
> = (props) => {
const {
popupState: { openPopupWindow },
style,
url,
} = props
const { breakpoint, breakpoints, setBreakpoint, setZoom, zoom } = useLivePreviewContext()
const { breakpoint, breakpoints, setBreakpoint, setZoom, toolbarPosition, zoom } =
useLivePreviewContext()
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: 'live-preview-toolbar',
@@ -34,10 +33,15 @@ export const LivePreviewToolbar: React.FC<
<div
className={baseClass}
style={{
...style,
transform: transform
? `translate3d(${transform?.x || 0}px, ${transform?.y || 0}px, 0)`
: undefined,
left: `${toolbarPosition.x}px`,
top: `${toolbarPosition.y}px`,
...(transform
? {
transform: transform
? `translate3d(${transform?.x || 0}px, ${transform?.y || 0}px, 0)`
: undefined,
}
: {}),
}}
>
<button
@@ -61,6 +65,11 @@ export const LivePreviewToolbar: React.FC<
{bp.label}
</option>
))}
{breakpoint === 'custom' && (
// Dynamically add this option so that it only appears when the width and height inputs are explicitly changed
// TODO: Translate this string
<option value="custom">Custom</option>
)}
</select>
)}
<div className={`${baseClass}__device-size`}>

View File

@@ -12,7 +12,7 @@ import { filterFields } from '../../forms/RenderFields/filterFields'
import { fieldTypes } from '../../forms/field-types'
import LeaveWithoutSaving from '../../modals/LeaveWithoutSaving'
import Meta from '../../utilities/Meta'
import { PreviewWindow } from './PreviewWindow'
import { LivePreview } from './Preview'
import './index.scss'
import { usePopupWindow } from './usePopupWindow'
@@ -111,7 +111,7 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
)}
</Gutter>
</div>
<PreviewWindow {...props} popupState={popupState} url={url} />
<LivePreview {...props} popupState={popupState} url={url} />
</div>
</Fragment>
)

View File

@@ -1,52 +1,75 @@
import payload, { Payload } from '../../packages/payload/src'
import { spawn } from 'child_process'
import path from 'path'
export const startLivePreviewDemo = async (args: { payload: Payload }): Promise<void> => {
import type { Payload } from '../../packages/payload/src'
const installNodeModules = async (args: { payload: Payload }): Promise<void> => {
const { payload } = args
let installing = false
let started = false
// Install the node modules for the Next.js app
const installation = spawn('yarn', ['install'], {
cwd: path.resolve(__dirname, './next-app'),
})
return new Promise(function (resolve) {
// Install the node modules for the Next.js app
const installation = spawn('yarn', ['install'], {
cwd: path.resolve(__dirname, './next-app'),
})
installation.stdout.on('data', (data) => {
if (!installing) {
payload.logger.info('Installing Next.js...')
installing = true
}
installation.stdout.on('data', (data) => {
if (!installing) {
payload.logger.info('Installing Next.js...')
installing = true
}
payload.logger.info(data.toString())
})
payload.logger.info(data.toString())
})
installation.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
installation.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
installation.on('exit', (code) => {
payload.logger.info(`Next.js exited with code ${code}`)
})
// Boot up the Next.js app
const app = spawn('yarn', ['dev'], {
cwd: path.resolve(__dirname, './next-app'),
})
app.stdout.on('data', (data) => {
if (!started) {
payload.logger.info('Starting Next.js...')
started = true
}
payload.logger.info(data.toString())
})
app.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
app.on('exit', (code) => {
payload.logger.info(`Next.js exited with code ${code}`)
installation.on('exit', () => {
payload.logger.info('Done')
resolve()
})
})
}
const bootNextApp = async (args: { payload: Payload }): Promise<void> => {
const { payload } = args
let started = false
return new Promise(function (resolve, reject) {
// Boot up the Next.js app
const app = spawn('yarn', ['dev'], {
cwd: path.resolve(__dirname, './next-app'),
})
app.stdout.on('data', (data) => {
if (!started) {
payload.logger.info('Starting Next.js...')
started = true
}
payload.logger.info(data.toString())
if (data.toString().includes('Ready in')) {
resolve()
}
})
app.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
app.on('exit', (code) => {
payload.logger.info(`Next.js exited with code ${code}`)
reject()
})
})
}
export const startLivePreviewDemo = async (args: { payload: Payload }): Promise<void> => {
await installNodeModules(args)
await bootNextApp(args)
}