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:
Rémi de Juvigny 2025-08-19 11:25:07 -04:00 committed by GitHub
parent 9fe221c689
commit 09e349ad45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 451 additions and 154 deletions

View File

@ -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>

View File

@ -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 };

View File

@ -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}

View File

@ -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 };

View File

@ -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 });
},
};
}

View File

@ -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>
</>

View File

@ -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
);
};
}

View File

@ -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 = (