0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Switched from Shadow DOM back to Iframes

refs https://github.com/TryGhost/Team/issues/1701

The Selection API is missing in Safari in Shadow DOM: https://bugs.webkit.org/show_bug.cgi?id=163921. So we need to move away from it for now.

- Adds a new component: Modal, which makes sure we can style modals using tailwind, while displaying them outside of the main iframe (inside a different iframe that is positioned fixed)
- Updated GenericDialog to use the new Modal component
- Removed ShadowDOM
This commit is contained in:
Simon Backx 2022-07-21 14:35:32 +02:00
parent a9c3ef5444
commit 0b8f92ddbe
7 changed files with 201 additions and 60 deletions

View file

@ -1,4 +1,4 @@
import ShadowRoot from './components/ShadowRoot';
import Frame from './components/Frame';
import * as Sentry from '@sentry/react';
import React from 'react';
import ActionHandler from './actions';
@ -20,9 +20,9 @@ function AuthFrame({adminUrl, onLoad}) {
function CommentsBoxContainer({done, appVersion}) {
return (
<ShadowRoot appVersion={appVersion}>
<Frame>
<CommentsBox done={done} />
</ShadowRoot>
</Frame>
);
}
@ -273,6 +273,7 @@ export default class App extends React.Component {
avatarSaturation: this.props.avatarSaturation,
accentColor: this.props.accentColor,
commentsEnabled: this.props.commentsEnabled,
appVersion: this.props.appVersion,
dispatchAction: (_action, data) => this.dispatchAction(_action, data),
/**
@ -294,8 +295,9 @@ export default class App extends React.Component {
return (
<SentryErrorBoundary dsn={this.props.sentryDsn}>
<AppContext.Provider value={this.getContextFromState()}>
<CommentsBoxContainer done={done} appVersion={this.props.appVersion} />
<CommentsBoxContainer done={done} />
<AuthFrame adminUrl={this.props.adminUrl} onLoad={this.initSetup.bind(this)} initStatus={this.state.initStatus}/>
<div id="ghost-comments-modal-root"></div>
</AppContext.Provider>
</SentryErrorBoundary>
);

View file

@ -0,0 +1,78 @@
import React, {useContext, useState} from 'react';
import {getBundledCssLink} from '../utils/helpers';
import AppContext from '../AppContext';
import IFrame from './IFrame';
const Frame = ({
children,
type,
style,
...props
}) => {
const {appVersion} = useContext(AppContext);
const cssLink = getBundledCssLink({appVersion});
const styles = `
body, html {
overflow: hidden;
}
`;
// We have two types of frames:
// - A full width + content fitted height one
// - A fixed positioned one for modals
/**
* @type {'dynamic'|'fixed'}
*/
type = type ?? 'dynamic';
// For now we don't listen for type changes, we could consider adding useEffect, but that won't be used
const defaultStyle = type === 'dynamic' ? {
width: '100%',
height: '400px'
} : {
zIndex: '3999999',
position: 'fixed',
left: '0',
top: '0',
width: '100%',
height: '100%',
overflow: 'hidden'
};
const [iframeStyle, setIframeStyle] = useState(defaultStyle);
const onResize = (iframeRoot) => {
setIframeStyle((current) => {
return {
...current,
height: `${iframeRoot.scrollHeight}px`
};
});
};
const [cssLoaded, setCssLoaded] = useState(false);
const onLoadCSS = () => {
setCssLoaded(true);
};
const head = (
<>
<link rel="stylesheet" href={cssLink} onLoad={onLoadCSS} />
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</>
);
const mergedStyle = {...iframeStyle, ...style};
// For now we're using <NewFrame> because using a functional component with portal caused some weird issues with modals
return (
<IFrame {...props} head={head} style={mergedStyle} onResize={type === 'dynamic' ? onResize : null}>
{cssLoaded && children}
</IFrame>
);
};
export default Frame;

View file

@ -0,0 +1,39 @@
import React, {Component} from 'react';
import {createPortal} from 'react-dom';
export default class IFrame extends Component {
componentDidMount() {
this.node.addEventListener('load', this.handleLoad);
}
handleLoad = () => {
this.setupFrameBaseStyle();
};
componentWillUnmout() {
this.node.removeEventListener('load', this.handleLoad);
}
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();
if (this.props.onResize) {
(new ResizeObserver(_ => this.props.onResize(this.iframeRoot))).observe(this.iframeRoot);
}
}
}
render() {
const {children, head, title = '', style = {}, onResize, ...rest} = this.props;
return (
<iframe srcDoc={`<!DOCTYPE html>`} {...rest} ref={node => (this.node = node)} title={title} style={style} frameBorder="0">
{this.iframeHead && createPortal(head, this.iframeHead)}
{this.iframeRoot && createPortal(children, this.iframeRoot)}
</iframe>
);
}
}

