feat: add preview popout window (#46251)

* feat: add preview popout window

* fix: remove unused

* fix: add title to window

* fix: add preview to window title

* feat: it works

* chore: clean up

* chore: more clean up

* fix: add better screen reader messages
This commit is contained in:
Tom
2022-09-04 06:15:54 -05:00
committed by GitHub
parent b436b5b337
commit c280e8a363
8 changed files with 206 additions and 24 deletions

View File

@@ -488,6 +488,12 @@
"breadcrumb-nav": "breadcrumb",
"submit": "Use Ctrl + Enter to submit.",
"running-tests": "Running tests",
"hide-preview": "Hide the preview",
"move-preview-to-new-window": "Move the preview to a new window and focus it",
"move-preview-to-main-window": "Move the preview to this window and close the external preview window",
"close-external-preview-window": "Close the external preview window",
"show-preview": "Show the preview in this window",
"open-preview-in-new-window": "Open the preview in a new window and focus it",
"step": "Step",
"steps": "Steps",
"steps-for": "Steps for {{blockTitle}}"

View File

@@ -1,4 +1,6 @@
import React from 'react';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useTranslation } from 'react-i18next';
import BreadCrumb from '../components/bread-crumb';
@@ -11,7 +13,8 @@ interface ActionRowProps {
showConsole: boolean;
showNotes: boolean;
showInstructions: boolean;
showPreview: boolean;
showPreviewPane: boolean;
showPreviewPortal: boolean;
superBlock: string;
togglePane: (pane: string) => void;
}
@@ -20,7 +23,8 @@ const ActionRow = ({
hasNotes,
togglePane,
showNotes,
showPreview,
showPreviewPane,
showPreviewPortal,
showConsole,
showInstructions,
isProjectBasedChallenge,
@@ -28,6 +32,29 @@ const ActionRow = ({
block
}: ActionRowProps): JSX.Element => {
const { t } = useTranslation();
// sets screen reader text for the two preview buttons
function getPreviewBtnsSrText() {
// no preview open
const previewBtnsSrText = {
pane: t('aria.show-preview'),
portal: t('aria.open-preview-in-new-window')
};
// preview open in main window
if (showPreviewPane && !showPreviewPortal) {
previewBtnsSrText.pane = t('aria.hide-preview');
previewBtnsSrText.portal = t('aria.move-preview-to-new-window');
// preview open in external window
} else if (showPreviewPortal && !showPreviewPane) {
previewBtnsSrText.pane = t('aria.move-preview-to-main-window');
previewBtnsSrText.portal = t('aria.close-external-preview-window');
}
return previewBtnsSrText;
}
return (
<div className='action-row'>
<div className='breadcrumbs-demo'>
@@ -36,7 +63,7 @@ const ActionRow = ({
<div className='tabs-row'>
{!isProjectBasedChallenge && (
<button
aria-expanded={showInstructions ? 'true' : 'false'}
aria-expanded={!!showInstructions}
onClick={() => togglePane('showInstructions')}
>
{t('learn.editor-tabs.instructions')}
@@ -45,24 +72,32 @@ const ActionRow = ({
<EditorTabs />
<div className='panel-display-tabs'>
<button
aria-expanded={showConsole ? 'true' : 'false'}
aria-expanded={!!showConsole}
onClick={() => togglePane('showConsole')}
>
{t('learn.editor-tabs.console')}
</button>
{hasNotes && (
<button
aria-expanded={showNotes ? 'true' : 'false'}
aria-expanded={!!showNotes}
onClick={() => togglePane('showNotes')}
>
{t('learn.editor-tabs.notes')}
</button>
)}
<button
aria-expanded={showPreview ? 'true' : 'false'}
onClick={() => togglePane('showPreview')}
aria-expanded={!!showPreviewPane}
onClick={() => togglePane('showPreviewPane')}
>
{t('learn.editor-tabs.preview')}
<span className='sr-only'>{getPreviewBtnsSrText().pane}</span>
<span aria-hidden='true'>{t('learn.editor-tabs.preview')}</span>
</button>
<button
aria-expanded={!!showPreviewPortal}
onClick={() => togglePane('showPreviewPortal')}
>
<span className='sr-only'>{getPreviewBtnsSrText().portal}</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</button>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import {
ChallengeFiles,
ResizeProps
} from '../../../redux/prop-types';
import PreviewPortal from '../components/preview-portal';
import ActionRow from './action-row';
type Pane = { flex: number };
@@ -34,6 +35,7 @@ interface DesktopLayoutProps {
resizeProps: ResizeProps;
superBlock: string;
testOutput: ReactElement;
windowTitle: string;
}
const reflexProps = {
@@ -42,14 +44,20 @@ const reflexProps = {
const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
const [showNotes, setShowNotes] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [showPreviewPane, setShowPreviewPane] = useState(true);
const [showPreviewPortal, setShowPreviewPortal] = useState(false);
const [showConsole, setShowConsole] = useState(false);
const [showInstructions, setShowInstuctions] = useState(true);
const togglePane = (pane: string): void => {
switch (pane) {
case 'showPreview':
setShowPreview(!showPreview);
case 'showPreviewPane':
if (!showPreviewPane && showPreviewPortal) setShowPreviewPortal(false);
setShowPreviewPane(!showPreviewPane);
break;
case 'showPreviewPortal':
if (!showPreviewPortal && showPreviewPane) setShowPreviewPane(false);
setShowPreviewPortal(!showPreviewPortal);
break;
case 'showConsole':
setShowConsole(!showConsole);
@@ -61,9 +69,10 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
setShowInstuctions(!showInstructions);
break;
default:
setShowInstuctions(false);
setShowInstuctions(true);
setShowConsole(false);
setShowPreview(false);
setShowPreviewPane(true);
setShowPreviewPortal(false);
setShowNotes(false);
}
};
@@ -86,17 +95,16 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
notes,
preview,
hasEditableBoundaries,
superBlock
superBlock,
windowTitle
} = props;
const challengeFile = getChallengeFile();
const projectBasedChallenge = hasEditableBoundaries;
const isMultifileCertProject =
challengeType === challengeTypes.multifileCertProject;
const displayPreview =
projectBasedChallenge || isMultifileCertProject
? showPreview && hasPreview
: hasPreview;
const displayPreviewPane = hasPreview && showPreviewPane;
const displayPreviewPortal = hasPreview && showPreviewPortal;
const displayNotes = projectBasedChallenge ? showNotes && hasNotes : false;
const displayConsole =
projectBasedChallenge || isMultifileCertProject ? showConsole : true;
@@ -119,7 +127,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
showConsole={showConsole}
showNotes={showNotes}
showInstructions={showInstructions}
showPreview={showPreview}
showPreviewPane={showPreviewPane}
showPreviewPortal={showPreviewPortal}
superBlock={superBlock}
togglePane={togglePane}
/>
@@ -169,13 +178,20 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
</ReflexElement>
)}
{displayPreview && <ReflexSplitter propagate={true} {...resizeProps} />}
{displayPreview && (
{displayPreviewPane && (
<ReflexSplitter propagate={true} {...resizeProps} />
)}
{displayPreviewPane && (
<ReflexElement flex={previewPane.flex} {...resizeProps}>
{preview}
</ReflexElement>
)}
</ReflexContainer>
{displayPreviewPortal && (
<PreviewPortal togglePane={togglePane} windowTitle={windowTitle}>
{preview}
</PreviewPortal>
)}
</div>
);
};

View File

@@ -476,6 +476,9 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
t
} = this.props;
const blockNameTitle = this.getBlockNameTitle(t);
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
return (
<Hotkeys
challengeType={challengeType}
@@ -488,7 +491,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
usesMultifileEditor={usesMultifileEditor}
>
<LearnLayout>
<Helmet title={`${this.getBlockNameTitle(t)} | freeCodeCamp.org`} />
<Helmet title={windowTitle} />
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor()}
@@ -524,6 +527,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
resizeProps={this.resizeProps}
superBlock={superBlock}
testOutput={this.renderTestOutput()}
windowTitle={windowTitle}
/>
</Media>
<CompletionModal

View File

@@ -0,0 +1,97 @@
import { Component, ReactElement } from 'react';
import ReactDOM from 'react-dom';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { storePortalDocument, removePortalDocument } from '../redux';
interface PreviewPortalProps {
children: ReactElement | null;
togglePane: (pane: string) => void;
windowTitle: string;
t: TFunction;
storePortalDocument: (document: Document | undefined) => void;
removePortalDocument: () => void;
}
const mapDispatchToProps = {
storePortalDocument,
removePortalDocument
};
class PreviewPortal extends Component<PreviewPortalProps> {
static displayName = 'PreviewPortal';
mainWindow: Window;
externalWindow: Window | null = null;
containerEl;
titleEl;
styleEl;
constructor(props: PreviewPortalProps) {
super(props);
this.mainWindow = window;
this.externalWindow = null;
this.containerEl = document.createElement('div');
this.titleEl = document.createElement('title');
this.styleEl = document.createElement('style');
}
componentDidMount() {
const { t, windowTitle } = this.props;
this.titleEl.innerText = `${t(
'learn.editor-tabs.preview'
)} | ${windowTitle}`;
this.styleEl.innerHTML = `
#fcc-main-frame {
width: 100%;
height: 100%;
border: none;
}
`;
this.externalWindow = window.open(
'',
'',
'width=960,height=540,left=100,top=100'
);
this.externalWindow?.document.head.appendChild(this.titleEl);
this.externalWindow?.document.head.appendChild(this.styleEl);
this.externalWindow?.document.body.setAttribute(
'style',
`
margin: 0px;
padding: 0px;
overflow: hidden;
`
);
this.externalWindow?.document.body.appendChild(this.containerEl);
this.externalWindow?.addEventListener('beforeunload', () => {
this.props.togglePane('showPreviewPortal');
});
this.props.storePortalDocument(this.externalWindow?.document);
this.mainWindow?.addEventListener('beforeunload', () => {
this.externalWindow?.close();
});
}
componentWillUnmount() {
this.externalWindow?.close();
this.props.removePortalDocument();
}
render() {
return ReactDOM.createPortal(this.props.children, this.containerEl);
}
}
PreviewPortal.displayName = 'PreviewPortal';
export default connect(
null,
mapDispatchToProps
)(withTranslation()(PreviewPortal));

View File

@@ -33,6 +33,8 @@ export const actionTypes = createTypes(
'previewMounted',
'projectPreviewMounted',
'storePortalDocument',
'removePortalDocument',
'challengeMounted',
'checkChallenge',
'executeChallenge',

View File

@@ -36,6 +36,7 @@ import {
} from '../../../utils/challenge-request-helpers';
import { actionTypes } from './action-types';
import {
portalDocumentSelector,
challengeDataSelector,
challengeMetaSelector,
challengeTestsSelector,
@@ -243,7 +244,10 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
// evaluate the user code in the preview frame or in the worker
if (challengeHasPreview(challengeData)) {
const document = yield getContext('document');
yield call(updatePreview, buildData, document, proxyLogger);
const portalDocument = yield select(portalDocumentSelector);
const finalDocument = portalDocument || document;
yield call(updatePreview, buildData, finalDocument, proxyLogger);
} else if (isJavaScriptChallenge(challengeData)) {
const runUserCode = getTestRunner(buildData, {
proxyLogger,

View File

@@ -42,6 +42,7 @@ const initialState = {
projectPreview: false,
shortcuts: false
},
portalDocument: false,
projectFormValues: {},
successMessage: 'Happy Coding!'
};
@@ -112,6 +113,14 @@ export const previewMounted = createAction(actionTypes.previewMounted);
export const projectPreviewMounted = createAction(
actionTypes.projectPreviewMounted
);
export const storePortalDocument = createAction(
actionTypes.storePortalDocument
);
export const removePortalDocument = createAction(
actionTypes.removePortalDocument
);
export const challengeMounted = createAction(actionTypes.challengeMounted);
export const checkChallenge = createAction(actionTypes.checkChallenge);
export const executeChallenge = createAction(actionTypes.executeChallenge);
@@ -155,6 +164,8 @@ export const successMessageSelector = state => state[ns].successMessage;
export const projectFormValuesSelector = state =>
state[ns].projectFormValues || {};
export const portalDocumentSelector = state => state[ns].portalDocument;
export const challengeDataSelector = state => {
const { challengeType } = challengeMetaSelector(state);
let challengeData = { challengeType };
@@ -322,7 +333,14 @@ export const reducer = handleActions(
...state,
isBuildEnabled: false
}),
[actionTypes.storePortalDocument]: (state, { payload }) => ({
...state,
portalDocument: payload
}),
[actionTypes.removePortalDocument]: state => ({
...state,
portalDocument: false
}),
[actionTypes.updateSuccessMessage]: (state, { payload }) => ({
...state,
successMessage: payload