11'use client'
2- import React , { type PropsWithChildren , useCallback , useState , useRef , useEffect } from 'react'
2+ import React , { type PropsWithChildren , useCallback , useState , useRef , useEffect , useId } from 'react'
33import clsx from 'clsx'
44import { LiveProvider , LiveEditor , LiveError , LivePreview } from 'react-live'
55import { useColorMode } from '../../context/color-modes/useColorMode'
66import { useConfig } from '../../context/useConfig'
77import styles from './ReactCodeBlock.module.css'
88import { ActionBar , Button , ThemeProvider } from '@primer/react'
9- import { CopyIcon , MoonIcon , SunIcon , UndoIcon } from '@primer/octicons-react'
9+ import { Text } from '@primer/react-brand'
10+ import { CopyIcon , MoonIcon , SunIcon , UndoIcon , UnfoldIcon , FoldIcon } from '@primer/octicons-react'
1011import { colorModes } from '../../context/color-modes/context'
1112
1213import { lightTheme , darkTheme } from './syntax-highlighting-themes'
1314import { codeTransformer } from './code-transformer'
1415
16+ const COLLAPSE_HEIGHT = 400 // TODO: Hoist this to config to make user customizable eventually
17+
1518type ReactCodeBlockProps = {
1619 'data-language' : string
1720 'data-filename' ?: string
@@ -28,13 +31,35 @@ const getFocusableElements = () => {
2831}
2932
3033export function ReactCodeBlock ( props : ReactCodeBlockProps ) {
34+ const uniqueId = useId ( )
3135 const { colorMode, setColorMode} = useColorMode ( )
3236 const { basePath} = useConfig ( )
3337 const initialCode = getCodeFromChildren ( props . children )
3438 const [ code , setCode ] = useState ( initialCode )
35- const shouldShowPreview = [ 'tsx' , 'jsx' ] . includes ( props [ 'data-language' ] )
39+ const rootRef = useRef < HTMLDivElement > ( null )
40+ const [ isCodePaneCollapsed , setIsCodePaneCollapsed ] = useState < boolean | null > ( null )
41+ const [ initialPosition , setInitialPosition ] = useState < number | null > ( null )
3642 const editorRef = useRef < HTMLDivElement > ( null )
3743 const resetButtonRef = useRef < HTMLButtonElement > ( null )
44+ const shouldShowPreview = [ 'tsx' , 'jsx' ] . includes ( props [ 'data-language' ] )
45+
46+ // scroll back to the initial y pos on collapse state change
47+ useEffect ( ( ) => {
48+ if ( rootRef . current && initialPosition === null ) {
49+ const rect = rootRef . current . getBoundingClientRect ( )
50+ const scrollTop = window . pageYOffset || document . documentElement . scrollTop
51+ setInitialPosition ( rect . top + scrollTop )
52+ }
53+ } , [ initialPosition ] )
54+
55+ useEffect ( ( ) => {
56+ if ( editorRef . current && isCodePaneCollapsed === null ) {
57+ const editorHeight = editorRef . current . scrollHeight
58+ setIsCodePaneCollapsed ( editorHeight > COLLAPSE_HEIGHT )
59+ }
60+ } , [ code , isCodePaneCollapsed ] )
61+
62+ const shouldShowCollapse = isCodePaneCollapsed !== null && ( editorRef . current ?. scrollHeight || 0 ) > COLLAPSE_HEIGHT
3863
3964 /**
4065 * Transforms code to prepend basePath to img src attributes
@@ -52,6 +77,21 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
5277 navigator . clipboard . writeText ( code )
5378 } , [ code ] )
5479
80+ const handleCollapsibleCodePane = useCallback ( ( ) => {
81+ const newCollapsedState = ! isCodePaneCollapsed
82+
83+ if ( ! isCodePaneCollapsed && newCollapsedState && initialPosition !== null ) {
84+ requestAnimationFrame ( ( ) => {
85+ window . scrollTo ( {
86+ top : initialPosition ,
87+ behavior : 'smooth' ,
88+ } )
89+ } )
90+ }
91+
92+ setIsCodePaneCollapsed ( newCollapsedState )
93+ } , [ isCodePaneCollapsed , initialPosition ] )
94+
5595 const noInline = props [ 'data-filename' ] === 'noinline' || false
5696
5797 useEffect ( ( ) => {
@@ -92,7 +132,7 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
92132 return (
93133 < >
94134 < LiveProvider transformCode = { transformCodeWithBasePath } code = { code } scope = { props . jsxScope } noInline = { noInline } >
95- < div className = { clsx ( styles . CodeBlock , 'custom-component' ) } >
135+ < div ref = { rootRef } className = { clsx ( styles . CodeBlock , 'custom-component' ) } >
96136 { shouldShowPreview && (
97137 < div >
98138 < div className = { styles . colorModeMenu } >
@@ -126,8 +166,28 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
126166 Reset
127167 </ Button >
128168 </ div >
129- < div className = { styles . Editor } ref = { editorRef } >
130- < LiveEditor theme = { colorMode === 'light' ? lightTheme : darkTheme } onChange = { setCode } />
169+ < div className = { styles . EditorWrapper } >
170+ < div
171+ className = { clsx ( styles . Editor , isCodePaneCollapsed && styles [ 'Editor--is-collapsed' ] ) }
172+ ref = { editorRef }
173+ id = { `${ uniqueId } -code-editor-content` }
174+ >
175+ < LiveEditor theme = { colorMode === 'light' ? lightTheme : darkTheme } onChange = { setCode } />
176+ </ div >
177+ { shouldShowCollapse && (
178+ < button
179+ className = { clsx ( styles . collapseButton , isCodePaneCollapsed && styles [ 'collapseButton--collapsed' ] ) }
180+ onClick = { handleCollapsibleCodePane }
181+ aria-expanded = { ! isCodePaneCollapsed }
182+ aria-controls = { `${ uniqueId } -code-editor-content` }
183+ aria-label = { isCodePaneCollapsed ? 'Show full code block' : 'Collapse code block' }
184+ >
185+ < Text size = "100" className = { styles . collapseLabel } >
186+ { isCodePaneCollapsed ? 'Show full code' : 'Collapse code' }
187+ </ Text >
188+ < Text variant = "muted" > { isCodePaneCollapsed ? < UnfoldIcon /> : < FoldIcon /> } </ Text >
189+ </ button >
190+ ) }
131191 </ div >
132192 { shouldShowPreview && (
133193 < div className = { styles . Error } >
0 commit comments