mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 11:25:17 +00:00
future: sync preview to side editor forms (#24179)
* feat: live preview via iframe events * fix: only show focus style if not in popover * chore: extract Blocker component * chore: single WILL_EDIT_FIELD listener * chore: rename constants * fix: hovering focused highlight * fix: remove useless highlights * fix: adrien feedback
This commit is contained in:
parent
9fe221c689
commit
09e349ad45
@ -2,9 +2,7 @@ import * as React from 'react';
|
||||
|
||||
import {
|
||||
Page,
|
||||
Blocker,
|
||||
Form,
|
||||
useForm,
|
||||
useRBAC,
|
||||
useNotification,
|
||||
useQueryParams,
|
||||
@ -25,6 +23,7 @@ import { useOnce } from '../../hooks/useOnce';
|
||||
import { getTranslation } from '../../utils/translations';
|
||||
import { createYupSchema } from '../../utils/validation';
|
||||
|
||||
import { Blocker } from './components/Blocker';
|
||||
import { FormLayout } from './components/FormLayout';
|
||||
import { Header } from './components/Header';
|
||||
import { Panels } from './components/Panels';
|
||||
@ -34,15 +33,6 @@ import { handleInvisibleAttributes } from './utils/data';
|
||||
* EditViewPage
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
// Needs to be wrapped in a component to have access to the form context via a hook.
|
||||
// Using the Form component's render prop instead would cause unnecessary re-renders of Form children
|
||||
const BlockerWrapper = () => {
|
||||
const resetForm = useForm('BlockerWrapper', (state) => state.resetForm);
|
||||
|
||||
// We reset the form to the published version to avoid errors like – https://strapi-inc.atlassian.net/browse/CONTENT-2284
|
||||
return <Blocker onProceed={resetForm} />;
|
||||
};
|
||||
|
||||
const EditViewPage = () => {
|
||||
const location = useLocation();
|
||||
const [
|
||||
@ -220,7 +210,7 @@ const EditViewPage = () => {
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Tabs.Root>
|
||||
<BlockerWrapper />
|
||||
<Blocker />
|
||||
</>
|
||||
</Form>
|
||||
</Main>
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
// Needs to be wrapped in a component to have access to the form context via a hook.
|
||||
|
||||
import { Blocker as BaseBlocker, useForm } from '@strapi/admin/strapi-admin';
|
||||
|
||||
/**
|
||||
* Prevents users from leaving the page with unsaved form changes
|
||||
*/
|
||||
const Blocker = () => {
|
||||
const resetForm = useForm('Blocker', (state) => state.resetForm);
|
||||
|
||||
// We reset the form to the published version to avoid errors like – https://strapi-inc.atlassian.net/browse/CONTENT-2284
|
||||
return <BaseBlocker onProceed={resetForm} />;
|
||||
};
|
||||
|
||||
export { Blocker };
|
||||
@ -14,6 +14,7 @@ import { type UseDocument } from '../../../hooks/useDocument';
|
||||
import { useDocumentContext } from '../../../hooks/useDocumentContext';
|
||||
import { useDocumentLayout } from '../../../hooks/useDocumentLayout';
|
||||
import { useLazyComponents } from '../../../hooks/useLazyComponents';
|
||||
import { usePreviewInputManager } from '../../../preview/hooks/usePreviewInputManager';
|
||||
|
||||
import { BlocksInput } from './FormInputs/BlocksInput/BlocksInput';
|
||||
import { ComponentInput } from './FormInputs/Component/Input';
|
||||
@ -39,7 +40,12 @@ type InputRendererProps = DistributiveOmit<EditFieldLayout, 'size'> & {
|
||||
* the complete EditFieldLayout and will handle RBAC conditions and rendering CM specific
|
||||
* components such as Blocks / Relations.
|
||||
*/
|
||||
const InputRenderer = ({ visible, hint: providedHint, document, ...props }: InputRendererProps) => {
|
||||
const InputRenderer = ({
|
||||
visible,
|
||||
hint: providedHint,
|
||||
document,
|
||||
...inputProps
|
||||
}: InputRendererProps) => {
|
||||
const { currentDocumentMeta } = useDocumentContext('DynamicComponent');
|
||||
const {
|
||||
edit: { components },
|
||||
@ -64,6 +70,10 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
const editableFields = idToCheck ? canUpdateFields : canCreateFields;
|
||||
const readableFields = idToCheck ? canReadFields : canCreateFields;
|
||||
|
||||
// Everything preview related
|
||||
const previewProps = usePreviewInputManager(inputProps.name);
|
||||
const props = { ...inputProps, ...previewProps };
|
||||
|
||||
/**
|
||||
* Component fields are always readable and editable,
|
||||
* however the fields within them may not be.
|
||||
@ -103,13 +113,21 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
const CustomInput = lazyComponentStore[props.attribute.customField];
|
||||
|
||||
if (CustomInput) {
|
||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||
return <CustomInput {...props} {...field} hint={hint} disabled={fieldIsDisabled} />;
|
||||
return (
|
||||
<CustomInput
|
||||
{...props}
|
||||
{...field}
|
||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||
hint={hint}
|
||||
disabled={fieldIsDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...props}
|
||||
{...previewProps}
|
||||
hint={hint}
|
||||
// @ts-expect-error – this workaround lets us display that the custom field is missing.
|
||||
type={props.attribute.customField}
|
||||
@ -124,8 +142,14 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
const addedInputTypes = Object.keys(fields);
|
||||
if (!attributeHasCustomFieldProperty(props.attribute) && addedInputTypes.includes(props.type)) {
|
||||
const CustomInput = fields[props.type];
|
||||
// @ts-expect-error – TODO: fix this type error in the useLibrary hook.
|
||||
return <CustomInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
return (
|
||||
<CustomInput
|
||||
{...props}
|
||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||
hint={hint}
|
||||
disabled={fieldIsDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +167,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
layout={components[props.attribute.component].layout}
|
||||
disabled={fieldIsDisabled}
|
||||
>
|
||||
{(inputProps) => <InputRenderer {...inputProps} />}
|
||||
{(componentInputProps) => <InputRenderer {...componentInputProps} />}
|
||||
</ComponentInput>
|
||||
);
|
||||
case 'dynamiczone':
|
||||
@ -161,6 +185,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...props}
|
||||
{...previewProps}
|
||||
hint={hint}
|
||||
options={props.attribute.enum.map((value) => ({ value }))}
|
||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
||||
@ -174,6 +199,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...restProps}
|
||||
{...previewProps}
|
||||
hint={hint}
|
||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
||||
type={props.customField ? 'custom-field' : props.type}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { createContext } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Popover } from '@strapi/design-system';
|
||||
|
||||
import { type UseDocument } from '../../hooks/useDocument';
|
||||
import { InputRenderer } from '../../pages/EditView/components/InputRenderer';
|
||||
import { usePreviewContext } from '../pages/Preview';
|
||||
|
||||
/**
|
||||
* No need for actual data in the context. It's just to let children check if they're rendered
|
||||
* inside of a preview InputPopover without relying on prop drilling.
|
||||
*/
|
||||
interface InputPopoverContextValue {}
|
||||
|
||||
const [InputPopoverProvider, useInputPopoverContext] =
|
||||
createContext<InputPopoverContextValue>('InputPopover');
|
||||
|
||||
const InputPopover = ({ documentResponse }: { documentResponse: ReturnType<UseDocument> }) => {
|
||||
const iframeRef = usePreviewContext('VisualEditingPopover', (state) => state.iframeRef);
|
||||
const popoverField = usePreviewContext('VisualEditingPopover', (state) => state.popoverField);
|
||||
const setPopoverField = usePreviewContext(
|
||||
'VisualEditingPopover',
|
||||
(state) => state.setPopoverField
|
||||
);
|
||||
|
||||
if (!popoverField || !documentResponse.schema || !iframeRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iframeRect = iframeRef.current.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/**
|
||||
* Overlay an empty div on top of the iframe while the popover is open so it can
|
||||
* intercept clicks. Without it, we wouldn't be able to close the popover by clicking outside,
|
||||
* because the click would be detected by the iframe window, not by the admin.
|
||||
**/}
|
||||
<Box
|
||||
position={'fixed'}
|
||||
top={iframeRect.top + 'px'}
|
||||
left={iframeRect.left + 'px'}
|
||||
width={iframeRect.width + 'px'}
|
||||
height={iframeRect.height + 'px'}
|
||||
zIndex={4}
|
||||
/>
|
||||
<InputPopoverProvider>
|
||||
<Popover.Root open={true} onOpenChange={(open) => !open && setPopoverField(null)}>
|
||||
<Popover.Trigger>
|
||||
<Box
|
||||
position="fixed"
|
||||
width={popoverField.position.width + 'px'}
|
||||
height={popoverField.position.height + 'px'}
|
||||
top={0}
|
||||
left={0}
|
||||
transform={`translate(${iframeRect.left + popoverField.position.left}px, ${iframeRect.top + popoverField.position.top}px)`}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content sideOffset={4}>
|
||||
<Box padding={4} width="400px">
|
||||
<InputRenderer
|
||||
document={documentResponse}
|
||||
attribute={documentResponse.schema.attributes[popoverField.path] as any}
|
||||
label={popoverField.path}
|
||||
name={popoverField.path}
|
||||
type={documentResponse.schema.attributes[popoverField.path].type}
|
||||
visible={true}
|
||||
/>
|
||||
</Box>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</InputPopoverProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useHasInputPopoverParent() {
|
||||
const context = useInputPopoverContext('useHasInputPopoverParent', () => true, false);
|
||||
|
||||
// useContext will return undefined if the called is not wrapped in the provider
|
||||
return context !== undefined;
|
||||
}
|
||||
|
||||
export { InputPopover, useHasInputPopoverParent };
|
||||
@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useField } from '@strapi/admin/strapi-admin';
|
||||
|
||||
import { useHasInputPopoverParent } from '../components/InputPopover';
|
||||
import { usePreviewContext } from '../pages/Preview';
|
||||
import { INTERNAL_EVENTS } from '../utils/constants';
|
||||
import { getSendMessage } from '../utils/getSendMessage';
|
||||
|
||||
type PreviewInputProps = Pick<
|
||||
Required<React.InputHTMLAttributes<HTMLInputElement>>,
|
||||
'onFocus' | 'onBlur'
|
||||
>;
|
||||
|
||||
export function usePreviewInputManager(name: string): PreviewInputProps {
|
||||
const iframe = usePreviewContext('usePreviewInputManager', (state) => state.iframeRef, false);
|
||||
const setPopoverField = usePreviewContext(
|
||||
'usePreviewInputManager',
|
||||
(state) => state.setPopoverField,
|
||||
false
|
||||
);
|
||||
const hasInputPopoverParent = useHasInputPopoverParent();
|
||||
const { value } = useField(name);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sendMessage = getSendMessage(iframe);
|
||||
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_CHANGE, { field: name, value });
|
||||
}, [name, value, iframe]);
|
||||
|
||||
const sendMessage = getSendMessage(iframe);
|
||||
|
||||
return {
|
||||
onFocus: () => {
|
||||
if (hasInputPopoverParent) return;
|
||||
|
||||
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_FOCUS, { field: name });
|
||||
},
|
||||
onBlur: () => {
|
||||
if (hasInputPopoverParent) return;
|
||||
|
||||
setPopoverField?.(null);
|
||||
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_BLUR, { field: name });
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -6,7 +6,6 @@ import {
|
||||
useRBAC,
|
||||
createContext,
|
||||
Form as FormContext,
|
||||
Blocker,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Box,
|
||||
@ -27,13 +26,16 @@ import { COLLECTION_TYPES } from '../../constants/collections';
|
||||
import { DocumentRBAC } from '../../features/DocumentRBAC';
|
||||
import { type UseDocument, useDocument } from '../../hooks/useDocument';
|
||||
import { type EditLayout, useDocumentLayout } from '../../hooks/useDocumentLayout';
|
||||
import { Blocker } from '../../pages/EditView/components/Blocker';
|
||||
import { FormLayout } from '../../pages/EditView/components/FormLayout';
|
||||
import { handleInvisibleAttributes } from '../../pages/EditView/utils/data';
|
||||
import { buildValidParams } from '../../utils/api';
|
||||
import { createYupSchema } from '../../utils/validation';
|
||||
import { InputPopover } from '../components/InputPopover';
|
||||
import { PreviewHeader } from '../components/PreviewHeader';
|
||||
import { useGetPreviewUrlQuery } from '../services/preview';
|
||||
import { INTERNAL_EVENTS, PUBLIC_EVENTS } from '../utils/constants';
|
||||
import { PUBLIC_EVENTS, INTERNAL_EVENTS } from '../utils/constants';
|
||||
import { getSendMessage } from '../utils/getSendMessage';
|
||||
import { previewScript } from '../utils/previewScript';
|
||||
|
||||
import type { UID } from '@strapi/types';
|
||||
@ -67,6 +69,11 @@ const DEVICES = [
|
||||
* PreviewProvider
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
interface PopoverField {
|
||||
path: string;
|
||||
position: DOMRect;
|
||||
}
|
||||
|
||||
interface PreviewContextValue {
|
||||
url: string;
|
||||
title: string;
|
||||
@ -75,6 +82,9 @@ interface PreviewContextValue {
|
||||
schema: NonNullable<ReturnType<UseDocument>['schema']>;
|
||||
layout: EditLayout;
|
||||
onPreview: () => void;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement>;
|
||||
popoverField: PopoverField | null;
|
||||
setPopoverField: (value: PopoverField | null) => void;
|
||||
}
|
||||
|
||||
const [PreviewProvider, usePreviewContext] = createContext<PreviewContextValue>('PreviewPage');
|
||||
@ -89,38 +99,13 @@ const AnimatedArrow = styled(ArrowLineLeft)<{ $isSideEditorOpen: boolean }>`
|
||||
transition: rotate 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
type MessageType =
|
||||
| (typeof INTERNAL_EVENTS)[keyof typeof INTERNAL_EVENTS]
|
||||
| (typeof PUBLIC_EVENTS)[keyof typeof PUBLIC_EVENTS];
|
||||
|
||||
/**
|
||||
* A function factory so we can generate a new sendMessage everytime we need one.
|
||||
* We can't store and reuse a single sendMessage because it needs to have a stable identity
|
||||
* as it used in a useEffect function. And we can't rely on useCallback because we need the
|
||||
* up-to-date iframe ref, and this would make it stale (refs don't trigger callback reevaluations).
|
||||
*/
|
||||
function getSendMessage(iframe: React.RefObject<HTMLIFrameElement>) {
|
||||
return (type: MessageType, payload?: unknown) => {
|
||||
if (!iframe.current) return;
|
||||
|
||||
const { origin } = new URL(iframe.current.src);
|
||||
|
||||
iframe.current.contentWindow?.postMessage(
|
||||
{
|
||||
type,
|
||||
...(payload !== undefined && { payload }),
|
||||
},
|
||||
origin
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const PreviewPage = () => {
|
||||
const location = useLocation();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const [isSideEditorOpen, setIsSideEditorOpen] = React.useState(true);
|
||||
const [popoverField, setPopoverField] = React.useState<PopoverField | null>(null);
|
||||
|
||||
// Read all the necessary data from the URL to find the right preview URL
|
||||
const {
|
||||
@ -147,11 +132,23 @@ const PreviewPage = () => {
|
||||
// Listen for ready message from iframe before injecting script
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Only listen to events from the preview iframe
|
||||
if (iframeRef.current) {
|
||||
const previewOrigin = new URL(iframeRef.current?.src).origin;
|
||||
if (event.origin !== previewOrigin) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data?.type === PUBLIC_EVENTS.PREVIEW_READY) {
|
||||
const script = `(${previewScript.toString()})()`;
|
||||
const sendMessage = getSendMessage(iframeRef);
|
||||
sendMessage(PUBLIC_EVENTS.STRAPI_SCRIPT, { script });
|
||||
}
|
||||
|
||||
if (event.data?.type === INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT) {
|
||||
setPopoverField?.(event.data.payload);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
@ -270,6 +267,9 @@ const PreviewPage = () => {
|
||||
schema={documentResponse.schema}
|
||||
layout={documentLayoutResponse.edit}
|
||||
onPreview={onPreview}
|
||||
iframeRef={iframeRef}
|
||||
popoverField={popoverField}
|
||||
setPopoverField={setPopoverField}
|
||||
>
|
||||
<FormContext
|
||||
method="PUT"
|
||||
@ -301,108 +301,107 @@ const PreviewPage = () => {
|
||||
return yupSchema.validate(cleanedValues, { abortEarly: false });
|
||||
}}
|
||||
>
|
||||
{({ resetForm }) => (
|
||||
<Flex direction="column" height="100%" alignItems="stretch">
|
||||
<Blocker onProceed={resetForm} />
|
||||
<PreviewHeader />
|
||||
<Flex flex={1} overflow="auto" alignItems="stretch">
|
||||
{hasAdvancedPreview && (
|
||||
<Box
|
||||
overflow="auto"
|
||||
width={isSideEditorOpen ? '50%' : 0}
|
||||
borderWidth="0 1px 0 0"
|
||||
borderColor="neutral150"
|
||||
paddingTop={6}
|
||||
paddingBottom={6}
|
||||
// Remove horizontal padding when the editor is closed or it won't fully disappear
|
||||
paddingLeft={isSideEditorOpen ? 6 : 0}
|
||||
paddingRight={isSideEditorOpen ? 6 : 0}
|
||||
transition="all 0.2s ease-in-out"
|
||||
>
|
||||
<FormLayout
|
||||
layout={documentLayoutResponse.edit.layout}
|
||||
document={documentResponse}
|
||||
hasBackground={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
flex={1}
|
||||
height="100%"
|
||||
overflow="hidden"
|
||||
<Flex direction="column" height="100%" alignItems="stretch">
|
||||
<Blocker />
|
||||
<PreviewHeader />
|
||||
<InputPopover documentResponse={documentResponse} />
|
||||
<Flex flex={1} overflow="auto" alignItems="stretch">
|
||||
{hasAdvancedPreview && (
|
||||
<Box
|
||||
overflow="auto"
|
||||
width={isSideEditorOpen ? '50%' : 0}
|
||||
borderWidth="0 1px 0 0"
|
||||
borderColor="neutral150"
|
||||
paddingTop={6}
|
||||
paddingBottom={6}
|
||||
// Remove horizontal padding when the editor is closed or it won't fully disappear
|
||||
paddingLeft={isSideEditorOpen ? 6 : 0}
|
||||
paddingRight={isSideEditorOpen ? 6 : 0}
|
||||
transition="all 0.2s ease-in-out"
|
||||
>
|
||||
<Flex
|
||||
direction="row"
|
||||
background="neutral0"
|
||||
padding={2}
|
||||
borderWidth="0 0 1px 0"
|
||||
borderColor="neutral150"
|
||||
>
|
||||
{hasAdvancedPreview && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={formatMessage(
|
||||
isSideEditorOpen
|
||||
? {
|
||||
id: 'content-manager.preview.content.close-editor',
|
||||
defaultMessage: 'Close editor',
|
||||
}
|
||||
: {
|
||||
id: 'content-manager.preview.content.open-editor',
|
||||
defaultMessage: 'Open editor',
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsSideEditorOpen((prev) => !prev)}
|
||||
>
|
||||
<AnimatedArrow $isSideEditorOpen={isSideEditorOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
<Flex justifyContent="center" flex={1}>
|
||||
<SingleSelect
|
||||
value={deviceName}
|
||||
onChange={(name) => setDeviceName(name.toString())}
|
||||
aria-label={formatMessage({
|
||||
id: 'content-manager.preview.device.select',
|
||||
defaultMessage: 'Select device type',
|
||||
})}
|
||||
>
|
||||
{DEVICES.map((deviceOption) => (
|
||||
<SingleSelectOption key={deviceOption.name} value={deviceOption.name}>
|
||||
{formatMessage(deviceOption.label)}
|
||||
</SingleSelectOption>
|
||||
))}
|
||||
</SingleSelect>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" justifyContent="center" background="neutral0" flex={1}>
|
||||
<Box
|
||||
data-testid="preview-iframe"
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
/**
|
||||
* For some reason, changing an iframe's src tag causes the browser to add a new item in the
|
||||
* history stack. This is an issue for us as it means clicking the back button will not let us
|
||||
* go back to the edit view. To fix it, we need to trick the browser into thinking this is a
|
||||
* different iframe when the preview URL changes. So we set a key prop to force React
|
||||
* to mount a different node when the src changes.
|
||||
*/
|
||||
key={previewUrl}
|
||||
title={formatMessage({
|
||||
id: 'content-manager.preview.panel.title',
|
||||
defaultMessage: 'Preview',
|
||||
<FormLayout
|
||||
layout={documentLayoutResponse.edit.layout}
|
||||
document={documentResponse}
|
||||
hasBackground={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
flex={1}
|
||||
height="100%"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
direction="row"
|
||||
background="neutral0"
|
||||
padding={2}
|
||||
borderWidth="0 0 1px 0"
|
||||
borderColor="neutral150"
|
||||
>
|
||||
{hasAdvancedPreview && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={formatMessage(
|
||||
isSideEditorOpen
|
||||
? {
|
||||
id: 'content-manager.preview.content.close-editor',
|
||||
defaultMessage: 'Close editor',
|
||||
}
|
||||
: {
|
||||
id: 'content-manager.preview.content.open-editor',
|
||||
defaultMessage: 'Open editor',
|
||||
}
|
||||
)}
|
||||
onClick={() => setIsSideEditorOpen((prev) => !prev)}
|
||||
>
|
||||
<AnimatedArrow $isSideEditorOpen={isSideEditorOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
<Flex justifyContent="center" flex={1}>
|
||||
<SingleSelect
|
||||
value={deviceName}
|
||||
onChange={(name) => setDeviceName(name.toString())}
|
||||
aria-label={formatMessage({
|
||||
id: 'content-manager.preview.device.select',
|
||||
defaultMessage: 'Select device type',
|
||||
})}
|
||||
width={device.width}
|
||||
height={device.height}
|
||||
borderWidth={0}
|
||||
tag="iframe"
|
||||
/>
|
||||
>
|
||||
{DEVICES.map((deviceOption) => (
|
||||
<SingleSelectOption key={deviceOption.name} value={deviceOption.name}>
|
||||
{formatMessage(deviceOption.label)}
|
||||
</SingleSelectOption>
|
||||
))}
|
||||
</SingleSelect>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" justifyContent="center" background="neutral0" flex={1}>
|
||||
<Box
|
||||
data-testid="preview-iframe"
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
/**
|
||||
* For some reason, changing an iframe's src tag causes the browser to add a new item in the
|
||||
* history stack. This is an issue for us as it means clicking the back button will not let us
|
||||
* go back to the edit view. To fix it, we need to trick the browser into thinking this is a
|
||||
* different iframe when the preview URL changes. So we set a key prop to force React
|
||||
* to mount a different node when the src changes.
|
||||
*/
|
||||
key={previewUrl}
|
||||
title={formatMessage({
|
||||
id: 'content-manager.preview.panel.title',
|
||||
defaultMessage: 'Preview',
|
||||
})}
|
||||
width={device.width}
|
||||
height={device.height}
|
||||
borderWidth={0}
|
||||
tag="iframe"
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</FormContext>
|
||||
</PreviewProvider>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import type { INTERNAL_EVENTS, PUBLIC_EVENTS } from './constants';
|
||||
|
||||
type MessageType =
|
||||
| (typeof INTERNAL_EVENTS)[keyof typeof INTERNAL_EVENTS]
|
||||
| (typeof PUBLIC_EVENTS)[keyof typeof PUBLIC_EVENTS];
|
||||
|
||||
/**
|
||||
* A function factory so we can generate a new sendMessage everytime we need one.
|
||||
* We can't store and reuse a single sendMessage because it needs to have a stable identity
|
||||
* as it used in a useEffect function. And we can't rely on useCallback because we need the
|
||||
* up-to-date iframe ref, and this would make it stale (refs don't trigger callback reevaluations).
|
||||
*/
|
||||
export function getSendMessage(iframe: React.RefObject<HTMLIFrameElement> | undefined) {
|
||||
return (type: MessageType, payload?: unknown) => {
|
||||
if (!iframe?.current) return;
|
||||
|
||||
const { origin } = new URL(iframe.current.src);
|
||||
|
||||
iframe.current.contentWindow?.postMessage(
|
||||
{
|
||||
type,
|
||||
...(payload !== undefined && { payload }),
|
||||
},
|
||||
origin
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -3,6 +3,7 @@ declare global {
|
||||
interface Window {
|
||||
__strapi_previewCleanup?: () => void;
|
||||
STRAPI_HIGHLIGHT_HOVER_COLOR?: string;
|
||||
STRAPI_HIGHLIGHT_ACTIVE_COLOR?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,10 +19,15 @@ const previewScript = (shouldRun = true) => {
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
const HIGHLIGHT_PADDING = 2; // in pixels
|
||||
const HIGHLIGHT_HOVER_COLOR = window.STRAPI_HIGHLIGHT_HOVER_COLOR ?? '#4945ff'; // dark primary500
|
||||
const HIGHLIGHT_ACTIVE_COLOR = window.STRAPI_HIGHLIGHT_ACTIVE_COLOR ?? '#7b79ff'; // dark primary600
|
||||
|
||||
const SOURCE_ATTRIBUTE = 'data-strapi-source';
|
||||
const OVERLAY_ID = 'strapi-preview-overlay';
|
||||
const INTERNAL_EVENTS = {
|
||||
DUMMY_EVENT: 'dummyEvent',
|
||||
STRAPI_FIELD_FOCUS: 'strapiFieldFocus',
|
||||
STRAPI_FIELD_BLUR: 'strapiFieldBlur',
|
||||
STRAPI_FIELD_CHANGE: 'strapiFieldChange',
|
||||
STRAPI_FIELD_FOCUS_INTENT: 'strapiFieldFocusIntent',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -33,6 +39,17 @@ const previewScript = (shouldRun = true) => {
|
||||
return { INTERNAL_EVENTS };
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
* Utils
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
|
||||
const sendMessage = (
|
||||
type: (typeof INTERNAL_EVENTS)[keyof typeof INTERNAL_EVENTS],
|
||||
payload: unknown
|
||||
) => {
|
||||
window.parent.postMessage({ type, payload }, '*');
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
* Functionality pieces
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
@ -59,15 +76,17 @@ const previewScript = (shouldRun = true) => {
|
||||
};
|
||||
|
||||
type EventListenersList = Array<{
|
||||
element: HTMLElement;
|
||||
type: keyof HTMLElementEventMap;
|
||||
element: HTMLElement | Window;
|
||||
type: keyof HTMLElementEventMap | 'message';
|
||||
handler: EventListener;
|
||||
}>;
|
||||
|
||||
const createHighlightManager = (overlay: HTMLElement) => {
|
||||
const elements = window.document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`);
|
||||
const highlights: HTMLElement[] = [];
|
||||
const eventListeners: EventListenersList = [];
|
||||
const highlights: HTMLElement[] = [];
|
||||
const focusedHighlights: HTMLElement[] = [];
|
||||
let focusedField: string | null = null;
|
||||
|
||||
const drawHighlight = (target: Element, highlight: HTMLElement) => {
|
||||
if (!highlight) return;
|
||||
@ -102,15 +121,31 @@ const previewScript = (shouldRun = true) => {
|
||||
|
||||
// Move hover detection to the underlying element
|
||||
const mouseEnterHandler = () => {
|
||||
highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR;
|
||||
if (!highlightManager.focusedHighlights.includes(highlight)) {
|
||||
highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR;
|
||||
}
|
||||
};
|
||||
const mouseLeaveHandler = () => {
|
||||
highlight.style.outlineColor = 'transparent';
|
||||
if (!highlightManager.focusedHighlights.includes(highlight)) {
|
||||
highlight.style.outlineColor = 'transparent';
|
||||
}
|
||||
};
|
||||
const doubleClickHandler = () => {
|
||||
// TODO: handle for real
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Double click on highlight', element);
|
||||
const sourceAttribute = element.getAttribute(SOURCE_ATTRIBUTE);
|
||||
if (sourceAttribute) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT, {
|
||||
path: sourceAttribute,
|
||||
position: {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const mouseDownHandler = (event: MouseEvent) => {
|
||||
// Prevent default multi click to select behavior
|
||||
@ -143,6 +178,12 @@ const previewScript = (shouldRun = true) => {
|
||||
elements,
|
||||
updateAllHighlights,
|
||||
eventListeners,
|
||||
highlights,
|
||||
focusedHighlights,
|
||||
setFocusedField: (field: string | null) => {
|
||||
focusedField = field;
|
||||
},
|
||||
getFocusedField: () => focusedField,
|
||||
};
|
||||
};
|
||||
|
||||
@ -187,7 +228,7 @@ const previewScript = (shouldRun = true) => {
|
||||
window.addEventListener('scroll', updateOnScroll);
|
||||
window.addEventListener('resize', updateOnScroll);
|
||||
} else {
|
||||
(element as Element).addEventListener('scroll', updateOnScroll);
|
||||
element.addEventListener('scroll', updateOnScroll);
|
||||
}
|
||||
});
|
||||
|
||||
@ -199,8 +240,73 @@ const previewScript = (shouldRun = true) => {
|
||||
};
|
||||
|
||||
const setupEventHandlers = (highlightManager: HighlightManager) => {
|
||||
// TODO: The listeners for postMessage events will go here
|
||||
return highlightManager.eventListeners;
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (!event.data?.type) return;
|
||||
|
||||
// The user typed in an input, reflect the change in the preview
|
||||
if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_CHANGE) {
|
||||
const { field, value } = event.data.payload;
|
||||
if (!field) return;
|
||||
|
||||
const matchingElements = document.querySelectorAll(`[${SOURCE_ATTRIBUTE}="${field}"]`);
|
||||
matchingElements.forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.textContent = value || '';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The user focused a new input, update the highlights in the preview
|
||||
if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_FOCUS) {
|
||||
const { field } = event.data.payload;
|
||||
if (!field) return;
|
||||
|
||||
// Clear existing focused highlights
|
||||
highlightManager.focusedHighlights.forEach((highlight: HTMLElement) => {
|
||||
highlight.style.outlineColor = 'transparent';
|
||||
});
|
||||
highlightManager.focusedHighlights.length = 0;
|
||||
|
||||
// Set new focused field and highlight matching elements
|
||||
highlightManager.setFocusedField(field);
|
||||
const matchingElements = document.querySelectorAll(`[${SOURCE_ATTRIBUTE}="${field}"]`);
|
||||
matchingElements.forEach((element) => {
|
||||
const highlight =
|
||||
highlightManager.highlights[Array.from(highlightManager.elements).indexOf(element)];
|
||||
if (highlight) {
|
||||
highlight.style.outlineColor = HIGHLIGHT_ACTIVE_COLOR;
|
||||
highlight.style.outlineWidth = '3px';
|
||||
highlightManager.focusedHighlights.push(highlight);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The user is no longer focusing an input, remove the highlights
|
||||
if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_BLUR) {
|
||||
const { field } = event.data.payload;
|
||||
if (field !== highlightManager.getFocusedField()) return;
|
||||
|
||||
highlightManager.focusedHighlights.forEach((highlight: HTMLElement) => {
|
||||
highlight.style.outlineColor = 'transparent';
|
||||
highlight.style.outlineWidth = '2px';
|
||||
});
|
||||
highlightManager.focusedHighlights.length = 0;
|
||||
highlightManager.setFocusedField(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
// Add the message handler to the cleanup list
|
||||
const messageEventListener = {
|
||||
element: window,
|
||||
type: 'message' as keyof HTMLElementEventMap,
|
||||
handler: handleMessage as EventListener,
|
||||
};
|
||||
|
||||
return [...highlightManager.eventListeners, messageEventListener];
|
||||
};
|
||||
|
||||
const createCleanupSystem = (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user