From 09e349ad451f6667368dec0a3a23c11e4e1eb63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20de=20Juvigny?= <8087692+remidej@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:25:07 -0400 Subject: [PATCH] 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 --- .../admin/src/pages/EditView/EditViewPage.tsx | 14 +- .../src/pages/EditView/components/Blocker.tsx | 15 ++ .../EditView/components/InputRenderer.tsx | 38 ++- .../src/preview/components/InputPopover.tsx | 85 ++++++ .../preview/hooks/usePreviewInputManager.ts | 49 ++++ .../admin/src/preview/pages/Preview.tsx | 247 +++++++++--------- .../admin/src/preview/utils/getSendMessage.ts | 27 ++ .../admin/src/preview/utils/previewScript.ts | 130 ++++++++- 8 files changed, 451 insertions(+), 154 deletions(-) create mode 100644 packages/core/content-manager/admin/src/pages/EditView/components/Blocker.tsx create mode 100644 packages/core/content-manager/admin/src/preview/components/InputPopover.tsx create mode 100644 packages/core/content-manager/admin/src/preview/hooks/usePreviewInputManager.ts create mode 100644 packages/core/content-manager/admin/src/preview/utils/getSendMessage.ts diff --git a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx index e662d53cb8..b86d712c96 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx @@ -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 ; -}; - const EditViewPage = () => { const location = useLocation(); const [ @@ -220,7 +210,7 @@ const EditViewPage = () => { - + diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/Blocker.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/Blocker.tsx new file mode 100644 index 0000000000..a240825dcf --- /dev/null +++ b/packages/core/content-manager/admin/src/pages/EditView/components/Blocker.tsx @@ -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 ; +}; + +export { Blocker }; diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx index 2a5e455e62..08fc9a9ff3 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx @@ -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 & { * 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 ; + return ( + + ); } return ( ; + return ( + + ); } /** @@ -143,7 +167,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu layout={components[props.attribute.component].layout} disabled={fieldIsDisabled} > - {(inputProps) => } + {(componentInputProps) => } ); case 'dynamiczone': @@ -161,6 +185,7 @@ const InputRenderer = ({ visible, hint: providedHint, document, ...props }: Inpu return ( ({ 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 ( ('InputPopover'); + +const InputPopover = ({ documentResponse }: { documentResponse: ReturnType }) => { + 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. + **/} + + + !open && setPopoverField(null)}> + + + + + + + + + + + + ); +}; + +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 }; diff --git a/packages/core/content-manager/admin/src/preview/hooks/usePreviewInputManager.ts b/packages/core/content-manager/admin/src/preview/hooks/usePreviewInputManager.ts new file mode 100644 index 0000000000..215ff11cdc --- /dev/null +++ b/packages/core/content-manager/admin/src/preview/hooks/usePreviewInputManager.ts @@ -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>, + '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 }); + }, + }; +} diff --git a/packages/core/content-manager/admin/src/preview/pages/Preview.tsx b/packages/core/content-manager/admin/src/preview/pages/Preview.tsx index 1fea305c86..e5df1e7fe6 100644 --- a/packages/core/content-manager/admin/src/preview/pages/Preview.tsx +++ b/packages/core/content-manager/admin/src/preview/pages/Preview.tsx @@ -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['schema']>; layout: EditLayout; onPreview: () => void; + iframeRef: React.RefObject; + popoverField: PopoverField | null; + setPopoverField: (value: PopoverField | null) => void; } const [PreviewProvider, usePreviewContext] = createContext('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) { - 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(null); const [isSideEditorOpen, setIsSideEditorOpen] = React.useState(true); + const [popoverField, setPopoverField] = React.useState(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} > { return yupSchema.validate(cleanedValues, { abortEarly: false }); }} > - {({ resetForm }) => ( - - - - - {hasAdvancedPreview && ( - - - - )} - + + + + + {hasAdvancedPreview && ( + - - {hasAdvancedPreview && ( - setIsSideEditorOpen((prev) => !prev)} - > - - - )} - - setDeviceName(name.toString())} - aria-label={formatMessage({ - id: 'content-manager.preview.device.select', - defaultMessage: 'Select device type', - })} - > - {DEVICES.map((deviceOption) => ( - - {formatMessage(deviceOption.label)} - - ))} - - - - - + + )} + + + {hasAdvancedPreview && ( + setIsSideEditorOpen((prev) => !prev)} + > + + + )} + + 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) => ( + + {formatMessage(deviceOption.label)} + + ))} + + + + - )} + diff --git a/packages/core/content-manager/admin/src/preview/utils/getSendMessage.ts b/packages/core/content-manager/admin/src/preview/utils/getSendMessage.ts new file mode 100644 index 0000000000..eacc3cae4a --- /dev/null +++ b/packages/core/content-manager/admin/src/preview/utils/getSendMessage.ts @@ -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 | 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 + ); + }; +} diff --git a/packages/core/content-manager/admin/src/preview/utils/previewScript.ts b/packages/core/content-manager/admin/src/preview/utils/previewScript.ts index cbde454392..9240f2843a 100644 --- a/packages/core/content-manager/admin/src/preview/utils/previewScript.ts +++ b/packages/core/content-manager/admin/src/preview/utils/previewScript.ts @@ -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 = (