From c280e8a363b119af4100ecd74c8742bff96ffbec Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Sun, 4 Sep 2022 06:15:54 -0500 Subject: [PATCH] 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 --- client/i18n/locales/english/translations.json | 6 ++ .../Challenges/classic/action-row.tsx | 51 ++++++++-- .../Challenges/classic/desktop-layout.tsx | 42 +++++--- .../src/templates/Challenges/classic/show.tsx | 6 +- .../Challenges/components/preview-portal.tsx | 97 +++++++++++++++++++ .../Challenges/redux/action-types.js | 2 + .../redux/execute-challenge-saga.js | 6 +- .../src/templates/Challenges/redux/index.js | 20 +++- 8 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 client/src/templates/Challenges/components/preview-portal.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index d872b38e89c..9b4d3801515 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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}}" diff --git a/client/src/templates/Challenges/classic/action-row.tsx b/client/src/templates/Challenges/classic/action-row.tsx index 3fedc739580..68a40bd2867 100644 --- a/client/src/templates/Challenges/classic/action-row.tsx +++ b/client/src/templates/Challenges/classic/action-row.tsx @@ -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 (
@@ -36,7 +63,7 @@ const ActionRow = ({
{!isProjectBasedChallenge && ( {hasNotes && ( )} +
diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index 7aa62f7f7d6..df8362ba06e 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -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 => { )} - {displayPreview && } - {displayPreview && ( + {displayPreviewPane && ( + + )} + {displayPreviewPane && ( {preview} )} + {displayPreviewPortal && ( + + {preview} + + )}
); }; diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 0b976c03f45..845a256be6a 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -476,6 +476,9 @@ class ShowClassic extends Component { t } = this.props; + const blockNameTitle = this.getBlockNameTitle(t); + const windowTitle = `${blockNameTitle} | freeCodeCamp.org`; + return ( { usesMultifileEditor={usesMultifileEditor} > - + { resizeProps={this.resizeProps} superBlock={superBlock} testOutput={this.renderTestOutput()} + windowTitle={windowTitle} /> void; + windowTitle: string; + t: TFunction; + storePortalDocument: (document: Document | undefined) => void; + removePortalDocument: () => void; +} + +const mapDispatchToProps = { + storePortalDocument, + removePortalDocument +}; + +class PreviewPortal extends Component { + 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)); diff --git a/client/src/templates/Challenges/redux/action-types.js b/client/src/templates/Challenges/redux/action-types.js index d7488e90b02..955194836a3 100644 --- a/client/src/templates/Challenges/redux/action-types.js +++ b/client/src/templates/Challenges/redux/action-types.js @@ -33,6 +33,8 @@ export const actionTypes = createTypes( 'previewMounted', 'projectPreviewMounted', + 'storePortalDocument', + 'removePortalDocument', 'challengeMounted', 'checkChallenge', 'executeChallenge', diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index ff4465feba2..fd171e041c1 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -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, diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index 7180f9b94d2..0364e590e16 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -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