import React, { useState, useEffect, useRef, KeyboardEvent, ChangeEvent } from 'react'; import './style.css'; export enum LineType { Input, Output } export enum ColorMode { Light, Dark } export interface Props { name?: string prompt?: string colorMode?: ColorMode lineData: Array<{type: LineType, value: string}> onInput?: ((input: string) => void) | null | undefined, startingInputValue?: string } const Terminal = ({name, prompt, colorMode, lineData, onInput, startingInputValue = ""}: Props) => { const [currentLineInput, setCurrentLineInput] = useState(''); const lastLineRef = useRef<null | HTMLElement>(null) const updateCurrentLineInput = (event: ChangeEvent<HTMLInputElement>) => { setCurrentLineInput(event.target.value); } const handleEnter = (event: KeyboardEvent<HTMLInputElement>) => { if (onInput != null && event.key === 'Enter') { onInput(currentLineInput); setCurrentLineInput(''); } } useEffect(() => { setCurrentLineInput(startingInputValue.trim()); }, [startingInputValue]); // An effect that handles scrolling into view the last line of terminal input or output const performScrolldown = useRef(false); useEffect(() => { if (performScrolldown.current) { // skip scrolldown when the component first loads setTimeout(() => lastLineRef?.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }), 500); } performScrolldown.current = true; }, [lineData.length]); // We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal useEffect(() => { if (onInput == null) { return; } // keep reference to listeners so we can perform cleanup const elListeners: { terminalEl: Element; listener: EventListenerOrEventListenerObject }[] = []; for (const terminalEl of document.getElementsByClassName('react-terminal-wrapper')) { const listener = () => (terminalEl?.querySelector('.terminal-hidden-input') as HTMLElement)?.focus(); terminalEl?.addEventListener('click', listener); elListeners.push({ terminalEl, listener }); } return function cleanup () { elListeners.forEach(elListener => { elListener.terminalEl.removeEventListener('click', elListener.listener); }); } }, [onInput]); const renderedLineData = lineData.map((ld, i) => { const classes = ['react-terminal-line']; if (ld.type === LineType.Input) { classes.push('react-terminal-input'); } // `lastLineRef` is used to ensure the terminal scrolls into view to the last line; make sure to add the ref to the last // redendered line if input prompt is not shown, i.e. `onInput` is not declared; see 'render prompt' below if (lineData.length === i + 1 && onInput == null) { return ( <span className={ classes.join(' ') } key={ i } ref={ lastLineRef }>{ ld.value }</span> ); } else { return ( <span className={ classes.join(' ') } key={ i }>{ ld.value }</span> ); } }); // render prompt if (onInput != null) { renderedLineData.push( <span className="react-terminal-line react-terminal-input react-terminal-active-input" data-terminal-prompt={ prompt || '$' } key={ lineData.length } ref={ lastLineRef }>{ currentLineInput }</span>, ); } const classes = ['react-terminal-wrapper']; if (colorMode === ColorMode.Light) { classes.push('react-terminal-light'); } return ( <div className={ classes.join(' ') } data-terminal-name={ name }> <div className="react-terminal"> { renderedLineData } </div> <input className="terminal-hidden-input" placeholder="Terminal Hidden Input" value={ currentLineInput } autoFocus={ onInput != null } onChange={ updateCurrentLineInput } onKeyDown={ handleEnter } /> </div> ); } export default Terminal;