@@ -5,16 +5,19 @@ import React, {
55 KeyboardEvent ,
66 ChangeEvent ,
77 ReactNode ,
8- ReactElement
9- } from 'react' ;
10- import TerminalInput from './linetypes/TerminalInput' ;
11- import TerminalOutput from './linetypes/TerminalOutput' ;
12- import './style.css' ;
13- import { IWindowButtonsProps , WindowButtons } from "./ui-elements/WindowButtons" ;
8+ ReactElement ,
9+ } from "react" ;
10+ import TerminalInput from "./linetypes/TerminalInput" ;
11+ import TerminalOutput from "./linetypes/TerminalOutput" ;
12+ import "./style.css" ;
13+ import {
14+ IWindowButtonsProps ,
15+ WindowButtons ,
16+ } from "./ui-elements/WindowButtons" ;
1417
1518export enum ColorMode {
1619 Light ,
17- Dark
20+ Dark ,
1821}
1922
2023export interface Props {
@@ -31,22 +34,41 @@ export interface Props {
3134 TopButtonsPanel ?: ( props : IWindowButtonsProps ) => ReactElement | null ;
3235}
3336
34- const Terminal = ( { name, prompt, height = "600px" , colorMode, onInput, children, startingInputValue = "" , redBtnCallback, yellowBtnCallback, greenBtnCallback, TopButtonsPanel = WindowButtons } : Props ) => {
35- const [ currentLineInput , setCurrentLineInput ] = useState ( '' ) ;
37+ const Terminal = ( {
38+ name,
39+ prompt,
40+ height = "600px" ,
41+ colorMode,
42+ onInput,
43+ children,
44+ startingInputValue = "" ,
45+ redBtnCallback,
46+ yellowBtnCallback,
47+ greenBtnCallback,
48+ TopButtonsPanel = WindowButtons ,
49+ } : Props ) => {
50+ // command history handling
51+ const [ historyIndex , setHistoryIndex ] = useState ( - 1 ) ;
52+ const [ history , setHistory ] = useState < string [ ] > ( [ ] ) ;
53+
54+ const [ currentLineInput , setCurrentLineInput ] = useState ( "" ) ;
3655 const [ cursorPos , setCursorPos ] = useState ( 0 ) ;
3756
38- const scrollIntoViewRef = useRef < HTMLDivElement > ( null )
57+ const scrollIntoViewRef = useRef < HTMLDivElement > ( null ) ;
3958
4059 const updateCurrentLineInput = ( event : ChangeEvent < HTMLInputElement > ) => {
4160 setCurrentLineInput ( event . target . value ) ;
42- }
61+ } ;
4362
4463 // Calculates the total width in pixels of the characters to the right of the cursor.
4564 // Create a temporary span element to measure the width of the characters.
46- const calculateInputWidth = ( inputElement : HTMLInputElement , chars : string ) => {
47- const span = document . createElement ( 'span' ) ;
48- span . style . visibility = 'hidden' ;
49- span . style . position = 'absolute' ;
65+ const calculateInputWidth = (
66+ inputElement : HTMLInputElement ,
67+ chars : string ,
68+ ) => {
69+ const span = document . createElement ( "span" ) ;
70+ span . style . visibility = "hidden" ;
71+ span . style . position = "absolute" ;
5072 span . style . fontSize = window . getComputedStyle ( inputElement ) . fontSize ;
5173 span . style . fontFamily = window . getComputedStyle ( inputElement ) . fontFamily ;
5274 span . innerText = chars ;
@@ -57,82 +79,169 @@ const Terminal = ({name, prompt, height = "600px", colorMode, onInput, children,
5779 return - width ;
5880 } ;
5981
82+ // Change index ensuring it doesn't go out of bound
83+ const changeHistoryIndex = ( direction : 1 | - 1 ) => {
84+ setHistoryIndex ( ( oldIndex ) => {
85+ if ( history . length === 0 ) return - 1 ;
86+
87+ // If we're not currently looking at history (oldIndex === -1) and user presses ArrowUp, jump to the last entry.
88+ if ( oldIndex === - 1 && direction === - 1 ) {
89+ return history . length - 1 ;
90+ }
91+
92+ // If oldIndex === -1 and direction === 1 (ArrowDown), keep -1 (nothing to go to).
93+ if ( oldIndex === - 1 && direction === 1 ) {
94+ return - 1 ;
95+ }
96+
97+ return clamp ( oldIndex + direction , 0 , history . length - 1 ) ;
98+ } ) ;
99+ } ;
100+
60101 const clamp = ( value : number , min : number , max : number ) => {
61- if ( value > max ) return max ;
62- if ( value < min ) return min ;
102+ if ( value > max ) return max ;
103+ if ( value < min ) return min ;
63104 return value ;
64- }
105+ } ;
65106
66107 const handleInputKeyDown = ( event : KeyboardEvent < HTMLInputElement > ) => {
67- if ( ! onInput ) {
108+ event . preventDefault ( ) ;
109+ if ( ! onInput ) {
68110 return ;
69- } ;
70- if ( event . key === ' Enter' ) {
111+ }
112+ if ( event . key === " Enter" ) {
71113 onInput ( currentLineInput ) ;
72114 setCursorPos ( 0 ) ;
73- setCurrentLineInput ( '' ) ;
74- setTimeout ( ( ) => scrollIntoViewRef ?. current ?. scrollIntoView ( { behavior : "auto" , block : "nearest" } ) , 500 ) ;
75- } else if ( [ "ArrowLeft" , "ArrowRight" , "ArrowDown" , "ArrowUp" , "Delete" ] . includes ( event . key ) ) {
115+
116+ // history update
117+ setHistory ( ( previousHistory ) =>
118+ currentLineInput . trim ( ) === ""
119+ ? previousHistory
120+ : [ ...previousHistory , currentLineInput ] ,
121+ ) ;
122+ setHistoryIndex ( - 1 ) ;
123+
124+ setCurrentLineInput ( "" ) ;
125+ setTimeout (
126+ ( ) =>
127+ scrollIntoViewRef ?. current ?. scrollIntoView ( {
128+ behavior : "auto" ,
129+ block : "nearest" ,
130+ } ) ,
131+ 500 ,
132+ ) ;
133+ } else if (
134+ [ "ArrowLeft" , "ArrowRight" , "ArrowDown" , "ArrowUp" , "Delete" ] . includes (
135+ event . key ,
136+ )
137+ ) {
76138 const inputElement = event . currentTarget ;
77139 let charsToRightOfCursor = "" ;
78- let cursorIndex = currentLineInput . length - ( inputElement . selectionStart || 0 ) ;
140+ let cursorIndex =
141+ currentLineInput . length - ( inputElement . selectionStart || 0 ) ;
79142 cursorIndex = clamp ( cursorIndex , 0 , currentLineInput . length ) ;
80143
81- if ( event . key === 'ArrowLeft' ) {
82- if ( cursorIndex > currentLineInput . length - 1 ) cursorIndex -- ;
83- charsToRightOfCursor = currentLineInput . slice ( currentLineInput . length - 1 - cursorIndex ) ;
84- }
85- else if ( event . key === 'ArrowRight' || event . key === 'Delete' ) {
86- charsToRightOfCursor = currentLineInput . slice ( currentLineInput . length - cursorIndex + 1 ) ;
87- }
88- else if ( event . key === 'ArrowUp' ) {
89- charsToRightOfCursor = currentLineInput . slice ( 0 )
144+ if ( event . key === "ArrowLeft" ) {
145+ if ( cursorIndex > currentLineInput . length - 1 ) cursorIndex -- ;
146+ charsToRightOfCursor = currentLineInput . slice (
147+ currentLineInput . length - 1 - cursorIndex ,
148+ ) ;
149+ } else if ( event . key === "ArrowRight" || event . key === "Delete" ) {
150+ charsToRightOfCursor = currentLineInput . slice (
151+ currentLineInput . length - cursorIndex + 1 ,
152+ ) ;
153+ } else if ( event . key === "ArrowUp" ) {
154+ charsToRightOfCursor = currentLineInput . slice ( 0 ) ;
155+ changeHistoryIndex ( - 1 ) ;
156+ } else if ( event . key === "ArrowDown" ) {
157+ charsToRightOfCursor = currentLineInput . slice ( 0 ) ;
158+ changeHistoryIndex ( 1 ) ;
90159 }
91160
92- const inputWidth = calculateInputWidth ( inputElement , charsToRightOfCursor ) ;
161+ const inputWidth = calculateInputWidth (
162+ inputElement ,
163+ charsToRightOfCursor ,
164+ ) ;
93165 setCursorPos ( inputWidth ) ;
94166 }
95- }
167+ } ;
96168
97169 useEffect ( ( ) => {
98170 setCurrentLineInput ( startingInputValue . trim ( ) ) ;
99171 } , [ startingInputValue ] ) ;
100172
173+ // If history index changes or history length changes, we want to update the input value
174+ useEffect ( ( ) => {
175+ if ( historyIndex >= 0 && historyIndex < history . length ) {
176+ setCurrentLineInput ( history [ historyIndex ] ) ;
177+ }
178+ } , [ historyIndex , history . length ] ) ;
179+
101180 // We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal
102181 useEffect ( ( ) => {
103182 if ( onInput == null ) {
104183 return ;
105184 }
106185 // keep reference to listeners so we can perform cleanup
107- const elListeners : { terminalEl : Element ; listener : EventListenerOrEventListenerObject } [ ] = [ ] ;
108- for ( const terminalEl of document . getElementsByClassName ( 'react-terminal-wrapper' ) ) {
109- const listener = ( ) => ( terminalEl ?. querySelector ( '.terminal-hidden-input' ) as HTMLElement ) ?. focus ( ) ;
110- terminalEl ?. addEventListener ( 'click' , listener ) ;
186+ const elListeners : {
187+ terminalEl : Element ;
188+ listener : EventListenerOrEventListenerObject ;
189+ } [ ] = [ ] ;
190+ for ( const terminalEl of document . getElementsByClassName (
191+ "react-terminal-wrapper" ,
192+ ) ) {
193+ const listener = ( ) =>
194+ (
195+ terminalEl ?. querySelector ( ".terminal-hidden-input" ) as HTMLElement
196+ ) ?. focus ( ) ;
197+ terminalEl ?. addEventListener ( "click" , listener ) ;
111198 elListeners . push ( { terminalEl, listener } ) ;
112199 }
113- return function cleanup ( ) {
114- elListeners . forEach ( elListener => {
115- elListener . terminalEl . removeEventListener ( ' click' , elListener . listener ) ;
200+ return function cleanup ( ) {
201+ elListeners . forEach ( ( elListener ) => {
202+ elListener . terminalEl . removeEventListener ( " click" , elListener . listener ) ;
116203 } ) ;
117- }
204+ } ;
118205 } , [ onInput ] ) ;
119206
120- const classes = [ ' react-terminal-wrapper' ] ;
207+ const classes = [ " react-terminal-wrapper" ] ;
121208 if ( colorMode === ColorMode . Light ) {
122- classes . push ( ' react-terminal-light' ) ;
209+ classes . push ( " react-terminal-light" ) ;
123210 }
211+
124212 return (
125- < div className = { classes . join ( ' ' ) } data-terminal-name = { name } >
126- < TopButtonsPanel { ...{ redBtnCallback, yellowBtnCallback, greenBtnCallback} } />
127- < div className = "react-terminal" style = { { height } } >
128- { children }
129- { typeof onInput === 'function' && < div className = "react-terminal-line react-terminal-input react-terminal-active-input" data-terminal-prompt = { prompt || '$' } key = "terminal-line-prompt" > { currentLineInput } < span className = "cursor" style = { { left : `${ cursorPos + 1 } px` } } > </ span > </ div > }
130- < div ref = { scrollIntoViewRef } > </ div >
213+ < div className = { classes . join ( " " ) } data-terminal-name = { name } >
214+ < TopButtonsPanel
215+ { ...{ redBtnCallback, yellowBtnCallback, greenBtnCallback } }
216+ />
217+ < div className = "react-terminal" style = { { height } } >
218+ { children }
219+ { typeof onInput === "function" && (
220+ < div
221+ className = "react-terminal-line react-terminal-input react-terminal-active-input"
222+ data-terminal-prompt = { prompt || "$" }
223+ key = "terminal-line-prompt"
224+ >
225+ { currentLineInput }
226+ < span
227+ className = "cursor"
228+ style = { { left : `${ cursorPos + 1 } px` } }
229+ > </ span >
230+ </ div >
231+ ) }
232+ < div ref = { scrollIntoViewRef } > </ div >
131233 </ div >
132- < input className = "terminal-hidden-input" placeholder = "Terminal Hidden Input" value = { currentLineInput } autoFocus = { onInput != null } onChange = { updateCurrentLineInput } onKeyDown = { handleInputKeyDown } />
234+ < input
235+ className = "terminal-hidden-input"
236+ placeholder = "Terminal Hidden Input"
237+ value = { currentLineInput }
238+ autoFocus = { onInput != null }
239+ onChange = { updateCurrentLineInput }
240+ onKeyDown = { handleInputKeyDown }
241+ />
133242 </ div >
134243 ) ;
135- }
244+ } ;
136245
137246export { TerminalInput , TerminalOutput } ;
138247export default Terminal ;
0 commit comments