😄
ReactでBashターミナル風UIを作って遊ぶ
ReactでBashターミナル風UIを作って遊ぶ
Reactの基本的なhooksを使ってBashターミナル風UIを作って遊びます。Todoリストに飽きた方におすすめです
注意事項
- あくまでターミナル風のUIです
- クライアント側で実行してください
- デザインパターンや適切なコンポーネントの分類は現在修行中のため、お許しください
コード
先に成果物ですが、こんな感じのコードになります。
import React, { useState, useRef, useEffect, ChangeEvent, forwardRef } from 'react';
import { cn } from '@/utils/cn';
type Command = (...args: string[]) => string | void;
interface Commands {
[key: string]: Command;
}
interface Props {
prompt?: string;
commands?: Commands
}
interface TerminalResult {
prompt: string;
command: string;
indent: number;
result?: string;
}
interface InputedTextProps {
promptWidth: number;
inputs: string;
focus: boolean;
handler: () => void;
}
interface HistoriesProps {
histories: TerminalResult[]
}
interface HistoryProps {
history: TerminalResult;
}
export const Cursor = () => (
<span
className='w-2 h-4 inline-block bg-gray-50'
/>
)
export const InputedText = forwardRef<HTMLDivElement, InputedTextProps>(({ promptWidth, inputs, focus, handler }, ref) => (
<div
className='max-w-full break-all absolute top-0 text-gray-50 py-0.5'
ref={ref}
style={{
textIndent: promptWidth
}}
onClick={() => handler()}
>
<>
{inputs}
{focus && (
<Cursor />
)}
</>
</div>
));
InputedText.displayName = "InputedText"
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>((props, ref) => (
<textarea
{...props}
ref={ref}
/>
))
Textarea .displayName = "Textarea"
export const Histories = ({ histories }: HistoriesProps) => (
<>{histories.map((history, i) => (
<History history={history} key={i} />
))}</>
)
export const History = ({ history }: HistoryProps) => (
<div
className={cn(
'w-full h-6 shrink-0 relative break-all py-0.5'
)}
>
{history.result ? (
<p>{history.result}</p>
) : (
<>
<div className='absolute left-0 top-0'>{history.prompt}</div>
<p
style={{
textIndent: history.indent
}}
>
{history.command}
</p>
</>
)}
</div>
)
export const TerminalUI = (props: Props) => {
const {
prompt = "[root@root ~]$ ",
commands,
} = props;
const [inputs, setInputs] = useState<string>("");
const [promptWidth, setPromptWidth] = useState<number>(0);
const [focus, setFocus] = useState<boolean>(false);
const [cols, setCols] = useState<number>(0);
const [histories, setHistories] = useState<TerminalResult[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptRef = useRef<HTMLDivElement>(null);
const inputTextRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const body = document.body;
const ref = promptRef;
if(ref && ref.current && ref.current.clientWidth) {
const width = ref.current.clientWidth;
setPromptWidth(width + 8);
}
if(textareaRef && textareaRef.current) {
const currentRef = textareaRef.current;
body.addEventListener('click', () => {
setFocus(true)
currentRef.focus()
})
return () => {
body.removeEventListener('click', () => {
setFocus(true)
currentRef.focus()
})
}
}
}, [])
useEffect(() => {
const height = inputTextRef.current?.clientHeight ?? 0;
if(inputs.length) {
setCols(Math.ceil(((height - 2) / 24) + 1))
return;
}
setCols(1)
return;
}, [inputs, setCols])
const defaultCommands: Commands = {
clear: () => {
setHistories([]);
setInputs("")
},
echo: (...args) => args.join(" ").replaceAll(/\"/g, "")
}
const allCommands: Commands = {
...defaultCommands,
...commands,
}
return (
<div
className='w-screen h-screen overflow-x-hidden bg-black text-gray-50'
>
<div className='w-full min-h-full font-noto-sans flex flex-col gap-1.5' ref={bodyRef}>
<Histories histories={histories} />
<div
className={cn(
"w-full relative h-full"
)}
>
<p className='absolute' ref={promptRef}>{prompt}</p>
<Textarea
className={cn(
"sr-only bg-transparent text-transparent caret-transparent border-none outline-none resize-none"
)}
value={inputs}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInputs(e.target.value)}
onFocus={() => {
textareaRef.current?.focus()
setFocus(true)
}}
onBlur={() => setFocus(false)}
onKeyDown={(e) => {
if(e.key === "Enter") {
const input = inputs.trim();
textareaRef.current?.focus();
if(!input.length) {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
]])
return;
}
const [command, ...args] = input.split(' ');
if(allCommands[command]) {
const result = allCommands[command](...args)
if(typeof result === "string") {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
{
prompt: prompt,
command: input,
indent: promptWidth,
result: result
},
]])
setInputs("")
}
} else {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
{
prompt: prompt,
command: input,
indent: promptWidth,
result: `${command}: command not found`
},
]])
setInputs("");
}
}
}}
style={{
textIndent: promptWidth,
height: cols * 25
}}
cols={cols}
ref={textareaRef}
/>
<InputedText
promptWidth={promptWidth}
ref={inputTextRef}
inputs={inputs}
focus={focus}
handler={() => textareaRef.current?.focus()}
/>
</div>
</div>
</div>
)
}
const BashTerminal = () => {
return (
<TerminalUI />
)
};
export default BashTerminal;
解説
- デフォルトのコマンドは
clear、echoの2つ - echo ~と入力したら、echoの後が出力
- clearと入力したら、入力履歴を消去
- Enterキーを押したときは改行される
- promptから好みのプロンプトを渡してください
Histories
- propsから渡されたhistoriesをmapで表示する。
- テキストフィールドより上部に配置することで入力履歴が増えていってるように見える
export const Histories = ({ histories }: HistoriesProps) => (
<>{histories.map((history, i) => (
<History history={history} key={i} />
))}</>
)
export const History = ({ history }: HistoryProps) => (
<div
className={cn(
'w-full h-6 shrink-0 relative break-all py-0.5'
)}
>
{history.result ? (
<p>{history.result}</p>
) : (
<>
<div className='absolute left-0 top-0'>{history.prompt}</div>
<p
style={{
textIndent: history.indent
}}
>
{history.command}
</p>
</>
)}
</div>
)
InputedText
- 入力したテキストを表示する。
- 文字の右側に
<Cursor/>を配置することで疑似的にターミナルのカーソルを表示している - text-indentを指定しpromptの文字数と同じ幅の空白を設けている
- useEffect実行時、promptRefのclientWidthを取得してstateに保存される。そのstateをpromptWidthとして渡すことでpromptの幅分text-indentを設けることができる。
- onClickにはテキストエリアにfocusを当てる関数を渡す
export const Cursor = () => (
<span
className='w-2 h-4 inline-block bg-gray-50'
/>
)
export const InputedText = forwardRef<HTMLDivElement, InputedTextProps>(({ promptWidth, inputs, focus, handler }, ref) => (
<div
className='max-w-full break-all absolute top-0 text-gray-50 py-0.5'
ref={ref}
style={{
textIndent: promptWidth
}}
onClick={() => handler()}
>
<>
{inputs}
{focus && (
<Cursor />
)}
</>
</div>
));
// 今回のコードではhanderはこれ
handler={() => textareaRef.current?.focus()}
Textarea
- textareaに入力したvalueをstateに保存してInvisibleTextに表示する。
- refを渡すことでtextareaを操作・入力値が取得できるようにする
- onChangeでsetInputsでtextareaで入力したvalueを保存。この際valueを渡さないとエラーが発生する
- onKeyDownで押したキーがEnterなら処理を実行
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>((props, ref) => (
<textarea
{...props}
ref={ref}
/>
));
// 以下抜粋
const [inputs, setInputs] = useState<string>("");
const [promptWidth, setPromptWidth] = useState<number>(0);
const [focus, setFocus] = useState<boolean>(false);
const [cols, setCols] = useState<number>(0);
const [histories, setHistories] = useState<TerminalResult[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptRef = useRef<HTMLDivElement>(null);
const inputTextRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
<Textarea
className={cn(
"sr-only bg-transparent text-transparent caret-transparent border-none outline-none resize-none"
)}
value={inputs}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInputs(e.target.value)}
// 下の二つもしかしたらいらないかも
onFocus={() => {
textareaRef.current?.focus()
setFocus(true)
}}
onBlur={() => setFocus(false)}
onKeyDown={(e) => {
if(e.key === "Enter") {
const input = inputs.trim();
textareaRef.current?.focus();
if(!input.length) {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
]])
return;
}
const [command, ...args] = input.split(' ');
if(allCommands[command]) {
const result = allCommands[command](...args)
if(typeof result === "string") {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
{
prompt: prompt,
command: input,
indent: promptWidth,
result: result
},
]])
setInputs("")
}
} else {
setHistories([...histories, ...[
{
prompt: prompt,
command: input,
indent: promptWidth,
},
{
prompt: prompt,
command: input,
indent: promptWidth,
result: `${command}: command not found`
},
]])
setInputs("");
}
}
}}
style={{
textIndent: promptWidth,
height: cols * 25
}}
cols={cols}
ref={textareaRef}
/>
結果

最後に
間違っていること・もっとこうした方がコードが読みやすくなるなどあればコメントに書いていただけると幸いです。
よろしくお願いいたします。
Discussion