mirror of
https://github.com/strapi/strapi.git
synced 2025-11-10 23:29:33 +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 {
|
import {
|
||||||
Page,
|
Page,
|
||||||
Blocker,
|
|
||||||
Form,
|
Form,
|
||||||
useForm,
|
|
||||||
useRBAC,
|
useRBAC,
|
||||||
useNotification,
|
useNotification,
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
@ -25,6 +23,7 @@ import { useOnce } from '../../hooks/useOnce';
|
|||||||
import { getTranslation } from '../../utils/translations';
|
import { getTranslation } from '../../utils/translations';
|
||||||
import { createYupSchema } from '../../utils/validation';
|
import { createYupSchema } from '../../utils/validation';
|
||||||
|
|
||||||
|
import { Blocker } from './components/Blocker';
|
||||||
import { FormLayout } from './components/FormLayout';
|
import { FormLayout } from './components/FormLayout';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Panels } from './components/Panels';
|
import { Panels } from './components/Panels';
|
||||||
@ -34,15 +33,6 @@ import { handleInvisibleAttributes } from './utils/data';
|
|||||||
* EditViewPage
|
* 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 EditViewPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [
|
const [
|
||||||
@ -220,7 +210,7 @@ const EditViewPage = () => {
|
|||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
</Grid.Root>
|
</Grid.Root>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
<BlockerWrapper />
|
<Blocker />
|
||||||
</>
|
</>
|
||||||
</Form>
|
</Form>
|
||||||
</Main>
|
</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 { useDocumentContext } from '../../../hooks/useDocumentContext';
|
||||||
import { useDocumentLayout } from '../../../hooks/useDocumentLayout';
|
import { useDocumentLayout } from '../../../hooks/useDocumentLayout';
|
||||||
import { useLazyComponents } from '../../../hooks/useLazyComponents';
|
import { useLazyComponents } from '../../../hooks/useLazyComponents';
|
||||||
|
import { usePreviewInputManager } from '../../../preview/hooks/usePreviewInputManager';
|
||||||
|
|
||||||
import { BlocksInput } from './FormInputs/BlocksInput/BlocksInput';
|
import { BlocksInput } from './FormInputs/BlocksInput/BlocksInput';
|
||||||
import { ComponentInput } from './FormInputs/Component/Input';
|
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
|
* the complete EditFieldLayout and will handle RBAC conditions and rendering CM specific
|
||||||
* components such as Blocks / Relations.
|
* 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 { currentDocumentMeta } = useDocumentContext('DynamicComponent');
|
||||||
const {
|
const {
|
||||||
edit: { components },
|
edit: { components },
|
||||||
@ -64,6 +70,10 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
|||||||
const editableFields = idToCheck ? canUpdateFields : canCreateFields;
|
const editableFields = idToCheck ? canUpdateFields : canCreateFields;
|
||||||
const readableFields = idToCheck ? canReadFields : 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,
|
* Component fields are always readable and editable,
|
||||||
* however the fields within them may not be.
|
* 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];
|
const CustomInput = lazyComponentStore[props.attribute.customField];
|
||||||
|
|
||||||
if (CustomInput) {
|
if (CustomInput) {
|
||||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
return (
|
||||||
return <CustomInput {...props} {...field} hint={hint} disabled={fieldIsDisabled} />;
|
<CustomInput
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||||
|
hint={hint}
|
||||||
|
disabled={fieldIsDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormInputRenderer
|
<FormInputRenderer
|
||||||
{...props}
|
{...props}
|
||||||
|
{...previewProps}
|
||||||
hint={hint}
|
hint={hint}
|
||||||
// @ts-expect-error – this workaround lets us display that the custom field is missing.
|
// @ts-expect-error – this workaround lets us display that the custom field is missing.
|
||||||
type={props.attribute.customField}
|
type={props.attribute.customField}
|
||||||
@ -124,8 +142,14 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
|||||||
const addedInputTypes = Object.keys(fields);
|
const addedInputTypes = Object.keys(fields);
|
||||||
if (!attributeHasCustomFieldProperty(props.attribute) && addedInputTypes.includes(props.type)) {
|
if (!attributeHasCustomFieldProperty(props.attribute) && addedInputTypes.includes(props.type)) {
|
||||||
const CustomInput = fields[props.type];
|
const CustomInput = fields[props.type];
|
||||||
// @ts-expect-error – TODO: fix this type error in the useLibrary hook.
|
return (
|
||||||
return <CustomInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
<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}
|
layout={components[props.attribute.component].layout}
|
||||||
disabled={fieldIsDisabled}
|
disabled={fieldIsDisabled}
|
||||||
>
|
>
|
||||||
{(inputProps) => <InputRenderer {...inputProps} />}
|
{(componentInputProps) => <InputRenderer {...componentInputProps} />}
|
||||||
</ComponentInput>
|
</ComponentInput>
|
||||||
);
|
);
|
||||||
case 'dynamiczone':
|
case 'dynamiczone':
|
||||||
@ -161,6 +185,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu
|
|||||||
return (
|
return (
|
||||||
<FormInputRenderer
|
<FormInputRenderer
|
||||||
{...props}
|
{...props}
|
||||||
|
{...previewProps}
|
||||||
hint={hint}
|
hint={hint}
|
||||||
options={props.attribute.enum.map((value) => ({ value }))}
|
options={props.attribute.enum.map((value) => ({ value }))}
|
||||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
// @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 (
|
return (
|
||||||
<FormInputRenderer
|
<FormInputRenderer
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
{...previewProps}
|
||||||
hint={hint}
|
hint={hint}
|
||||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
||||||
type={props.customField ? 'custom-field' : props.type}
|
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,
|
useRBAC,
|
||||||
createContext,
|
createContext,
|
||||||
Form as FormContext,
|
Form as FormContext,
|
||||||
Blocker,
|
|
||||||
} from '@strapi/admin/strapi-admin';
|
} from '@strapi/admin/strapi-admin';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -27,13 +26,16 @@ import { COLLECTION_TYPES } from '../../constants/collections';
|
|||||||
import { DocumentRBAC } from '../../features/DocumentRBAC';
|
import { DocumentRBAC } from '../../features/DocumentRBAC';
|
||||||
import { type UseDocument, useDocument } from '../../hooks/useDocument';
|
import { type UseDocument, useDocument } from '../../hooks/useDocument';
|
||||||
import { type EditLayout, useDocumentLayout } from '../../hooks/useDocumentLayout';
|
import { type EditLayout, useDocumentLayout } from '../../hooks/useDocumentLayout';
|
||||||
|
import { Blocker } from '../../pages/EditView/components/Blocker';
|
||||||
import { FormLayout } from '../../pages/EditView/components/FormLayout';
|
import { FormLayout } from '../../pages/EditView/components/FormLayout';
|
||||||
import { handleInvisibleAttributes } from '../../pages/EditView/utils/data';
|
import { handleInvisibleAttributes } from '../../pages/EditView/utils/data';
|
||||||
import { buildValidParams } from '../../utils/api';
|
import { buildValidParams } from '../../utils/api';
|
||||||
import { createYupSchema } from '../../utils/validation';
|
import { createYupSchema } from '../../utils/validation';
|
||||||
|
import { InputPopover } from '../components/InputPopover';
|
||||||
import { PreviewHeader } from '../components/PreviewHeader';
|
import { PreviewHeader } from '../components/PreviewHeader';
|
||||||
import { useGetPreviewUrlQuery } from '../services/preview';
|
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 { previewScript } from '../utils/previewScript';
|
||||||
|
|
||||||
import type { UID } from '@strapi/types';
|
import type { UID } from '@strapi/types';
|
||||||
@ -67,6 +69,11 @@ const DEVICES = [
|
|||||||
* PreviewProvider
|
* PreviewProvider
|
||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
interface PopoverField {
|
||||||
|
path: string;
|
||||||
|
position: DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
interface PreviewContextValue {
|
interface PreviewContextValue {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -75,6 +82,9 @@ interface PreviewContextValue {
|
|||||||
schema: NonNullable<ReturnType<UseDocument>['schema']>;
|
schema: NonNullable<ReturnType<UseDocument>['schema']>;
|
||||||
layout: EditLayout;
|
layout: EditLayout;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
|
iframeRef: React.RefObject<HTMLIFrameElement>;
|
||||||
|
popoverField: PopoverField | null;
|
||||||
|
setPopoverField: (value: PopoverField | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [PreviewProvider, usePreviewContext] = createContext<PreviewContextValue>('PreviewPage');
|
const [PreviewProvider, usePreviewContext] = createContext<PreviewContextValue>('PreviewPage');
|
||||||
@ -89,38 +99,13 @@ const AnimatedArrow = styled(ArrowLineLeft)<{ $isSideEditorOpen: boolean }>`
|
|||||||
transition: rotate 0.2s ease-in-out;
|
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 PreviewPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
const [isSideEditorOpen, setIsSideEditorOpen] = React.useState(true);
|
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
|
// Read all the necessary data from the URL to find the right preview URL
|
||||||
const {
|
const {
|
||||||
@ -147,11 +132,23 @@ const PreviewPage = () => {
|
|||||||
// Listen for ready message from iframe before injecting script
|
// Listen for ready message from iframe before injecting script
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent) => {
|
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) {
|
if (event.data?.type === PUBLIC_EVENTS.PREVIEW_READY) {
|
||||||
const script = `(${previewScript.toString()})()`;
|
const script = `(${previewScript.toString()})()`;
|
||||||
const sendMessage = getSendMessage(iframeRef);
|
const sendMessage = getSendMessage(iframeRef);
|
||||||
sendMessage(PUBLIC_EVENTS.STRAPI_SCRIPT, { script });
|
sendMessage(PUBLIC_EVENTS.STRAPI_SCRIPT, { script });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT) {
|
||||||
|
setPopoverField?.(event.data.payload);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
@ -270,6 +267,9 @@ const PreviewPage = () => {
|
|||||||
schema={documentResponse.schema}
|
schema={documentResponse.schema}
|
||||||
layout={documentLayoutResponse.edit}
|
layout={documentLayoutResponse.edit}
|
||||||
onPreview={onPreview}
|
onPreview={onPreview}
|
||||||
|
iframeRef={iframeRef}
|
||||||
|
popoverField={popoverField}
|
||||||
|
setPopoverField={setPopoverField}
|
||||||
>
|
>
|
||||||
<FormContext
|
<FormContext
|
||||||
method="PUT"
|
method="PUT"
|
||||||
@ -301,108 +301,107 @@ const PreviewPage = () => {
|
|||||||
return yupSchema.validate(cleanedValues, { abortEarly: false });
|
return yupSchema.validate(cleanedValues, { abortEarly: false });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ resetForm }) => (
|
<Flex direction="column" height="100%" alignItems="stretch">
|
||||||
<Flex direction="column" height="100%" alignItems="stretch">
|
<Blocker />
|
||||||
<Blocker onProceed={resetForm} />
|
<PreviewHeader />
|
||||||
<PreviewHeader />
|
<InputPopover documentResponse={documentResponse} />
|
||||||
<Flex flex={1} overflow="auto" alignItems="stretch">
|
<Flex flex={1} overflow="auto" alignItems="stretch">
|
||||||
{hasAdvancedPreview && (
|
{hasAdvancedPreview && (
|
||||||
<Box
|
<Box
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
width={isSideEditorOpen ? '50%' : 0}
|
width={isSideEditorOpen ? '50%' : 0}
|
||||||
borderWidth="0 1px 0 0"
|
borderWidth="0 1px 0 0"
|
||||||
borderColor="neutral150"
|
borderColor="neutral150"
|
||||||
paddingTop={6}
|
paddingTop={6}
|
||||||
paddingBottom={6}
|
paddingBottom={6}
|
||||||
// Remove horizontal padding when the editor is closed or it won't fully disappear
|
// Remove horizontal padding when the editor is closed or it won't fully disappear
|
||||||
paddingLeft={isSideEditorOpen ? 6 : 0}
|
paddingLeft={isSideEditorOpen ? 6 : 0}
|
||||||
paddingRight={isSideEditorOpen ? 6 : 0}
|
paddingRight={isSideEditorOpen ? 6 : 0}
|
||||||
transition="all 0.2s ease-in-out"
|
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
|
<FormLayout
|
||||||
direction="row"
|
layout={documentLayoutResponse.edit.layout}
|
||||||
background="neutral0"
|
document={documentResponse}
|
||||||
padding={2}
|
hasBackground={false}
|
||||||
borderWidth="0 0 1px 0"
|
/>
|
||||||
borderColor="neutral150"
|
</Box>
|
||||||
>
|
)}
|
||||||
{hasAdvancedPreview && (
|
<Flex
|
||||||
<IconButton
|
direction="column"
|
||||||
variant="ghost"
|
alignItems="stretch"
|
||||||
label={formatMessage(
|
flex={1}
|
||||||
isSideEditorOpen
|
height="100%"
|
||||||
? {
|
overflow="hidden"
|
||||||
id: 'content-manager.preview.content.close-editor',
|
>
|
||||||
defaultMessage: 'Close editor',
|
<Flex
|
||||||
}
|
direction="row"
|
||||||
: {
|
background="neutral0"
|
||||||
id: 'content-manager.preview.content.open-editor',
|
padding={2}
|
||||||
defaultMessage: 'Open editor',
|
borderWidth="0 0 1px 0"
|
||||||
}
|
borderColor="neutral150"
|
||||||
)}
|
>
|
||||||
onClick={() => setIsSideEditorOpen((prev) => !prev)}
|
{hasAdvancedPreview && (
|
||||||
>
|
<IconButton
|
||||||
<AnimatedArrow $isSideEditorOpen={isSideEditorOpen} />
|
variant="ghost"
|
||||||
</IconButton>
|
label={formatMessage(
|
||||||
)}
|
isSideEditorOpen
|
||||||
<Flex justifyContent="center" flex={1}>
|
? {
|
||||||
<SingleSelect
|
id: 'content-manager.preview.content.close-editor',
|
||||||
value={deviceName}
|
defaultMessage: 'Close editor',
|
||||||
onChange={(name) => setDeviceName(name.toString())}
|
}
|
||||||
aria-label={formatMessage({
|
: {
|
||||||
id: 'content-manager.preview.device.select',
|
id: 'content-manager.preview.content.open-editor',
|
||||||
defaultMessage: 'Select device type',
|
defaultMessage: 'Open editor',
|
||||||
})}
|
}
|
||||||
>
|
)}
|
||||||
{DEVICES.map((deviceOption) => (
|
onClick={() => setIsSideEditorOpen((prev) => !prev)}
|
||||||
<SingleSelectOption key={deviceOption.name} value={deviceOption.name}>
|
>
|
||||||
{formatMessage(deviceOption.label)}
|
<AnimatedArrow $isSideEditorOpen={isSideEditorOpen} />
|
||||||
</SingleSelectOption>
|
</IconButton>
|
||||||
))}
|
)}
|
||||||
</SingleSelect>
|
<Flex justifyContent="center" flex={1}>
|
||||||
</Flex>
|
<SingleSelect
|
||||||
</Flex>
|
value={deviceName}
|
||||||
<Flex direction="column" justifyContent="center" background="neutral0" flex={1}>
|
onChange={(name) => setDeviceName(name.toString())}
|
||||||
<Box
|
aria-label={formatMessage({
|
||||||
data-testid="preview-iframe"
|
id: 'content-manager.preview.device.select',
|
||||||
ref={iframeRef}
|
defaultMessage: 'Select device type',
|
||||||
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}
|
{DEVICES.map((deviceOption) => (
|
||||||
borderWidth={0}
|
<SingleSelectOption key={deviceOption.name} value={deviceOption.name}>
|
||||||
tag="iframe"
|
{formatMessage(deviceOption.label)}
|
||||||
/>
|
</SingleSelectOption>
|
||||||
|
))}
|
||||||
|
</SingleSelect>
|
||||||
</Flex>
|
</Flex>
|
||||||
</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>
|
</Flex>
|
||||||
)}
|
</Flex>
|
||||||
</FormContext>
|
</FormContext>
|
||||||
</PreviewProvider>
|
</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 {
|
interface Window {
|
||||||
__strapi_previewCleanup?: () => void;
|
__strapi_previewCleanup?: () => void;
|
||||||
STRAPI_HIGHLIGHT_HOVER_COLOR?: string;
|
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_PADDING = 2; // in pixels
|
||||||
const HIGHLIGHT_HOVER_COLOR = window.STRAPI_HIGHLIGHT_HOVER_COLOR ?? '#4945ff'; // dark primary500
|
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 SOURCE_ATTRIBUTE = 'data-strapi-source';
|
||||||
const OVERLAY_ID = 'strapi-preview-overlay';
|
const OVERLAY_ID = 'strapi-preview-overlay';
|
||||||
const INTERNAL_EVENTS = {
|
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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,6 +39,17 @@ const previewScript = (shouldRun = true) => {
|
|||||||
return { INTERNAL_EVENTS };
|
return { INTERNAL_EVENTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------------------------
|
||||||
|
* Utils
|
||||||
|
* ---------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const sendMessage = (
|
||||||
|
type: (typeof INTERNAL_EVENTS)[keyof typeof INTERNAL_EVENTS],
|
||||||
|
payload: unknown
|
||||||
|
) => {
|
||||||
|
window.parent.postMessage({ type, payload }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------------------------
|
||||||
* Functionality pieces
|
* Functionality pieces
|
||||||
* ---------------------------------------------------------------------------------------------*/
|
* ---------------------------------------------------------------------------------------------*/
|
||||||
@ -59,15 +76,17 @@ const previewScript = (shouldRun = true) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type EventListenersList = Array<{
|
type EventListenersList = Array<{
|
||||||
element: HTMLElement;
|
element: HTMLElement | Window;
|
||||||
type: keyof HTMLElementEventMap;
|
type: keyof HTMLElementEventMap | 'message';
|
||||||
handler: EventListener;
|
handler: EventListener;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const createHighlightManager = (overlay: HTMLElement) => {
|
const createHighlightManager = (overlay: HTMLElement) => {
|
||||||
const elements = window.document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`);
|
const elements = window.document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`);
|
||||||
const highlights: HTMLElement[] = [];
|
|
||||||
const eventListeners: EventListenersList = [];
|
const eventListeners: EventListenersList = [];
|
||||||
|
const highlights: HTMLElement[] = [];
|
||||||
|
const focusedHighlights: HTMLElement[] = [];
|
||||||
|
let focusedField: string | null = null;
|
||||||
|
|
||||||
const drawHighlight = (target: Element, highlight: HTMLElement) => {
|
const drawHighlight = (target: Element, highlight: HTMLElement) => {
|
||||||
if (!highlight) return;
|
if (!highlight) return;
|
||||||
@ -102,15 +121,31 @@ const previewScript = (shouldRun = true) => {
|
|||||||
|
|
||||||
// Move hover detection to the underlying element
|
// Move hover detection to the underlying element
|
||||||
const mouseEnterHandler = () => {
|
const mouseEnterHandler = () => {
|
||||||
highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR;
|
if (!highlightManager.focusedHighlights.includes(highlight)) {
|
||||||
|
highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const mouseLeaveHandler = () => {
|
const mouseLeaveHandler = () => {
|
||||||
highlight.style.outlineColor = 'transparent';
|
if (!highlightManager.focusedHighlights.includes(highlight)) {
|
||||||
|
highlight.style.outlineColor = 'transparent';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const doubleClickHandler = () => {
|
const doubleClickHandler = () => {
|
||||||
// TODO: handle for real
|
const sourceAttribute = element.getAttribute(SOURCE_ATTRIBUTE);
|
||||||
// eslint-disable-next-line no-console
|
if (sourceAttribute) {
|
||||||
console.log('Double click on highlight', element);
|
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) => {
|
const mouseDownHandler = (event: MouseEvent) => {
|
||||||
// Prevent default multi click to select behavior
|
// Prevent default multi click to select behavior
|
||||||
@ -143,6 +178,12 @@ const previewScript = (shouldRun = true) => {
|
|||||||
elements,
|
elements,
|
||||||
updateAllHighlights,
|
updateAllHighlights,
|
||||||
eventListeners,
|
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('scroll', updateOnScroll);
|
||||||
window.addEventListener('resize', updateOnScroll);
|
window.addEventListener('resize', updateOnScroll);
|
||||||
} else {
|
} else {
|
||||||
(element as Element).addEventListener('scroll', updateOnScroll);
|
element.addEventListener('scroll', updateOnScroll);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -199,8 +240,73 @@ const previewScript = (shouldRun = true) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupEventHandlers = (highlightManager: HighlightManager) => {
|
const setupEventHandlers = (highlightManager: HighlightManager) => {
|
||||||
// TODO: The listeners for postMessage events will go here
|
const handleMessage = (event: MessageEvent) => {
|
||||||
return highlightManager.eventListeners;
|
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 = (
|
const createCleanupSystem = (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user