View file

@ -1,33 +0,0 @@
import React from 'react';
import root from 'react-shadow';
import {getBundledCssLink} from '../utils/helpers';
const ShadowRoot = ({
children,
appVersion,
...props
}) => {
const cssLink = getBundledCssLink({appVersion});
const styles = `
.ghost-display {
display: none;
}
`;
const head = (
<>
<link rel="stylesheet" href={cssLink} />
<style dangerouslySetInnerHTML={{__html: styles}} />
</>
);
return (
<root.div {...props} mode={'closed'}>
{head}
{children}
</root.div>
);
};
export default ShadowRoot;

View file

@ -1,33 +1,42 @@
import React from 'react';
import {Transition} from '@headlessui/react';
import Modal from './Modal';
const GenericDialog = (props) => {
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
const style = props.show ? {} : {
pointerEvents: 'none'
};
return (
<Transition
show={props.show}
enter="transition duration-200 linear"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-200 linear"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed top-0 left-0 overflow-hidden w-screen h-screen z-[99999999] flex pt-12 justify-center bg-gradient-to-b from-[rgba(0,0,0,0.2)] to-rgba(0,0,0,0.1) backdrop-blur-[2px]" onClick={props.cancel}>
<Transition.Child
enter="transition duration-200 delay-150 linear"
enterFrom="translate-y-4 opacity-0"
enterTo="translate-y-0 opacity-100"
<Modal show={props.show} style={style}>
<div>
<Transition
show={props.show}
enter="transition duration-200 linear"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-200 linear"
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-4 opacity-0"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="bg-white w-[500px] p-8 rounded-xl text-center shadow-modal">
{props.children}
<div className="fixed top-0 left-0 overflow-hidden w-screen h-screen flex pt-12 justify-center bg-gradient-to-b from-[rgba(0,0,0,0.2)] to-rgba(0,0,0,0.1) backdrop-blur-[2px]" onClick={props.cancel}>
<Transition.Child
enter="transition duration-200 delay-150 linear"
enterFrom="translate-y-4 opacity-0"
enterTo="translate-y-0 opacity-100"
leave="transition duration-200 linear"
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-4 opacity-0"
>
<div className="bg-white w-[500px] p-8 rounded-xl text-center shadow-modal">
{props.children}
</div>
</Transition.Child>
</div>
</Transition.Child>
</Transition>
</div>
</Transition>
</Modal>
);
};
export default GenericDialog;
export default GenericDialog;

View file

@ -0,0 +1,43 @@
import {Component} from 'react';
import {createPortal} from 'react-dom';
import Frame from '../Frame';
/**
* Full screen iframe, that is displayed fixed, and that can be used anywhere ('portalled' outside of existing iframes)
*/
export default class Modal extends Component {
constructor(props) {
super(props);
this.el = document.createElement('div');
}
componentDidMount() {
// The portal element is inserted in the DOM tree after
// the Modal's children are mounted, meaning that children
// will be mounted on a detached DOM node. If a child
// component requires to be attached to the DOM tree
// immediately when mounted, for example to measure a
// DOM node, or uses 'autoFocus' in a descendant, add
// state to Modal and only render the children when Modal
// is inserted in the DOM tree.
const modalRoot = document.getElementById('ghost-comments-modal-root');
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
const modalRoot = document.getElementById('ghost-comments-modal-root');
modalRoot.removeChild(this.el);
}
render() {
const content = (
<Frame type="fixed" style={this.props.style}>
{this.props.children}
</Frame>
);
return createPortal(
content,
this.el
);
}
}

View file

@ -105,13 +105,16 @@ export function getInitials(name) {
return parts[0].substring(0, 1).toLocaleUpperCase() + parts[parts.length - 1].substring(0, 1).toLocaleUpperCase();
}
// Keep a reference outside, because document.currentScript is only returned on the initial script load.
const currentScript = document.currentScript;
export function getBundledCssLink({appVersion}) {
if (process.env.NODE_ENV === 'production' && appVersion) {
return `https://unpkg.com/@tryghost/comments-ui@~${appVersion}/umd/main.css`;
} else {
if (document.currentScript) {
if (currentScript) {
// Dynamically determine the current path
const url = new URL(document.currentScript.src);
const url = new URL(currentScript.src);
return url.origin + '/main.css';
}
return 'http://localhost:4000/main.css';