mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-25 14:01:29 -05:00
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:
@@ -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}}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
@@ -33,6 +33,8 @@ export const actionTypes = createTypes(
|
||||
|
||||
'previewMounted',
|
||||
'projectPreviewMounted',
|
||||
'storePortalDocument',
|
||||
'removePortalDocument',
|
||||
'challengeMounted',
|
||||
'checkChallenge',
|
||||
'executeChallenge',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user