😄

ReactでBashターミナル風UIを作って遊ぶ

に公開

ReactでBashターミナル風UIを作って遊ぶ

Reactの基本的なhooksを使ってBashターミナル風UIを作って遊びます。Todoリストに飽きた方におすすめです

注意事項

  1. あくまでターミナル風のUIです
  2. クライアント側で実行してください
  3. デザインパターンや適切なコンポーネントの分類は現在修行中のため、お許しください

コード

先に成果物ですが、こんな感じのコードになります。

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;

解説

  1. デフォルトのコマンドはclearechoの2つ
  2. echo ~と入力したら、echoの後が出力
  3. clearと入力したら、入力履歴を消去
  4. Enterキーを押したときは改行される
  5. promptから好みのプロンプトを渡してください

Histories

  1. propsから渡されたhistoriesをmapで表示する。
  2. テキストフィールドより上部に配置することで入力履歴が増えていってるように見える
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

  1. 入力したテキストを表示する。
  2. 文字の右側に<Cursor/>を配置することで疑似的にターミナルのカーソルを表示している
  3. text-indentを指定しpromptの文字数と同じ幅の空白を設けている
  4. useEffect実行時、promptRefのclientWidthを取得してstateに保存される。そのstateをpromptWidthとして渡すことでpromptの幅分text-indentを設けることができる。
  5. 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

  1. textareaに入力したvalueをstateに保存してInvisibleTextに表示する。
  2. refを渡すことでtextareaを操作・入力値が取得できるようにする
  3. onChangeでsetInputsでtextareaで入力したvalueを保存。この際valueを渡さないとエラーが発生する
  4. 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}
  />

結果

terminal.gif

最後に

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

GitHubで編集を提案

Discussion