chore: optimizes live preview (#3713)
This commit is contained in:
@@ -3,21 +3,31 @@ import type { Dispatch } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { LivePreviewConfig } from '../../../../../exports/config'
|
||||
import type { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
|
||||
import type { usePopupWindow } from '../usePopupWindow'
|
||||
import type { SizeReducerAction } from './sizeReducer'
|
||||
|
||||
export interface LivePreviewContextType {
|
||||
appIsReady: boolean
|
||||
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
|
||||
breakpoints: LivePreviewConfig['breakpoints']
|
||||
fieldSchemaJSON?: ReturnType<typeof fieldSchemaToJSON>
|
||||
iframeHasLoaded: boolean
|
||||
iframeRef: React.RefObject<HTMLIFrameElement>
|
||||
isPopupOpen: boolean
|
||||
measuredDeviceSize: {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
openPopupWindow: ReturnType<typeof usePopupWindow>['openPopupWindow']
|
||||
popupRef?: React.MutableRefObject<Window | null>
|
||||
previewWindowType: 'iframe' | 'popup'
|
||||
setAppIsReady: (appIsReady: boolean) => void
|
||||
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
|
||||
setHeight: (height: number) => void
|
||||
setIframeHasLoaded: (loaded: boolean) => void
|
||||
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
|
||||
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
|
||||
setSize: Dispatch<SizeReducerAction>
|
||||
setToolbarPosition: (position: { x: number; y: number }) => void
|
||||
setWidth: (width: number) => void
|
||||
@@ -30,22 +40,31 @@ export interface LivePreviewContextType {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
url: string | undefined
|
||||
zoom: number
|
||||
}
|
||||
|
||||
export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
appIsReady: false,
|
||||
breakpoint: undefined,
|
||||
breakpoints: undefined,
|
||||
fieldSchemaJSON: undefined,
|
||||
iframeHasLoaded: false,
|
||||
iframeRef: undefined,
|
||||
isPopupOpen: false,
|
||||
measuredDeviceSize: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
openPopupWindow: () => {},
|
||||
popupRef: undefined,
|
||||
previewWindowType: 'iframe',
|
||||
setAppIsReady: () => {},
|
||||
setBreakpoint: () => {},
|
||||
setHeight: () => {},
|
||||
setIframeHasLoaded: () => {},
|
||||
setMeasuredDeviceSize: () => {},
|
||||
setPreviewWindowType: () => {},
|
||||
setSize: () => {},
|
||||
setToolbarPosition: () => {},
|
||||
setWidth: () => {},
|
||||
@@ -58,6 +77,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
url: undefined,
|
||||
zoom: 1,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
import { DndContext } from '@dnd-kit/core'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { LivePreviewConfig } from '../../../../../exports/config'
|
||||
import type { Field } from '../../../../../fields/config/types'
|
||||
import type { EditViewProps } from '../../types'
|
||||
import type { usePopupWindow } from '../usePopupWindow'
|
||||
|
||||
import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
|
||||
import { customCollisionDetection } from './collisionDetection'
|
||||
import { LivePreviewContext } from './context'
|
||||
import { sizeReducer } from './sizeReducer'
|
||||
|
||||
export type ToolbarProviderProps = EditViewProps & {
|
||||
export type LivePreviewProviderProps = EditViewProps & {
|
||||
appIsReady?: boolean
|
||||
breakpoints?: LivePreviewConfig['breakpoints']
|
||||
children: React.ReactNode
|
||||
deviceSize?: {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
popupState: ReturnType<typeof usePopupWindow>
|
||||
isPopupOpen?: boolean
|
||||
openPopupWindow?: ReturnType<typeof usePopupWindow>['openPopupWindow']
|
||||
popupRef?: React.MutableRefObject<Window>
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
|
||||
const { breakpoints, children } = props
|
||||
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = (props) => {
|
||||
const { breakpoints, children, isPopupOpen, openPopupWindow, popupRef, url } = props
|
||||
|
||||
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
|
||||
|
||||
const [appIsReady, setAppIsReady] = useState(false)
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false)
|
||||
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
|
||||
|
||||
const [zoom, setZoom] = React.useState(1)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
const [position, setPosition] = React.useState({ x: 0, y: 0 })
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
const [size, setSize] = React.useReducer(sizeReducer, { height: 0, width: 0 })
|
||||
|
||||
const [measuredDeviceSize, setMeasuredDeviceSize] = React.useState({
|
||||
const [measuredDeviceSize, setMeasuredDeviceSize] = useState({
|
||||
height: 0,
|
||||
width: 0,
|
||||
})
|
||||
@@ -41,6 +50,22 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
|
||||
const [breakpoint, setBreakpoint] =
|
||||
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
|
||||
|
||||
const [fieldSchemaJSON] = useState(() => {
|
||||
let fields: Field[]
|
||||
|
||||
if ('collection' in props) {
|
||||
const { collection } = props
|
||||
fields = collection.fields
|
||||
}
|
||||
|
||||
if ('global' in props) {
|
||||
const { global } = props
|
||||
fields = global.fields
|
||||
}
|
||||
|
||||
return fieldSchemaToJSON(fields)
|
||||
})
|
||||
|
||||
// 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
|
||||
@@ -94,24 +119,70 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
|
||||
}
|
||||
}, [breakpoint, breakpoints])
|
||||
|
||||
// Receive the `ready` message from the popup window
|
||||
// This indicates that the app is ready to receive `window.postMessage` events
|
||||
// This is also the only cross-origin way of detecting when a popup window has loaded
|
||||
// Unlike iframe elements which have an `onLoad` handler, there is no way to access `window.open` on popups
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (url.startsWith(event.origin) && data.type === 'payload-live-preview' && data.ready) {
|
||||
setAppIsReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
const handleWindowChange = useCallback(
|
||||
(type: 'iframe' | 'popup') => {
|
||||
setAppIsReady(false)
|
||||
setPreviewWindowType(type)
|
||||
if (type === 'popup') openPopupWindow()
|
||||
},
|
||||
[openPopupWindow],
|
||||
)
|
||||
|
||||
// when the user closes the popup window, switch back to the iframe
|
||||
// the `usePopupWindow` reports the `isPopupOpen` state for us to use here
|
||||
useEffect(() => {
|
||||
if (!isPopupOpen) {
|
||||
handleWindowChange('iframe')
|
||||
}
|
||||
}, [isPopupOpen, handleWindowChange])
|
||||
|
||||
return (
|
||||
<LivePreviewContext.Provider
|
||||
value={{
|
||||
appIsReady,
|
||||
breakpoint,
|
||||
breakpoints,
|
||||
fieldSchemaJSON,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isPopupOpen,
|
||||
measuredDeviceSize,
|
||||
openPopupWindow,
|
||||
popupRef,
|
||||
previewWindowType,
|
||||
setAppIsReady,
|
||||
setBreakpoint,
|
||||
setHeight,
|
||||
setIframeHasLoaded,
|
||||
setMeasuredDeviceSize,
|
||||
setPreviewWindowType: handleWindowChange,
|
||||
setSize,
|
||||
setToolbarPosition: setPosition,
|
||||
setWidth,
|
||||
setZoom,
|
||||
size,
|
||||
toolbarPosition: position,
|
||||
url,
|
||||
zoom,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import type { LivePreviewConfig } from '../../../../../exports/config'
|
||||
import type { Field } from '../../../../../fields/config/types'
|
||||
import type { EditViewProps } from '../../types'
|
||||
import type { usePopupWindow } from '../usePopupWindow'
|
||||
|
||||
import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
|
||||
import { useAllFormFields } from '../../../forms/Form/context'
|
||||
import reduceFieldsToValues from '../../../forms/Form/reduceFieldsToValues'
|
||||
import { LivePreviewProvider } from '../Context'
|
||||
import { useLivePreviewContext } from '../Context/context'
|
||||
import { DeviceContainer } from '../Device'
|
||||
import { IFrame } from '../IFrame'
|
||||
@@ -17,93 +12,81 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-window'
|
||||
|
||||
const Preview: React.FC<
|
||||
EditViewProps & {
|
||||
popupState: ReturnType<typeof usePopupWindow>
|
||||
url?: string
|
||||
}
|
||||
> = (props) => {
|
||||
export const LivePreview: React.FC<EditViewProps> = (props) => {
|
||||
const {
|
||||
popupState: { isPopupOpen, popupHasLoaded, popupRef },
|
||||
appIsReady,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
popupRef,
|
||||
previewWindowType,
|
||||
setIframeHasLoaded,
|
||||
url,
|
||||
} = props
|
||||
} = useLivePreviewContext()
|
||||
|
||||
const { iframeHasLoaded, iframeRef, setIframeHasLoaded } = useLivePreviewContext()
|
||||
const { breakpoint, fieldSchemaJSON } = useLivePreviewContext()
|
||||
|
||||
const { breakpoint } = useLivePreviewContext()
|
||||
const prevWindowType =
|
||||
React.useRef<ReturnType<typeof useLivePreviewContext>['previewWindowType']>()
|
||||
|
||||
const [fields] = useAllFormFields()
|
||||
|
||||
const [fieldSchemaJSON] = useState(() => {
|
||||
let fields: Field[]
|
||||
|
||||
if ('collection' in props) {
|
||||
const { collection } = props
|
||||
fields = collection.fields
|
||||
}
|
||||
|
||||
if ('global' in props) {
|
||||
const { global } = props
|
||||
fields = global.fields
|
||||
}
|
||||
|
||||
return fieldSchemaToJSON(fields)
|
||||
})
|
||||
|
||||
// The preview could either be an iframe embedded on the page
|
||||
// Or it could be a separate popup window
|
||||
// We need to transmit data to both accordingly
|
||||
useEffect(() => {
|
||||
if (fields && window && 'postMessage' in window) {
|
||||
// For performance, do no reduce fields to values until after the iframe or popup has loaded
|
||||
if (fields && window && 'postMessage' in window && appIsReady) {
|
||||
const values = reduceFieldsToValues(fields, true)
|
||||
|
||||
// TODO: only send `fieldSchemaToJSON` one time
|
||||
// To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time
|
||||
// To do this, the underlying JS function maintains a cache of this value
|
||||
// So we need to send it through each time the window type changes
|
||||
// But only once per window type change, not on every render, because this is a potentially large obj
|
||||
const shouldSendSchema =
|
||||
!prevWindowType.current || prevWindowType.current !== previewWindowType
|
||||
|
||||
prevWindowType.current = previewWindowType
|
||||
|
||||
const message = JSON.stringify({
|
||||
data: values,
|
||||
fieldSchemaJSON,
|
||||
fieldSchemaJSON: shouldSendSchema ? fieldSchemaJSON : undefined,
|
||||
type: 'payload-live-preview',
|
||||
})
|
||||
|
||||
// external window
|
||||
if (isPopupOpen) {
|
||||
setIframeHasLoaded(false)
|
||||
if (popupRef.current) {
|
||||
popupRef.current.postMessage(message, url)
|
||||
}
|
||||
// Post message to external popup window
|
||||
if (previewWindowType === 'popup' && popupRef.current) {
|
||||
popupRef.current.postMessage(message, url)
|
||||
}
|
||||
|
||||
// embedded iframe
|
||||
if (!isPopupOpen) {
|
||||
if (iframeHasLoaded && iframeRef.current) {
|
||||
iframeRef.current.contentWindow?.postMessage(message, url)
|
||||
}
|
||||
// Post message to embedded iframe
|
||||
if (previewWindowType === 'iframe' && iframeRef.current) {
|
||||
iframeRef.current.contentWindow?.postMessage(message, url)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
fields,
|
||||
url,
|
||||
iframeHasLoaded,
|
||||
isPopupOpen,
|
||||
previewWindowType,
|
||||
popupRef,
|
||||
popupHasLoaded,
|
||||
appIsReady,
|
||||
iframeRef,
|
||||
setIframeHasLoaded,
|
||||
fieldSchemaJSON,
|
||||
])
|
||||
|
||||
if (!isPopupOpen) {
|
||||
if (previewWindowType === 'iframe') {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
isPopupOpen && `${baseClass}--popup-open`,
|
||||
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<LivePreviewToolbar {...props} iframeRef={iframeRef} url={url} />
|
||||
<LivePreviewToolbar {...props} />
|
||||
<div className={`${baseClass}__main`}>
|
||||
<DeviceContainer>
|
||||
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
|
||||
@@ -114,29 +97,3 @@ const Preview: React.FC<
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const LivePreview: React.FC<
|
||||
EditViewProps & {
|
||||
livePreviewConfig?: LivePreviewConfig
|
||||
popupState: ReturnType<typeof usePopupWindow>
|
||||
url?: string
|
||||
}
|
||||
> = (props) => {
|
||||
const { livePreviewConfig, url } = props
|
||||
|
||||
const breakpoints: LivePreviewConfig['breakpoints'] = [
|
||||
...(livePreviewConfig?.breakpoints || []),
|
||||
{
|
||||
name: 'responsive',
|
||||
height: '100%',
|
||||
label: 'Responsive',
|
||||
width: '100%',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<LivePreviewProvider {...props} breakpoints={breakpoints} url={url}>
|
||||
<Preview {...props} />
|
||||
</LivePreviewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { LivePreviewToolbarProps } from '..'
|
||||
import type { EditViewProps } from '../../../types'
|
||||
|
||||
import { X } from '../../../..'
|
||||
import { ExternalLinkIcon } from '../../../../graphics/ExternalLink'
|
||||
@@ -10,13 +10,9 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-toolbar-controls'
|
||||
|
||||
export const ToolbarControls: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
const { breakpoint, breakpoints, setBreakpoint, setZoom, zoom } = useLivePreviewContext()
|
||||
|
||||
const {
|
||||
popupState: { openPopupWindow },
|
||||
url,
|
||||
} = props
|
||||
export const ToolbarControls: React.FC<EditViewProps> = () => {
|
||||
const { breakpoint, breakpoints, setBreakpoint, setPreviewWindowType, setZoom, url, zoom } =
|
||||
useLivePreviewContext()
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
@@ -57,7 +53,15 @@ export const ToolbarControls: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
<option value={150}>150%</option>
|
||||
<option value={200}>200%</option>
|
||||
</select>
|
||||
<a className={`${baseClass}__external`} href={url} onClick={openPopupWindow} type="button">
|
||||
<a
|
||||
className={`${baseClass}__external`}
|
||||
href={url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setPreviewWindowType('popup')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import React from 'react'
|
||||
|
||||
import type { ToolbarProviderProps } from '../Context'
|
||||
import type { EditViewProps } from '../../types'
|
||||
|
||||
import DragHandle from '../../../icons/Drag'
|
||||
import { useLivePreviewContext } from '../Context/context'
|
||||
@@ -10,11 +10,7 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-toolbar'
|
||||
|
||||
export type LivePreviewToolbarProps = Omit<ToolbarProviderProps, 'children'> & {
|
||||
iframeRef: React.RefObject<HTMLIFrameElement>
|
||||
}
|
||||
|
||||
const DraggableToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
const DraggableToolbar: React.FC<EditViewProps> = (props) => {
|
||||
const { toolbarPosition } = useLivePreviewContext()
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
@@ -50,7 +46,7 @@ const DraggableToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const StaticToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
const StaticToolbar: React.FC<EditViewProps> = (props) => {
|
||||
return (
|
||||
<div className={[baseClass, `${baseClass}--static`].join(' ')}>
|
||||
<ToolbarControls {...props} />
|
||||
@@ -59,7 +55,7 @@ const StaticToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
|
||||
}
|
||||
|
||||
export const LivePreviewToolbar: React.FC<
|
||||
LivePreviewToolbarProps & {
|
||||
EditViewProps & {
|
||||
draggable?: boolean
|
||||
}
|
||||
> = (props) => {
|
||||
|
||||
@@ -17,47 +17,17 @@ import { useDocumentInfo } from '../../utilities/DocumentInfo'
|
||||
import { useLocale } from '../../utilities/Locale'
|
||||
import Meta from '../../utilities/Meta'
|
||||
import { SetStepNav } from '../collections/Edit/SetStepNav'
|
||||
import { LivePreviewProvider } from './Context'
|
||||
import { useLivePreviewContext } from './Context/context'
|
||||
import { LivePreview } from './Preview'
|
||||
import './index.scss'
|
||||
import { usePopupWindow } from './usePopupWindow'
|
||||
|
||||
const baseClass = 'live-preview'
|
||||
|
||||
export const LivePreviewView: React.FC<EditViewProps> = (props) => {
|
||||
const PreviewView: React.FC<EditViewProps> = (props) => {
|
||||
const { i18n, t } = useTranslation('general')
|
||||
const config = useConfig()
|
||||
const documentInfo = useDocumentInfo()
|
||||
const locale = useLocale()
|
||||
|
||||
let livePreviewConfig: LivePreviewConfig = config?.admin?.livePreview
|
||||
|
||||
if ('collection' in props) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(props?.collection.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
if ('global' in props) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(props?.global.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
typeof livePreviewConfig?.url === 'function'
|
||||
? livePreviewConfig?.url({
|
||||
data: props?.data,
|
||||
documentInfo,
|
||||
locale,
|
||||
})
|
||||
: livePreviewConfig?.url
|
||||
|
||||
const popupState = usePopupWindow({
|
||||
eventType: 'payload-live-preview',
|
||||
url,
|
||||
})
|
||||
const { previewWindowType } = useLivePreviewContext()
|
||||
|
||||
const { apiURL, data, permissions } = props
|
||||
|
||||
@@ -113,14 +83,14 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
|
||||
permissions={permissions}
|
||||
/>
|
||||
<div
|
||||
className={[baseClass, popupState?.isPopupOpen && `${baseClass}--detached`]
|
||||
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
`${baseClass}__main`,
|
||||
popupState?.isPopupOpen && `${baseClass}__main--popup-open`,
|
||||
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -148,13 +118,67 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
|
||||
)}
|
||||
</Gutter>
|
||||
</div>
|
||||
<LivePreview
|
||||
{...props}
|
||||
livePreviewConfig={livePreviewConfig}
|
||||
popupState={popupState}
|
||||
url={url}
|
||||
/>
|
||||
<LivePreview {...props} />
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export const LivePreviewView: React.FC<EditViewProps> = (props) => {
|
||||
const config = useConfig()
|
||||
const documentInfo = useDocumentInfo()
|
||||
const locale = useLocale()
|
||||
|
||||
let livePreviewConfig: LivePreviewConfig = config?.admin?.livePreview
|
||||
|
||||
if ('collection' in props) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(props?.collection.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
if ('global' in props) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(props?.global.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
typeof livePreviewConfig?.url === 'function'
|
||||
? livePreviewConfig?.url({
|
||||
data: props?.data,
|
||||
documentInfo,
|
||||
locale,
|
||||
})
|
||||
: livePreviewConfig?.url
|
||||
|
||||
const breakpoints: LivePreviewConfig['breakpoints'] = [
|
||||
...(livePreviewConfig?.breakpoints || []),
|
||||
{
|
||||
name: 'responsive',
|
||||
height: '100%',
|
||||
label: 'Responsive',
|
||||
width: '100%',
|
||||
},
|
||||
]
|
||||
|
||||
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
|
||||
eventType: 'payload-live-preview',
|
||||
url,
|
||||
})
|
||||
|
||||
return (
|
||||
<LivePreviewProvider
|
||||
{...props}
|
||||
breakpoints={breakpoints}
|
||||
isPopupOpen={isPopupOpen}
|
||||
openPopupWindow={openPopupWindow}
|
||||
popupRef={popupRef}
|
||||
url={url}
|
||||
>
|
||||
<PreviewView {...props} />
|
||||
</LivePreviewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,17 +19,14 @@ export const usePopupWindow = (props: {
|
||||
url: string
|
||||
}): {
|
||||
isPopupOpen: boolean
|
||||
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
|
||||
popupHasLoaded: boolean
|
||||
openPopupWindow: () => void
|
||||
popupRef?: React.MutableRefObject<Window | null>
|
||||
} => {
|
||||
const { eventType, onMessage, url } = props
|
||||
const isReceivingMessage = useRef(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
|
||||
const { serverURL } = useConfig()
|
||||
const popupRef = useRef<Window | null>(null)
|
||||
const hasAttachedMessageListener = useRef(false)
|
||||
|
||||
// Optionally broadcast messages back out to the parent component
|
||||
useEffect(() => {
|
||||
@@ -65,8 +62,10 @@ export const usePopupWindow = (props: {
|
||||
|
||||
// Customize the size, position, and style of the popup window
|
||||
const openPopupWindow = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
(e?: MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const features = {
|
||||
height: 700,
|
||||
@@ -106,27 +105,6 @@ export const usePopupWindow = (props: {
|
||||
[url],
|
||||
)
|
||||
|
||||
// the only cross-origin way of detecting when a popup window has loaded
|
||||
// we catch a message event that the site rendered within the popup window fires
|
||||
// there is no way in js to add an event listener to a popup window across domains
|
||||
useEffect(() => {
|
||||
if (hasAttachedMessageListener.current) return
|
||||
hasAttachedMessageListener.current = true
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (
|
||||
url.startsWith(event.origin) &&
|
||||
data.type === eventType &&
|
||||
data.popupReady &&
|
||||
!popupHasLoaded
|
||||
) {
|
||||
setPopupHasLoaded(true)
|
||||
}
|
||||
})
|
||||
}, [url, eventType, popupHasLoaded])
|
||||
|
||||
// this is the most stable and widely supported way to check if a popup window is no longer open
|
||||
// we poll its ref every x ms and use the popup window's `closed` property
|
||||
useEffect(() => {
|
||||
@@ -137,7 +115,6 @@ export const usePopupWindow = (props: {
|
||||
if (popupRef.current.closed) {
|
||||
clearInterval(timer)
|
||||
setIsOpen(false)
|
||||
setPopupHasLoaded(false)
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
@@ -154,7 +131,6 @@ export const usePopupWindow = (props: {
|
||||
return {
|
||||
isPopupOpen: isOpen,
|
||||
openPopupWindow,
|
||||
popupHasLoaded,
|
||||
popupRef,
|
||||
}
|
||||
}
|
||||
|
||||
10
test/live-preview/collections/Users.ts
Normal file
10
test/live-preview/collections/Users.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [],
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import path from 'path'
|
||||
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
|
||||
import { devUser } from '../credentials'
|
||||
import Categories from './collections/Categories'
|
||||
import { Media } from './collections/Media'
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Posts, postsSlug } from './collections/Posts'
|
||||
import { Posts } from './collections/Posts'
|
||||
import { Users } from './collections/Users'
|
||||
import { Footer } from './globals/Footer'
|
||||
import { Header } from './globals/Header'
|
||||
import { footer } from './seed/footer'
|
||||
import { header } from './seed/header'
|
||||
import { home } from './seed/home'
|
||||
import { post1 } from './seed/post-1'
|
||||
import { post2 } from './seed/post-2'
|
||||
import { post3 } from './seed/post-3'
|
||||
import { postsPage } from './seed/posts-page'
|
||||
import { seed } from './seed'
|
||||
|
||||
export const pagesSlug = 'pages'
|
||||
|
||||
@@ -40,92 +32,7 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
cors: ['http://localhost:3001'],
|
||||
csrf: ['http://localhost:3001'],
|
||||
collections: [
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
Pages,
|
||||
Posts,
|
||||
Categories,
|
||||
Media,
|
||||
],
|
||||
collections: [Users, Pages, Posts, Categories, Media],
|
||||
globals: [Header, Footer],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
filePath: path.resolve(__dirname, 'image-1.jpg'),
|
||||
data: {
|
||||
alt: 'Image 1',
|
||||
},
|
||||
})
|
||||
|
||||
const mediaID = payload.db.defaultIDType === 'number' ? media.id : `"${media.id}"`
|
||||
|
||||
const [post1Doc, post2Doc, post3Doc] = await Promise.all([
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post1).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post2).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post3).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
])
|
||||
|
||||
const postsPageDoc = await payload.create({
|
||||
collection: pagesSlug,
|
||||
data: JSON.parse(JSON.stringify(postsPage).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
})
|
||||
|
||||
let postsPageDocID = postsPageDoc.id
|
||||
let post1DocID = post1Doc.id
|
||||
let post2DocID = post2Doc.id
|
||||
let post3DocID = post3Doc.id
|
||||
|
||||
if (payload.db.defaultIDType !== 'number') {
|
||||
postsPageDocID = `"${postsPageDoc.id}"`
|
||||
post1DocID = `"${post1Doc.id}"`
|
||||
post2DocID = `"${post2Doc.id}"`
|
||||
post3DocID = `"${post3Doc.id}"`
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: pagesSlug,
|
||||
data: JSON.parse(
|
||||
JSON.stringify(home)
|
||||
.replace(/"\{\{MEDIA_ID\}\}"/g, mediaID)
|
||||
.replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)
|
||||
.replace(/"\{\{POST_1_ID\}\}"/g, post1DocID)
|
||||
.replace(/"\{\{POST_2_ID\}\}"/g, post2DocID)
|
||||
.replace(/"\{\{POST_3_ID\}\}"/g, post3DocID),
|
||||
),
|
||||
})
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'header',
|
||||
data: JSON.parse(JSON.stringify(header).replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)),
|
||||
})
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'footer',
|
||||
data: JSON.parse(JSON.stringify(footer)),
|
||||
})
|
||||
},
|
||||
onInit: seed,
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
88
test/live-preview/seed/index.ts
Normal file
88
test/live-preview/seed/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from 'path'
|
||||
|
||||
import type { Config } from '../../../packages/payload/src/config/types'
|
||||
|
||||
import { devUser } from '../../credentials'
|
||||
import { postsSlug } from '../collections/Posts'
|
||||
import { pagesSlug } from '../config'
|
||||
import { footer } from './footer'
|
||||
import { header } from './header'
|
||||
import { home } from './home'
|
||||
import { post1 } from './post-1'
|
||||
import { post2 } from './post-2'
|
||||
import { post3 } from './post-3'
|
||||
import { postsPage } from './posts-page'
|
||||
|
||||
export const seed: Config['onInit'] = async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
filePath: path.resolve(__dirname, 'image-1.jpg'),
|
||||
data: {
|
||||
alt: 'Image 1',
|
||||
},
|
||||
})
|
||||
|
||||
const mediaID = payload.db.defaultIDType === 'number' ? media.id : `"${media.id}"`
|
||||
|
||||
const [post1Doc, post2Doc, post3Doc] = await Promise.all([
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post1).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post2).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: JSON.parse(JSON.stringify(post3).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
}),
|
||||
])
|
||||
|
||||
const postsPageDoc = await payload.create({
|
||||
collection: pagesSlug,
|
||||
data: JSON.parse(JSON.stringify(postsPage).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
|
||||
})
|
||||
|
||||
let postsPageDocID = postsPageDoc.id
|
||||
let post1DocID = post1Doc.id
|
||||
let post2DocID = post2Doc.id
|
||||
let post3DocID = post3Doc.id
|
||||
|
||||
if (payload.db.defaultIDType !== 'number') {
|
||||
postsPageDocID = `"${postsPageDoc.id}"`
|
||||
post1DocID = `"${post1Doc.id}"`
|
||||
post2DocID = `"${post2Doc.id}"`
|
||||
post3DocID = `"${post3Doc.id}"`
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: pagesSlug,
|
||||
data: JSON.parse(
|
||||
JSON.stringify(home)
|
||||
.replace(/"\{\{MEDIA_ID\}\}"/g, mediaID)
|
||||
.replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)
|
||||
.replace(/"\{\{POST_1_ID\}\}"/g, post1DocID)
|
||||
.replace(/"\{\{POST_2_ID\}\}"/g, post2DocID)
|
||||
.replace(/"\{\{POST_3_ID\}\}"/g, post3DocID),
|
||||
),
|
||||
})
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'header',
|
||||
data: JSON.parse(JSON.stringify(header).replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)),
|
||||
})
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'footer',
|
||||
data: JSON.parse(JSON.stringify(footer)),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user