キーボードによるテキストエリアのカーソル移動 - Javascript

キーボードによるテキストエリアのカーソル移動

テキストエリアに文字を入力し、vi エディタなどのようにキーボードの「 h, j, k, l 」のキーでカーソルの移動をするスクリプトです。

Altキーと「 h, j, k, l 」で上下左右に移動することができます。

h 上に移動
j 下に移動
k 上に移動
l 右に移動

コード

index.html

<h1>Editor <img id="question" class="question" src="/images/question.svg" alt=""></h1>
<div id="editor_box">
  <textarea name="" id="txt" rows="10" cols="100"></textarea>
</div>
<div id="modal_wrapper">
  <div id="explain">
    <h1>使い方</h1>
    <ul>
      <li>Alt + H: 左に移動</li>
      <li>Alt + J:下に移動</li>
      <li>Alt + k:上に移動</li>
      <li>Alt + l:右に移動</li>
    </ul>
  </div>
</div>

editor.js

window.addEventListener('load', () => {
  const txt = document.getElementById('txt');

  // キャレットがある行番号を返す関数
  // 行番号の最初は、「 0 」からスタート
  function getLineNumber(txtarea){
    let value = txtarea.value;
    let cursorPosition = txtarea.selectionStart;
    let lines = value.substr(0, cursorPosition).split("\n");
    let lineNumber = lines.length;
    return lineNumber - 1;
  }

  // 各行の改行コードも含めた文字数を配列に格納する関数
  function linesLengthMap(lines){
    let lines_length = [];
    for(let i = 0; i < lines.length; i++){
      let count = 0;
      count += lines[i].length;
      
      if(i < lines.length - 1){
        count += 1;
      }
      lines_length.push(count);
    }
    return lines_length;
  }

  // キャレットがある行とその前後の行の文字数を取得する関数
  function getLinesLength(line_number, lines_length){
    let previous_lines_total = 0;
    let current_lines_total = 0;
    let next_lines_total = 0;

    for(let i = 0; i <= line_number + 1; i++){
      // キャレットがある前の行の末尾までの改行も含めた文字数
      if(i <= line_number - 1){ previous_lines_total += lines_length[i] }
      
      // キャレットがある行の末尾までの改行も含めた文字数
      if(i <= line_number){ current_lines_total += lines_length[i] }

      // キャレットがある次行の末尾までの改行も含めた文字数
      if(i <= line_number + 1){ next_lines_total += lines_length[i] }
    }

    return [previous_lines_total, current_lines_total, next_lines_total]
  }

  txt.addEventListener('keydown', (e) => {
    let caret_position, line_number, lines, lines_length;
    if(e.altKey){
      
      switch(e.key){
        case "h":
          caret_position = txt.selectionStart;
          // カーソルを左に移動
          if (caret_position > 0) { // カーソルが最初の位置でない場合のみ移動
            txt.selectionStart = caret_position - 1;
            txt.selectionEnd = caret_position - 1;
          }
          break;
        case "l":
          caret_position = txt.selectionStart;
          // カーソルを右に移動
          if (caret_position >= 0) {
              txt.selectionStart = caret_position + 1;
              txt.selectionEnd = caret_position + 1;
          }
          break;
        case "j":
          caret_position = txt.selectionStart;
          line_number = getLineNumber(txt);
          lines = txt.value.split('\n');
          lines_length = linesLengthMap(lines);
          let next_lines_length = lines_length[line_number + 1];
          
          if(lines.length == 1 || next_lines_length == undefined){
            return;
          }

          if(lines.length >= 2){
            let [previous_lines_total, current_lines_total, next_lines_total] = getLinesLength(line_number, lines_length);

            // キャレットがある行の行頭からキャレットのある位置までの番号
            let current_lines_position = caret_position - previous_lines_total;

            // 最後の行かつ、現在のキャレットの位置番号が最後の行の文字数以下の場合
            if(lines_length[line_number + 2] == undefined && next_lines_length <= current_lines_position){
              txt.selectionStart = next_lines_total;
              txt.selectionEnd = next_lines_total;
              return;
            }
            
            // 最後の行かつ、現在のキャレットの位置番号が最後の行の文字数より多い場合
            if(lines_length[line_number + 2] == undefined && next_lines_length > current_lines_position){
              txt.selectionStart = current_lines_total + current_lines_position;
              txt.selectionEnd = current_lines_total + current_lines_position;
              return;
            }

            // 次の行が改行コードのみ
            if(lines[line_number + 1] == ''){
              txt.selectionStart = current_lines_total;
              txt.selectionEnd = current_lines_total;
              return;
            }

            if(next_lines_length > current_lines_position){
              txt.selectionStart = current_lines_total + current_lines_position;
              txt.selectionEnd = current_lines_total + current_lines_position;
              return;
            }

            if(next_lines_length <= current_lines_position){
              // キャレットの位置が次の行の文字数以下の場合、次の行の最後にキャレットを移動
              txt.selectionStart = next_lines_total - 1;
              txt.selectionEnd = next_lines_total - 1;
              return;
            }
          }
          break;
        case "k":
          line_number = getLineNumber(txt); // キャレットがある行番号を取得
          caret_position = txt.selectionStart; // キャレットの位置番号を取得
          lines = txt.value.split('\n'); // textarea の文字を改行コードで分割し、配列に格納
          lines_length = linesLengthMap(lines); // 文字を配列に分割したそれぞれの文字数を配列に格納
          let previous_lines_length = lines_length[line_number - 1]; // キャレットのある位置の前の行の文字数

          if(lines.length == 1 || previous_lines_length == undefined){
            return;
          }

          if(lines.length >= 2){
            let [previous_lines_total, current_lines_total, next_lines_total] = getLinesLength(line_number, lines_length);

            let current_lines_position = caret_position - previous_lines_total;

            if(previous_lines_length > current_lines_position){
              console.log(previous_lines_total - 1)
              txt.selectionStart = previous_lines_total - previous_lines_length + current_lines_position;
              txt.selectionEnd = previous_lines_total - previous_lines_length + current_lines_position;
            }

            if(previous_lines_length <= current_lines_position){
              txt.selectionStart = previous_lines_total - 1;
              txt.selectionEnd = previous_lines_total - 1;
            }
          }
          break;
      }
      e.preventDefault();
    }
  });


  const question = document.getElementById('question');
  const modal_wrapper = document.getElementById('modal_wrapper');
  const explain = document.getElementById('explain');

  question.addEventListener('click', function() {
    modal_wrapper.classList.add('show');
  });

  modal_wrapper.addEventListener('click', function() {
    if(modal_wrapper.classList.contains('show')){
      modal_wrapper.classList.remove('show');
    }
  });

  explain.addEventListener('click', function(event){
    event.stopPropagation();
  })
})

style.css

.question {
  width: 20px;
}
#modal_wrapper {
  width: 100%;
  height: 100%;
  position: fixed;
  top: 0;
  left: 0;
  background: rgba(0,0,0,0.5);
  pointer-events: none;
  opacity: 0;
  transition: 0.25s ease-out;
}
#modal_wrapper.show {
  opacity: 1;
  pointer-events: all;
}
#explain {
  position: absolute;
  width: 80%;
  height: 80%;
  padding: 10px 20px;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  object-fit: cover;
  transition: 0.5s ease-out;
  background: white;
  overflow-y: scroll;
}
textarea {
  padding: 8px 12px;
  font-size: 24px;
}

キャレットの位置がある行の番号を取得する関数

「 editor.js 」のコードを以外に、行の番号を取得するための関数で別途作ったものが以下のものです。

function getLineNumber(textarea){
  let text = textarea.value;
  let position = textarea.selectionStart;
  let lines = text.split('\n');
  let lineIndex = 0;
  let charIndex = position;

  for(let i = 0; 1 < lines.length; i++){
    if(charIndex <= lines[i].length){
      lineIndex = i;
      break;
    }

    charIndex -= (lines[i].length+1);
  }
  return lineIndex;
}

計算機能も付与したエディタ