mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Added iframe double buffering in AdminX (#18002)
refs https://github.com/TryGhost/Product/issues/3807 https://github.com/TryGhost/Product/issues/3806 Added double buffering to iframes in Admin to have smoother transitions when swapping out iframes for preview pages. --- <!-- Leave the line below if you'd like GitHub Copilot to generate a summary from your commit --> <!-- copilot:summary --> ### <samp>🤖 Generated by Copilot at ad2b1a9</samp> Refactored the announcement bar modal and preview components to use a custom `IframeBuffering` component for better performance and simplicity. Extracted the `IframeBuffering` component to a new file and added some types and functions to support it. Removed some unused code and cleaned up imports.
This commit is contained in:
parent
73aca83fe5
commit
210de7fa11
5 changed files with 176 additions and 91 deletions
|
@ -1,4 +1,5 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import IframeBuffering from '../../../../utils/IframeBuffering';
|
||||
import React from 'react';
|
||||
|
||||
type EmbedSignupPreviewProps = {
|
||||
html: string;
|
||||
|
@ -6,16 +7,7 @@ type EmbedSignupPreviewProps = {
|
|||
};
|
||||
|
||||
const EmbedSignupPreview: React.FC<EmbedSignupPreviewProps> = ({html, style}) => {
|
||||
const [visibleIframeIndex, setVisibleIframeIndex] = useState(0);
|
||||
const iframes = [useRef<HTMLIFrameElement>(null), useRef<HTMLIFrameElement>(null)];
|
||||
|
||||
const updateIframeContent = (index: number) => {
|
||||
const iframe = iframes[index].current;
|
||||
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generateContentForEmbed = (iframe: HTMLIFrameElement) => {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (!iframeDoc) {
|
||||
return;
|
||||
|
@ -35,39 +27,14 @@ const EmbedSignupPreview: React.FC<EmbedSignupPreviewProps> = ({html, style}) =>
|
|||
iframeDoc.write(docString);
|
||||
iframeDoc.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const invisibleIframeIndex = visibleIframeIndex === 0 ? 1 : 0;
|
||||
updateIframeContent(invisibleIframeIndex);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleIframeIndex(invisibleIframeIndex);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [html, style]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<iframe
|
||||
ref={iframes[0]}
|
||||
// allowTransparency={true}
|
||||
className={`absolute h-full w-full transition-opacity duration-500 ${visibleIframeIndex !== 0 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
frameBorder="0"
|
||||
title="Signup Form Preview 1"
|
||||
></iframe>
|
||||
|
||||
<iframe
|
||||
ref={iframes[1]}
|
||||
// allowTransparency={true}
|
||||
className={`absolute h-full w-full transition-opacity duration-500 ${visibleIframeIndex !== 1 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
frameBorder="0"
|
||||
title="Signup Form Preview 2"
|
||||
></iframe>
|
||||
</div>
|
||||
<IframeBuffering
|
||||
className="absolute h-full w-full overflow-hidden transition-opacity duration-500"
|
||||
generateContent={generateContentForEmbed}
|
||||
height="100%"
|
||||
parentClassName="relative h-full w-full"
|
||||
width="100%"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,12 +4,11 @@ import ColorIndicator from '../../../admin-x-ds/global/form/ColorIndicator';
|
|||
import Form from '../../../admin-x-ds/global/form/Form';
|
||||
import HtmlField from '../../../admin-x-ds/global/form/HtmlField';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useRef, useState} from 'react';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
|
||||
import {Tab} from '../../../admin-x-ds/global/TabView';
|
||||
import {debounce} from '../../../utils/debounce';
|
||||
import {getHomepageUrl} from '../../../api/site';
|
||||
import {getSettingValues} from '../../../api/settings';
|
||||
import {showToast} from '../../../admin-x-ds/global/Toast';
|
||||
|
@ -25,6 +24,7 @@ type SidebarProps = {
|
|||
toggleVisibility: (visibility: string, value: boolean) => void;
|
||||
visibility?: string[];
|
||||
paidMembersEnabled?: boolean;
|
||||
onBlur: () => void;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
|
@ -35,7 +35,8 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
toggleColorSwatch,
|
||||
toggleVisibility,
|
||||
visibility = [],
|
||||
paidMembersEnabled
|
||||
paidMembersEnabled,
|
||||
onBlur
|
||||
}) => {
|
||||
const {config} = useGlobalData();
|
||||
|
||||
|
@ -74,6 +75,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
placeholder='Highlight breaking news, offers or updates'
|
||||
title='Announcement'
|
||||
value={announcementContent}
|
||||
onBlur={onBlur}
|
||||
onChange={announcementTextHandler}
|
||||
/>
|
||||
<ColorIndicator
|
||||
|
@ -116,7 +118,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
|
||||
const AnnouncementBarModal: React.FC = () => {
|
||||
const {siteData} = useGlobalData();
|
||||
// const homePageURL = getHomepageUrl(siteData!);
|
||||
const modal = NiceModal.useModal();
|
||||
const {localSettings, updateSetting, handleSave} = useSettingGroup();
|
||||
const [announcementContent] = getSettingValues<string>(localSettings, ['announcement_content']);
|
||||
|
@ -127,6 +128,7 @@ const AnnouncementBarModal: React.FC = () => {
|
|||
const visibilitySettings = JSON.parse(announcementVisibility?.toString() || '[]') as string[];
|
||||
const {updateRoute} = useRouting();
|
||||
const [selectedPreviewTab, setSelectedPreviewTab] = useState('homepage');
|
||||
const [announcementContentState, setAnnouncementContentState] = useState(announcementContent);
|
||||
|
||||
const toggleColorSwatch = (e: string | null) => {
|
||||
updateSetting('announcement_background', e);
|
||||
|
@ -142,23 +144,28 @@ const AnnouncementBarModal: React.FC = () => {
|
|||
updateSetting('announcement_visibility', JSON.stringify(visibilitySettings));
|
||||
};
|
||||
|
||||
const updateAnnouncementContextDebounced = useRef(
|
||||
debounce((value: string) => {
|
||||
updateSetting('announcement_content', value);
|
||||
}, 500)
|
||||
);
|
||||
const announcementTextHandler = useCallback((e:string) => {
|
||||
setAnnouncementContentState(e);
|
||||
}, []);
|
||||
|
||||
const sidebar = <Sidebar
|
||||
accentColor={accentColor}
|
||||
announcementBackgroundColor={announcementBackgroundColor}
|
||||
announcementContent={announcementContent}
|
||||
announcementTextHandler={(e) => {
|
||||
updateAnnouncementContextDebounced.current(e);
|
||||
announcementTextHandler(e);
|
||||
}}
|
||||
paidMembersEnabled={paidMembersEnabled}
|
||||
toggleColorSwatch={toggleColorSwatch}
|
||||
toggleVisibility={toggleVisibility}
|
||||
visibility={announcementVisibility as string[]}
|
||||
onBlur={() => {
|
||||
if (announcementContentState) {
|
||||
updateSetting('announcement_content', announcementContentState);
|
||||
} else {
|
||||
updateSetting('announcement_content', null);
|
||||
}
|
||||
}}
|
||||
/>;
|
||||
|
||||
const {data: {posts: [latestPost]} = {posts: []}} = useBrowsePosts({
|
||||
|
@ -184,14 +191,6 @@ const AnnouncementBarModal: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// const onTabChange = (id: string) => {
|
||||
// if (id === 'post' && latestPost) {
|
||||
// setSelectedPreviewTab('post');
|
||||
// } else {
|
||||
// setSelectedPreviewTab('homepage');
|
||||
// }
|
||||
// };
|
||||
|
||||
let selectedTabURL = getHomepageUrl(siteData!);
|
||||
switch (selectedPreviewTab) {
|
||||
case 'homepage':
|
||||
|
@ -202,6 +201,13 @@ const AnnouncementBarModal: React.FC = () => {
|
|||
break;
|
||||
}
|
||||
|
||||
const preview = <AnnouncementBarPreview
|
||||
announcementBackgroundColor={announcementBackgroundColor}
|
||||
announcementContent={announcementContent}
|
||||
url={selectedTabURL}
|
||||
visibility={visibilitySettings}
|
||||
/>;
|
||||
|
||||
return <PreviewModalContent
|
||||
afterClose={() => {
|
||||
modal.remove();
|
||||
|
@ -211,7 +217,7 @@ const AnnouncementBarModal: React.FC = () => {
|
|||
deviceSelector={false}
|
||||
dirty={false}
|
||||
okLabel='Save'
|
||||
preview={<AnnouncementBarPreview announcementBackgroundColor={announcementBackgroundColor} announcementContent={announcementContent} url={selectedTabURL} />}
|
||||
preview={preview}
|
||||
previewBgColor='greygradient'
|
||||
previewToolbarTabs={previewTabs}
|
||||
selectedURL={selectedPreviewTab}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import React, {useEffect, useRef} from 'react';
|
||||
import IframeBuffering from '../../../../utils/IframeBuffering';
|
||||
import React, {memo} from 'react';
|
||||
|
||||
const getPreviewData = (announcementBackgroundColor?:string, announcementContext?: string) => {
|
||||
const getPreviewData = (announcementBackgroundColor?: string, announcementContent?: string, visibility?: string[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('announcement_bg', announcementBackgroundColor || 'accent');
|
||||
params.append('announcement', announcementContext || '');
|
||||
params.append('announcement_vis', 'paid_members');
|
||||
params.append('announcement', announcementContent || '');
|
||||
if (visibility && visibility.length > 0) {
|
||||
params.append('announcement_vis', visibility?.join(',') || '');
|
||||
}
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
|
@ -12,24 +15,23 @@ type AnnouncementBarSettings = {
|
|||
announcementBackgroundColor?: string;
|
||||
announcementContent?: string;
|
||||
url: string;
|
||||
visibility?: string[];
|
||||
};
|
||||
|
||||
const AnnouncementBarPreview: React.FC<AnnouncementBarSettings> = ({announcementBackgroundColor, announcementContent, url}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const AnnouncementBarPreview: React.FC<AnnouncementBarSettings> = ({announcementBackgroundColor, announcementContent, url, visibility}) => {
|
||||
const injectContentIntoIframe = (iframe: HTMLIFrameElement) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch theme preview HTML
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/html;charset=utf-8',
|
||||
'x-ghost-preview': getPreviewData(
|
||||
announcementBackgroundColor,
|
||||
announcementContent
|
||||
announcementContent,
|
||||
visibility
|
||||
),
|
||||
Accept: 'text/plain',
|
||||
mode: 'cors',
|
||||
|
@ -54,30 +56,59 @@ const AnnouncementBarPreview: React.FC<AnnouncementBarSettings> = ({announcement
|
|||
|
||||
// Send the data to the iframe's window using postMessage
|
||||
// Inject the received content into the iframe
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.contentDocument?.open();
|
||||
iframe.contentDocument?.write(finalDoc);
|
||||
iframe.contentDocument?.close();
|
||||
}
|
||||
iframe.contentDocument?.open();
|
||||
iframe.contentDocument?.write(finalDoc);
|
||||
iframe.contentDocument?.close();
|
||||
})
|
||||
.catch(() => {
|
||||
// handle error in fetching data
|
||||
});
|
||||
}, [announcementBackgroundColor, announcementContent, url]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid='announcement-bar-preview'
|
||||
<div className='h-screen w-screen overflow-hidden'>
|
||||
<IframeBuffering
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
generateContent={injectContentIntoIframe}
|
||||
height='100%'
|
||||
title='Announcement Bar Preview'
|
||||
parentClassName="relative h-full w-full"
|
||||
testId='announcement-bar-preview-iframe'
|
||||
width='100%'
|
||||
>
|
||||
</iframe>
|
||||
</>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementBarPreview;
|
||||
export default memo(AnnouncementBarPreview, (prevProps, nextProps) => {
|
||||
// Check if announcementBackgroundColor changed
|
||||
if (prevProps.announcementBackgroundColor !== nextProps.announcementBackgroundColor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if announcementContent changed
|
||||
if (prevProps.announcementContent !== nextProps.announcementContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if url changed
|
||||
if (prevProps.url !== nextProps.url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if visibility array changed in size or content
|
||||
if (prevProps.visibility?.length !== nextProps.visibility?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.visibility && nextProps.visibility) {
|
||||
for (let i = 0; i < prevProps.visibility.length; i++) {
|
||||
if (prevProps.visibility[i] !== nextProps.visibility[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached this point, all props are the same
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
55
apps/admin-x-settings/src/utils/IframeBuffering.tsx
Normal file
55
apps/admin-x-settings/src/utils/IframeBuffering.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
|
||||
type IframeBufferingProps = {
|
||||
generateContent: (iframe: HTMLIFrameElement) => void;
|
||||
className?: string;
|
||||
parentClassName?: string;
|
||||
height?: string;
|
||||
width?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const IframeBuffering: React.FC<IframeBufferingProps> = ({generateContent, className, height, width, parentClassName, testId}) => {
|
||||
const [visibleIframeIndex, setVisibleIframeIndex] = useState(0);
|
||||
const iframes = [useRef<HTMLIFrameElement>(null), useRef<HTMLIFrameElement>(null)];
|
||||
useEffect(() => {
|
||||
const invisibleIframeIndex = visibleIframeIndex === 0 ? 1 : 0;
|
||||
const iframe = iframes[invisibleIframeIndex].current;
|
||||
if (iframe) {
|
||||
generateContent(iframe);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleIframeIndex(invisibleIframeIndex);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generateContent]);
|
||||
|
||||
return (
|
||||
<div className={parentClassName} data-testId={testId}>
|
||||
<iframe
|
||||
ref={iframes[0]}
|
||||
className={`${className} ${visibleIframeIndex !== 0 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
frameBorder="0"
|
||||
height={height}
|
||||
title="Buffered Preview 1"
|
||||
width={width}
|
||||
></iframe>
|
||||
|
||||
<iframe
|
||||
ref={iframes[1]}
|
||||
className={`${className} ${visibleIframeIndex !== 1 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
frameBorder="0"
|
||||
height={height}
|
||||
title="Buffered Preview 2"
|
||||
width={width}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IframeBuffering;
|
|
@ -24,15 +24,41 @@ test.describe('Announcement Bar', async () => {
|
|||
|
||||
await section.getByRole('button', {name: 'Customize'}).click();
|
||||
|
||||
// // Homepage and post preview
|
||||
await page.waitForSelector('[data-testid="announcement-bar-preview-iframe"]');
|
||||
const iframeCount = await page.$$eval('[data-testid="announcement-bar-preview-iframe"] > iframe', frames => frames.length);
|
||||
|
||||
expect(iframeCount).toEqual(2);
|
||||
|
||||
const modal = page.getByTestId('announcement-bar-modal');
|
||||
|
||||
// // Homepage and post preview
|
||||
await page.waitForSelector('[data-testid="announcement-bar-preview-iframe"]');
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="announcement-bar-preview"]').getByText('homepage preview')).toHaveCount(1);
|
||||
// Get the iframes inside the modal
|
||||
const iframesHandleHome = await page.$$('[data-testid="announcement-bar-preview-iframe"] > iframe');
|
||||
|
||||
const homeiframeHandler = iframesHandleHome[1]; // index 1 since that's the visible iframe
|
||||
const frameHome = await homeiframeHandler.contentFrame();
|
||||
const textExistsInFirstIframe = await frameHome?.$eval('body', (body, textToSearch) => {
|
||||
return body.innerText.includes(textToSearch);
|
||||
}, 'homepage preview');
|
||||
|
||||
expect(textExistsInFirstIframe).toBeTruthy();
|
||||
|
||||
await modal.getByTestId('design-toolbar').getByRole('tab', {name: 'Post'}).click();
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="announcement-bar-preview"]').getByText('post preview')).toHaveCount(1);
|
||||
await page.waitForSelector('[data-testid="announcement-bar-preview-iframe"]');
|
||||
|
||||
const iframesHandlePost = await page.$$('[data-testid="announcement-bar-preview-iframe"] > iframe');
|
||||
|
||||
const postiframeHandler = iframesHandlePost[0]; // index 0 since that's the visible iframe
|
||||
const framePost = await postiframeHandler.contentFrame();
|
||||
|
||||
const textExistsInSecondIframe = await framePost?.$eval('body', (body, textToSearch) => {
|
||||
return body.innerText.includes(textToSearch);
|
||||
}, 'post preview');
|
||||
|
||||
expect(textExistsInSecondIframe).toBeTruthy();
|
||||
});
|
||||
|
||||
// TODO - lexical isn't loading in the preview
|
||||
|
|
Loading…
Add table
Reference in a new issue