@@ -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,46 @@ 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+ // local storage key
51+ const terminalHistoryKey = name
52+ ? `terminal-history-${ name } `
53+ : "terminal-history" ;
54+
55+ // command history handling
56+ const [ historyIndex , setHistoryIndex ] = useState ( - 1 ) ;
57+ const [ history , setHistory ] = useState < string [ ] > ( [ ] ) ;
58+
59+ const [ currentLineInput , setCurrentLineInput ] = useState ( "" ) ;
3660 const [ cursorPos , setCursorPos ] = useState ( 0 ) ;
3761
38- const scrollIntoViewRef = useRef < HTMLDivElement > ( null )
62+ const scrollIntoViewRef = useRef < HTMLDivElement > ( null ) ;
3963
4064 const updateCurrentLineInput = ( event : ChangeEvent < HTMLInputElement > ) => {
4165 setCurrentLineInput ( event . target . value ) ;
42- }
66+ } ;
4367
4468 // Calculates the total width in pixels of the characters to the right of the cursor.
4569 // 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' ;
70+ const calculateInputWidth = (
71+ inputElement : HTMLInputElement ,
72+ chars : string ,
73+ ) => {
74+ const span = document . createElement ( "span" ) ;
75+ span . style . visibility = "hidden" ;
76+ span . style . position = "absolute" ;
5077 span . style . fontSize = window . getComputedStyle ( inputElement ) . fontSize ;
5178 span . style . fontFamily = window . getComputedStyle ( inputElement ) . fontFamily ;
5279 span . innerText = chars ;
@@ -57,82 +84,182 @@ const Terminal = ({name, prompt, height = "600px", colorMode, onInput, children,
5784 return - width ;
5885 } ;
5986
87+ // Change index ensuring it doesn't go out of bound
88+ const changeHistoryIndex = ( direction : 1 | - 1 ) => {
89+ setHistoryIndex ( ( oldIndex ) => {
90+ if ( history . length === 0 ) return - 1 ;
91+
92+ // If we're not currently looking at history (oldIndex === -1) and user presses ArrowUp, jump to the last entry.
93+ if ( oldIndex === - 1 && direction === - 1 ) {
94+ return history . length - 1 ;
95+ }
96+
97+ // If oldIndex === -1 and direction === 1 (ArrowDown), keep -1 (nothing to go to).
98+ if ( oldIndex === - 1 && direction === 1 ) {
99+ return - 1 ;
100+ }
101+
102+ return clamp ( oldIndex + direction , 0 , history . length - 1 ) ;
103+ } ) ;
104+ } ;
105+
60106 const clamp = ( value : number , min : number , max : number ) => {
61- if ( value > max ) return max ;
62- if ( value < min ) return min ;
107+ if ( value > max ) return max ;
108+ if ( value < min ) return min ;
63109 return value ;
64- }
110+ } ;
65111
66112 const handleInputKeyDown = ( event : KeyboardEvent < HTMLInputElement > ) => {
67- if ( ! onInput ) {
113+ event . preventDefault ( ) ;
114+ if ( ! onInput ) {
68115 return ;
69- } ;
70- if ( event . key === ' Enter' ) {
116+ }
117+ if ( event . key === " Enter" ) {
71118 onInput ( currentLineInput ) ;
72119 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 ) ) {
120+
121+ // history update
122+ setHistory ( ( previousHistory ) =>
123+ currentLineInput . trim ( ) === "" ||
124+ previousHistory [ previousHistory . length - 1 ] === currentLineInput . trim ( )
125+ ? previousHistory
126+ : [ ...previousHistory , currentLineInput ] ,
127+ ) ;
128+ setHistoryIndex ( - 1 ) ;
129+
130+ setCurrentLineInput ( "" ) ;
131+ setTimeout (
132+ ( ) =>
133+ scrollIntoViewRef ?. current ?. scrollIntoView ( {
134+ behavior : "auto" ,
135+ block : "nearest" ,
136+ } ) ,
137+ 500 ,
138+ ) ;
139+ } else if (
140+ [ "ArrowLeft" , "ArrowRight" , "ArrowDown" , "ArrowUp" , "Delete" ] . includes (
141+ event . key ,
142+ )
143+ ) {
76144 const inputElement = event . currentTarget ;
77145 let charsToRightOfCursor = "" ;
78- let cursorIndex = currentLineInput . length - ( inputElement . selectionStart || 0 ) ;
146+ let cursorIndex =
147+ currentLineInput . length - ( inputElement . selectionStart || 0 ) ;
79148 cursorIndex = clamp ( cursorIndex , 0 , currentLineInput . length ) ;
80149
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 )
150+ if ( event . key === "ArrowLeft" ) {
151+ if ( cursorIndex > currentLineInput . length - 1 ) cursorIndex -- ;
152+ charsToRightOfCursor = currentLineInput . slice (
153+ currentLineInput . length - 1 - cursorIndex ,
154+ ) ;
155+ } else if ( event . key === "ArrowRight" || event . key === "Delete" ) {
156+ charsToRightOfCursor = currentLineInput . slice (
157+ currentLineInput . length - cursorIndex + 1 ,
158+ ) ;
159+ } else if ( event . key === "ArrowUp" ) {
160+ charsToRightOfCursor = currentLineInput . slice ( 0 ) ;
161+ changeHistoryIndex ( - 1 ) ;
162+ } else if ( event . key === "ArrowDown" ) {
163+ charsToRightOfCursor = currentLineInput . slice ( 0 ) ;
164+ changeHistoryIndex ( 1 ) ;
90165 }
91166
92- const inputWidth = calculateInputWidth ( inputElement , charsToRightOfCursor ) ;
167+ const inputWidth = calculateInputWidth (
168+ inputElement ,
169+ charsToRightOfCursor ,
170+ ) ;
93171 setCursorPos ( inputWidth ) ;
94172 }
95- }
173+ } ;
96174
97175 useEffect ( ( ) => {
98176 setCurrentLineInput ( startingInputValue . trim ( ) ) ;
99177 } , [ startingInputValue ] ) ;
100178
179+ // If history index changes or history length changes, we want to update the input value
180+ useEffect ( ( ) => {
181+ if ( historyIndex >= 0 && historyIndex < history . length ) {
182+ setCurrentLineInput ( history [ historyIndex ] ) ;
183+ }
184+ } , [ historyIndex , history . length ] ) ;
185+
186+ // history local storage persistency
187+ useEffect ( ( ) => {
188+ const storedHistory = localStorage . getItem ( terminalHistoryKey ) ;
189+ if ( storedHistory ) {
190+ setHistory ( JSON . parse ( storedHistory ) ) ;
191+ }
192+ } , [ ] ) ;
193+
194+ useEffect ( ( ) => {
195+ localStorage . setItem ( terminalHistoryKey , JSON . stringify ( history ) ) ;
196+ } , [ history ] ) ;
197+
101198 // We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal
102199 useEffect ( ( ) => {
103200 if ( onInput == null ) {
104201 return ;
105202 }
106203 // 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 ) ;
204+ const elListeners : {
205+ terminalEl : Element ;
206+ listener : EventListenerOrEventListenerObject ;
207+ } [ ] = [ ] ;
208+ for ( const terminalEl of document . getElementsByClassName (
209+ "react-terminal-wrapper" ,
210+ ) ) {
211+ const listener = ( ) =>
212+ (
213+ terminalEl ?. querySelector ( ".terminal-hidden-input" ) as HTMLElement
214+ ) ?. focus ( ) ;
215+ terminalEl ?. addEventListener ( "click" , listener ) ;
111216 elListeners . push ( { terminalEl, listener } ) ;
112217 }
113- return function cleanup ( ) {
114- elListeners . forEach ( elListener => {
115- elListener . terminalEl . removeEventListener ( ' click' , elListener . listener ) ;
218+ return function cleanup ( ) {
219+ elListeners . forEach ( ( elListener ) => {
220+ elListener . terminalEl . removeEventListener ( " click" , elListener . listener ) ;
116221 } ) ;
117- }
222+ } ;
118223 } , [ onInput ] ) ;
119224
120- const classes = [ ' react-terminal-wrapper' ] ;
225+ const classes = [ " react-terminal-wrapper" ] ;
121226 if ( colorMode === ColorMode . Light ) {
122- classes . push ( ' react-terminal-light' ) ;
227+ classes . push ( " react-terminal-light" ) ;
123228 }
229+
124230 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 >
231+ < div className = { classes . join ( " " ) } data-terminal-name = { name } >
232+ < TopButtonsPanel
233+ { ...{ redBtnCallback, yellowBtnCallback, greenBtnCallback } }
234+ />
235+ < div className = "react-terminal" style = { { height } } >
236+ { children }
237+ { typeof onInput === "function" && (
238+ < div
239+ className = "react-terminal-line react-terminal-input react-terminal-active-input"
240+ data-terminal-prompt = { prompt || "$" }
241+ key = "terminal-line-prompt"
242+ >
243+ { currentLineInput }
244+ < span
245+ className = "cursor"
246+ style = { { left : `${ cursorPos + 1 } px` } }
247+ > </ span >
248+ </ div >
249+ ) }
250+ < div ref = { scrollIntoViewRef } > </ div >
131251 </ div >
132- < input className = "terminal-hidden-input" placeholder = "Terminal Hidden Input" value = { currentLineInput } autoFocus = { onInput != null } onChange = { updateCurrentLineInput } onKeyDown = { handleInputKeyDown } />
252+ < input
253+ className = "terminal-hidden-input"
254+ placeholder = "Terminal Hidden Input"
255+ value = { currentLineInput }
256+ autoFocus = { onInput != null }
257+ onChange = { updateCurrentLineInput }
258+ onKeyDown = { handleInputKeyDown }
259+ />
133260 </ div >
134261 ) ;
135- }
262+ } ;
136263
137264export { TerminalInput , TerminalOutput } ;
138265export default Terminal ;
0 commit comments