feat(client): add action row with interactive editor toggle to lectures (#62928)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Huyen Nguyen
2025-10-24 20:12:00 +07:00
committed by GitHub
parent 3893532634
commit ae8417a467
15 changed files with 281 additions and 165 deletions
@@ -523,7 +523,8 @@
"instructions": "Instructions",
"notes": "Notes",
"preview": "Preview",
"editor": "Editor"
"editor": "Editor",
"interactive-editor": "Interactive Editor"
},
"editor-alerts": {
"tab-trapped": "Pressing tab will now insert the tab character",
@@ -952,7 +953,8 @@
"editor-a11y-on-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Command+E to disable or press Option+F1 for more options.",
"editor-a11y-on-non-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Ctrl+E to disable or press Alt+F1 for more options.",
"terminal-output": "Terminal output",
"not-available": "Not available"
"not-available": "Not available",
"interactive-editor-desc": "Turn static code examples into interactive editors. This allows you to edit and run the code directly on the page."
},
"flash": {
"no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.",
+1 -1
View File
@@ -10,5 +10,5 @@ div.flash-message {
padding-bottom: 3px;
position: fixed;
width: 100%;
z-index: 150;
z-index: var(--z-index-flash);
}
@@ -73,6 +73,7 @@
position: absolute;
right: 0;
width: 100%;
z-index: var(--z-index-site-header);
}
@media (min-width: 980px) {
+8
View File
@@ -139,6 +139,14 @@ hr {
min-height: 0;
}
.default-layout:has(.breadcrumbs-demo) #learn-app-wrapper {
padding-top: var(--breadcrumbs-height);
}
.default-layout:has(.action-row) #learn-app-wrapper {
padding-top: calc(var(--breadcrumbs-height) + var(--action-row-height));
}
h1 {
color: var(--secondary-color);
font-weight: 700;
@@ -37,6 +37,9 @@
--header-sub-element-size: 45px;
--header-height: 38px;
--breadcrumbs-height: 44px;
--action-row-height: 64px;
--z-index-breadcrumbs: 100;
--z-index-flash: 150;
--z-index-site-header: 200;
}
+6 -1
View File
@@ -182,7 +182,12 @@ type ParagraphNodule = {
type InteractiveEditorNodule = {
type: 'interactiveEditor';
data: { ext: Ext; name: string; contents: string }[];
data: {
ext: Ext;
name: string;
contents: string;
contentsHtml: string;
}[];
};
export type ChallengeNode = {
@@ -6,7 +6,7 @@ import store from 'store';
import { DailyCodingChallengeLanguages } from '../../../redux/prop-types';
import EditorTabs from './editor-tabs';
interface ActionRowProps {
interface ClassicLayoutProps {
dailyCodingChallengeLanguage: DailyCodingChallengeLanguages;
hasNotes: boolean;
hasPreview: boolean;
@@ -21,24 +21,58 @@ interface ActionRowProps {
showPreviewPane: boolean;
showPreviewPortal: boolean;
togglePane: (pane: string) => void;
hasInteractiveEditor?: never;
}
const ActionRow = ({
hasPreview,
hasNotes,
togglePane,
showNotes,
showPreviewPane,
showPreviewPortal,
showConsole,
showInstructions,
areInstructionsDisplayable,
isDailyCodingChallenge,
dailyCodingChallengeLanguage,
setDailyCodingChallengeLanguage
}: ActionRowProps): JSX.Element => {
interface InteractiveEditorProps {
hasInteractiveEditor: true;
showInteractiveEditor: boolean;
toggleInteractiveEditor: () => void;
}
type ActionRowProps = ClassicLayoutProps | InteractiveEditorProps;
const ActionRow = (props: ActionRowProps): JSX.Element => {
const { t } = useTranslation();
if (props.hasInteractiveEditor) {
const { toggleInteractiveEditor, showInteractiveEditor } = props;
return (
<div className='action-row'>
<div className='tabs-row'>
<div className='tabs-row-right'>
<button
aria-expanded={!!showInteractiveEditor}
aria-describedby='interactive-editor-desc'
onClick={toggleInteractiveEditor}
>
{t('learn.editor-tabs.interactive-editor')}
</button>
<span id='interactive-editor-desc' className='sr-only'>
{t('aria.interactive-editor-desc')}
</span>
</div>
</div>
</div>
);
}
const {
togglePane,
hasPreview,
hasNotes,
areInstructionsDisplayable,
showConsole,
showNotes,
showInstructions,
showPreviewPane,
showPreviewPortal,
isDailyCodingChallenge,
dailyCodingChallengeLanguage,
setDailyCodingChallengeLanguage
} = props;
// sets screen reader text for the two preview buttons
function getPreviewBtnsSrText() {
// no preview open
@@ -18,8 +18,15 @@
}
.action-row {
height: var(--action-row-height);
position: fixed;
top: calc(var(--header-height) + var(--breadcrumbs-height));
left: 0;
right: 0;
z-index: 100;
padding: 10px;
border-bottom: 1px solid var(--quaternary-background);
background-color: var(--secondary-background);
}
.monaco-editor-tabs button[aria-expanded='true'],
@@ -79,6 +86,7 @@
width: 30%;
display: flex;
justify-content: flex-end;
margin-inline-start: auto;
}
.monaco-editor-tabs button + button {
@@ -32,10 +32,16 @@ textarea.inputarea {
}
.breadcrumbs-demo {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
z-index: var(--z-index-breadcrumbs);
font-size: 16px;
margin: 0;
padding: 10px;
height: var(--breadcrumbs-height);
background: var(--secondary-background);
}
@media screen and (max-height: 300px) {
@@ -23,7 +23,7 @@
}
.sp-preview-actions .sp-button:hover {
background-color: var(--gray-10);
background-color: var(--gray-10) !important;
color: var(--gray-90) !important;
border-color: var(--gray-90);
border-color: var(--gray-90) !important;
}
@@ -7,6 +7,7 @@ export interface InteractiveFile {
ext: string;
name: string;
contents: string;
contentsHtml: string;
fileKey?: string;
}
@@ -52,7 +53,7 @@ const InteractiveEditor = ({ files }: Props) => {
};
return (
<div className='interactive-editor-wrapper' aria-hidden='true'>
<div className='interactive-editor-wrapper'>
<Sandpack
template={
got('tsx')
+136 -96
View File
@@ -15,6 +15,7 @@ import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import ChallengeDescription from '../components/challenge-description';
import InteractiveEditor from '../components/interactive-editor';
import ActionRow from '../classic/action-row';
import Hotkeys from '../components/hotkeys';
import ChallengeTitle from '../components/challenge-title';
import VideoPlayer from '../components/video-player';
@@ -70,12 +71,22 @@ interface ShowQuizProps {
updateSolutionFormValues: () => void;
}
function renderNodule(nodule: ChallengeNode['challenge']['nodules'][number]) {
function renderNodule(
nodule: ChallengeNode['challenge']['nodules'][number],
showInteractiveEditor: boolean
) {
switch (nodule.type) {
case 'paragraph':
return <PrismFormatted text={nodule.data} />;
case 'interactiveEditor':
return <InteractiveEditor files={nodule.data} />;
if (showInteractiveEditor) {
return <InteractiveEditor files={nodule.data} />;
} else {
const files = nodule.data;
return files.map((file, index) => (
<PrismFormatted key={index} text={file.contentsHtml} />
));
}
default:
return null;
}
@@ -207,6 +218,20 @@ const ShowGeneric = ({
const sceneSubject = new SceneSubject();
// interactive editor
const hasInteractiveEditor = nodules?.some(
nodule => nodule.type === 'interactiveEditor'
);
const [showInteractiveEditor, setShowInteractiveEditor] = useState(
() => !!store.get('showInteractiveEditor')
);
const toggleInteractiveEditor = () => {
store.set('showInteractiveEditor', !showInteractiveEditor);
setShowInteractiveEditor(!showInteractiveEditor);
};
return (
<Hotkeys
executeChallenge={handleSubmit}
@@ -217,114 +242,129 @@ const ShowGeneric = ({
<Helmet
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
/>
<Container>
<Row>
<Spacer size='m' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Container
fluid
className={`generic-challenge-container${hasInteractiveEditor ? ' has-action-row' : ''}`}
>
{hasInteractiveEditor && (
<ActionRow
hasInteractiveEditor={hasInteractiveEditor}
showInteractiveEditor={showInteractiveEditor}
toggleInteractiveEditor={toggleInteractiveEditor}
/>
)}
<Spacer size='m' />
<Container>
<Row>
<Spacer size='m' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
{description && (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription
description={description}
superBlock={superBlock}
/>
<Spacer size='m' />
</Col>
)}
<Spacer size='m' />
{nodules?.map((nodule, i) => {
return (
<React.Fragment key={i}>{renderNodule(nodule)}</React.Fragment>
);
})}
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
{videoId && (
<>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={handleVideoIsLoaded}
title={title}
videoId={videoId}
videoIsLoaded={videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
<Spacer size='m' />
</>
)}
</Col>
{scene && <Scene scene={scene} sceneSubject={sceneSubject} />}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
{transcript && <ChallengeTranscript transcript={transcript} />}
{instructions && (
<>
{description && (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription
instructions={instructions}
description={description}
superBlock={superBlock}
/>
<Spacer size='m' />
</>
</Col>
)}
{assignments.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<Assignments
assignments={assignments}
allAssignmentsCompleted={allAssignmentsCompleted}
handleAssignmentChange={handleAssignmentChange}
/>
</ObserveKeys>
)}
{nodules?.map((nodule, i) => {
return (
<React.Fragment key={i}>
{renderNodule(nodule, showInteractiveEditor)}
</React.Fragment>
);
})}
{questions.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<MultipleChoiceQuestions
questions={questions}
selectedOptions={selectedMcqOptions}
handleOptionChange={handleMcqOptionChange}
submittedMcqAnswers={submittedMcqAnswers}
showFeedback={showFeedback}
/>
</ObserveKeys>
)}
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
{videoId && (
<>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={handleVideoIsLoaded}
title={title}
videoId={videoId}
videoIsLoaded={videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
<Spacer size='m' />
</>
)}
</Col>
{explanation ? (
<ChallengeExplanation explanation={explanation} />
) : null}
{scene && <Scene scene={scene} sceneSubject={sceneSubject} />}
{!hasAnsweredMcqCorrectly && (
<p className='text-center'>{t('learn.answered-mcq')}</p>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
{transcript && <ChallengeTranscript transcript={transcript} />}
<Button block={true} variant='primary' onClick={handleSubmit}>
{questions.length == 0
? t('buttons.submit')
: t('buttons.check-answer')}
</Button>
<Spacer size='xxs' />
<Button block={true} variant='primary' onClick={openHelpModal}>
{t('buttons.ask-for-help')}
</Button>
{instructions && (
<>
<ChallengeDescription
instructions={instructions}
superBlock={superBlock}
/>
<Spacer size='m' />
</>
)}
<Spacer size='l' />
</Col>
<CompletionModal />
<HelpModal
challengeTitle={title}
challengeBlock={blockName}
superBlock={superBlock}
/>
</Row>
{assignments.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<Assignments
assignments={assignments}
allAssignmentsCompleted={allAssignmentsCompleted}
handleAssignmentChange={handleAssignmentChange}
/>
</ObserveKeys>
)}
{questions.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<MultipleChoiceQuestions
questions={questions}
selectedOptions={selectedMcqOptions}
handleOptionChange={handleMcqOptionChange}
submittedMcqAnswers={submittedMcqAnswers}
showFeedback={showFeedback}
/>
</ObserveKeys>
)}
{explanation ? (
<ChallengeExplanation explanation={explanation} />
) : null}
{!hasAnsweredMcqCorrectly && (
<p className='text-center'>{t('learn.answered-mcq')}</p>
)}
<Button block={true} variant='primary' onClick={handleSubmit}>
{questions.length == 0
? t('buttons.submit')
: t('buttons.check-answer')}
</Button>
<Spacer size='xxs' />
<Button block={true} variant='primary' onClick={openHelpModal}>
{t('buttons.ask-for-help')}
</Button>
<Spacer size='l' />
</Col>
<CompletionModal />
<HelpModal
challengeTitle={title}
challengeBlock={blockName}
superBlock={superBlock}
/>
</Row>
</Container>
</Container>
</LearnLayout>
</Hotkeys>
+2 -1
View File
@@ -192,7 +192,8 @@ const schema = Joi.object().keys({
Joi.object().keys({
ext: Joi.string().required(),
name: Joi.string().required(),
contents: Joi.string().required()
contents: Joi.string().required(),
contentsHtml: Joi.string().required()
})
),
otherwise: Joi.string().required()
@@ -54,12 +54,16 @@ function getFiles(filesNodes) {
return filesNodes.map(node => {
counts[node.lang] = counts[node.lang] ? counts[node.lang] + 1 : 1;
const contentsHtml = mdastToHTML([node]);
const out = {
contents: node.value,
ext: node.lang,
name:
getFilenames(node.lang) +
(counts[node.lang] ? `-${counts[node.lang]}` : '')
(counts[node.lang] ? `-${counts[node.lang]}` : ''),
contentsHtml
};
return out;
@@ -28,51 +28,51 @@ describe('add-interactive-editor plugin', () => {
element => element.type === 'interactiveEditor'
);
expect(editorElements).toEqual(
expect.arrayContaining([
{
data: [
{
ext: expect.any(String),
name: expect.any(String),
contents: expect.stringContaining(
'<div>This is an interactive element</div>'
)
}
],
type: 'interactiveEditor'
}
])
);
expect(editorElements).toEqual(
expect.arrayContaining([
{
data: [
{
ext: expect.any(String),
name: expect.any(String),
contents: expect.stringContaining(
'This is an interactive element'
)
}
],
type: 'interactiveEditor'
},
{
data: [
{
ext: expect.any(String),
name: expect.any(String),
contents: expect.stringContaining(
"console.log('Interactive JS');"
)
}
],
type: 'interactiveEditor'
}
])
);
expect(editorElements).toEqual([
{
type: 'interactiveEditor',
data: [
{
contents: "console.log('Interactive JS');",
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-js">console.log(\'Interactive JS\');\n</code></pre>'
}
]
},
{
type: 'interactiveEditor',
data: [
{
contents: '<div>This is an interactive element</div>',
ext: 'html',
name: 'index-1',
contentsHtml:
'<pre><code class="language-html">&#x3C;div>This is an interactive element&#x3C;/div>\n</code></pre>'
}
]
},
{
type: 'interactiveEditor',
data: [
{
contents: '<div>This is an interactive element</div>',
ext: 'html',
name: 'index-1',
contentsHtml:
'<pre><code class="language-html">&#x3C;div>This is an interactive element&#x3C;/div>\n</code></pre>'
},
{
contents: "console.log('Interactive JS');",
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-js">console.log(\'Interactive JS\');\n</code></pre>'
}
]
}
]);
});
it('provides unique names for each file with the same extension', async () => {
@@ -97,6 +97,9 @@ describe('add-interactive-editor plugin', () => {
// Contents should match
expect(files[0].contents).toBe("console.log('First JavaScript file');");
expect(files[1].contents).toBe("console.log('Second JavaScript file');");
expect(files[0].contentsHtml).toContain('<pre><code class="language-js">');
expect(files[1].contentsHtml).toContain('<pre><code class="language-js">');
});
it('respects the order of elements in the original markdown', async () => {