0
Fork 0
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:
Ronald Langeveld 2023-09-11 15:23:12 +07:00 committed by GitHub
parent 73aca83fe5
commit 210de7fa11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 176 additions and 91 deletions

View file

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

View file

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

View file

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

View 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;

View file

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