diff --git a/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/index.scss b/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/index.scss index d6408fa19..80c0329cf 100644 --- a/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/index.scss +++ b/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/index.scss @@ -12,6 +12,7 @@ &__tab { display: flex; + white-space: nowrap; } @include mid-break { diff --git a/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/tabs.ts b/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/tabs.ts index 633f2e4e7..5dfa37de1 100644 --- a/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/tabs.ts +++ b/packages/payload/src/admin/components/elements/DocumentHeader/Tabs/tabs.ts @@ -8,6 +8,14 @@ export const tabs: DocumentTabConfig[] = [ location.pathname === href || location.pathname === `${href}/create`, label: ({ t }) => t('edit'), }, + // Live Preview + { + condition: ({ collection, global }) => + Boolean(collection?.admin?.livePreview || global?.admin?.livePreview), + href: ({ match }) => `${match.url}/preview`, + isActive: ({ href, location }) => location.pathname === href, + label: ({ t }) => t('livePreview'), + }, // Versions { condition: ({ collection, global }) => Boolean(collection?.versions || global?.versions), diff --git a/packages/payload/src/admin/components/graphics/ExternalLink/index.tsx b/packages/payload/src/admin/components/graphics/ExternalLink/index.tsx new file mode 100644 index 000000000..81e28e203 --- /dev/null +++ b/packages/payload/src/admin/components/graphics/ExternalLink/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +export const ExternalLinkIcon: React.FC<{ + className?: string +}> = (props) => { + const { className } = props + return ( + + + + ) +} diff --git a/packages/payload/src/admin/components/views/Global/Routes/index.tsx b/packages/payload/src/admin/components/views/Global/Routes/index.tsx index 185ab0cef..e979a91af 100644 --- a/packages/payload/src/admin/components/views/Global/Routes/index.tsx +++ b/packages/payload/src/admin/components/views/Global/Routes/index.tsx @@ -48,6 +48,13 @@ export const GlobalRoutes: React.FC = (props) => { )} + + + {globalCustomRoutes({ global, match, diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewContext/collisionDetection.ts b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/collisionDetection.ts new file mode 100644 index 000000000..05da49f8d --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/collisionDetection.ts @@ -0,0 +1,36 @@ +import { Collision, CollisionDetection, rectIntersection } from '@dnd-kit/core' + +// If the toolbar exits the preview area, we need to reset its position +// This will prevent the toolbar from getting stuck outside the preview area +export const customCollisionDetection: CollisionDetection = ({ + droppableContainers, + collisionRect, + ...args +}) => { + const droppableContainer = droppableContainers.find(({ id }) => id === 'live-preview-area') + + const rectIntersectionCollisions = rectIntersection({ + ...args, + collisionRect, + droppableContainers: [droppableContainer], + }) + + // Collision detection algorithms return an array of collisions + if (rectIntersectionCollisions.length === 0) { + // The preview area is not intersecting, return early + return rectIntersectionCollisions + } + + // Compute whether the draggable element is completely contained within the preview area + const previewAreaRect = droppableContainer?.rect?.current + + const isContained = + collisionRect.top >= previewAreaRect.top && + collisionRect.left >= previewAreaRect.left && + collisionRect.bottom <= previewAreaRect.bottom && + collisionRect.right <= previewAreaRect.right + + if (isContained) { + return rectIntersectionCollisions + } +} diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewContext/context.ts b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/context.ts new file mode 100644 index 000000000..e9058e8ef --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/context.ts @@ -0,0 +1,53 @@ +import { Dispatch, createContext, useContext } from 'react' +import { LivePreview } from '../../../../../exports/config' +import { SizeReducerAction } from './sizeReducer' + +export interface LivePreviewContextType { + zoom: number + setZoom: (zoom: number) => void + size: { + width: number + height: number + } + setWidth: (width: number) => void + setHeight: (height: number) => void + setSize: Dispatch + breakpoint: LivePreview['breakpoints'][number]['name'] + iframeRef: React.RefObject + deviceFrameRef: React.RefObject + iframeHasLoaded: boolean + setIframeHasLoaded: (loaded: boolean) => void + toolbarPosition: { + x: number + y: number + } + setToolbarPosition: (position: { x: number; y: number }) => void + breakpoints: LivePreview['breakpoints'] + setBreakpoint: (breakpoint: LivePreview['breakpoints'][number]['name']) => void +} + +export const LivePreviewContext = createContext({ + zoom: 1, + setZoom: () => {}, + size: { + width: 0, + height: 0, + }, + setWidth: () => {}, + setHeight: () => {}, + setSize: () => {}, + breakpoint: undefined, + iframeRef: undefined, + deviceFrameRef: undefined, + iframeHasLoaded: false, + setIframeHasLoaded: () => {}, + toolbarPosition: { + x: 0, + y: 0, + }, + setToolbarPosition: () => {}, + breakpoints: undefined, + setBreakpoint: () => {}, +}) + +export const useLivePreviewContext = () => useContext(LivePreviewContext) diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewContext/index.tsx b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/index.tsx new file mode 100644 index 000000000..50c2791d4 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/index.tsx @@ -0,0 +1,139 @@ +import { DndContext } from '@dnd-kit/core' +import React, { useCallback, useEffect } from 'react' + +import type { usePopupWindow } from '../usePopupWindow' + +import { EditViewProps } from '../../types' +import { LivePreviewContext } from './context' +import { customCollisionDetection } from './collisionDetection' +import { LivePreview } from '../../../../../exports/config' +import { useResize } from '../../../../utilities/useResize' +import { sizeReducer } from './sizeReducer' + +export type ToolbarProviderProps = EditViewProps & { + breakpoints?: LivePreview['breakpoints'] + deviceSize?: { + height: number + width: number + } + popupState: ReturnType + url?: string + children: React.ReactNode +} + +export const LivePreviewProvider: React.FC = (props) => { + const { children, breakpoints } = props + + const iframeRef = React.useRef(null) + + const deviceFrameRef = React.useRef(null) + + const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false) + + const [zoom, setZoom] = React.useState(1) + + const [position, setPosition] = React.useState({ x: 0, y: 0 }) + + const [size, setSize] = React.useReducer(sizeReducer, { width: 0, height: 0 }) + + const [breakpoint, setBreakpoint] = + React.useState('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` + } + } + + let url + + if ('collection' in props) { + url = props?.collection.admin.livePreview.url + } + + if ('global' in props) { + url = props?.global.admin.livePreview.url + } + + // 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 + // otherwise reset it back to the previous position + // TODO: reset to the nearest edge of the preview area + if (ev.over && ev.over.id === 'live-preview-area') { + const newPos = { + x: position.x + ev.delta.x, + y: position.y + ev.delta.y, + } + + setPosition(newPos) + } else { + // reset + } + } + + const setWidth = useCallback( + (width) => { + setSize({ type: 'width', value: width }) + }, + [setSize], + ) + + const setHeight = useCallback( + (height) => { + setSize({ type: 'height', value: height }) + }, + [setSize], + ) + + const { size: actualDeviceSize } = useResize(deviceFrameRef) + + useEffect(() => { + if (actualDeviceSize) { + setSize({ + type: 'reset', + value: { + width: Number(actualDeviceSize.width.toFixed(0)), + height: Number(actualDeviceSize.height.toFixed(0)), + }, + }) + } + }, [actualDeviceSize]) + + return ( + + + {children} + + + ) +} diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewContext/sizeReducer.ts b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/sizeReducer.ts new file mode 100644 index 000000000..2ef1daa28 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewContext/sizeReducer.ts @@ -0,0 +1,39 @@ +// export const sizeReducer: (state, action) => { +// switch (action.type) { +// case 'width': +// return { ...state, width: action.value } +// case 'height': +// return { ...state, height: action.value } +// default: +// return { ...state, ...(action?.value || {}) } +// } +// }, + +type SizeReducerState = { + width: number + height: number +} + +export type SizeReducerAction = + | { + type: 'width' | 'height' + value: number + } + | { + type: 'reset' + value: { + width: number + height: number + } + } + +export const sizeReducer = (state: SizeReducerState, action: SizeReducerAction) => { + switch (action.type) { + case 'width': + return { ...state, width: action.value } + case 'height': + return { ...state, height: action.value } + default: + return { ...state, ...(action?.value || {}) } + } +} diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.scss b/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.scss new file mode 100644 index 000000000..22ef7301a --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.scss @@ -0,0 +1,8 @@ +@import '../../../../scss/styles.scss'; + +.live-preview-iframe { + border: 0; + width: 100%; + height: 100%; + transform-origin: top left; +} diff --git a/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.tsx b/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.tsx new file mode 100644 index 000000000..bad45fee4 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/PreviewIFrame/index.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from 'react' +import { useLivePreviewContext } from '../PreviewContext/context' + +import './index.scss' + +const baseClass = 'live-preview-iframe' + +export const IFrame: React.FC<{ + url: string + ref: React.Ref + setIframeHasLoaded: (value: boolean) => void +}> = forwardRef((props, ref) => { + const { url, setIframeHasLoaded } = props + + const { zoom } = useLivePreviewContext() + + return ( +