スプレッドシート風な操作ができる画面をNext.jsで実装してみる

## はじめに
スプレッドシートからコピー＆ペーストができたり、逆にスプレッドシートに対してコピーで値を持っていける画面をNext.jsで実装してみようと思ったので、Cursor(GPT-4o)を使いながら作ってみました。
※フロントエンドは普段あまり触っていないので、至らないところがあると思いますがご容赦ください。

実際に実装したソースコードは以下からご確認ください。

https://github.com/t-ujiie-g/table-like-spreadsheet

## 実装する内容
今回は以下の仕様で実装してみています。

+ スプレッドシートからコピー＆ペーストができる
+ スプレッドシートにコピー＆ペーストができる
+ コピーするために選択範囲をCommand(WindowsはCtrl), Shift, 矢印キーで操作できる
    + Command + 矢印は今のセルから端のセルに移動する
    + Shift + 矢印は今のセルから矢印方向のセルに選択範囲を拡張する
    + Command + Shift + 矢印は上記2つを組み合わせたもの。端のセルまで全てが選択される
+ Enterで選択セルを編集状態にできる
+ 編集状態のセルからEnterを押すと1つ下のセルが選択状態になる

## コードの内容
コードの全体はGitHubからご確認ください。いくつか簡単にコードの内容を説明します。

### スプレッドシートからコピーする
```typescript
  const isValidDate = (dateString: string): boolean => {
    const date = new Date(dateString);
    return !isNaN(date.getTime());
  };
  
  const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
    e.preventDefault();
    const clipboardData = e.clipboardData.getData('Text');
    const rowsData = clipboardData.split('\n').map(row => row.split('\t'));
  
    const newRows = [...rows];
    rowsData.forEach((rowData, i) => {
      const targetRowIndex = selectedCell.row + i;
      if (targetRowIndex < newRows.length) {
        rowData.forEach((cellData, j) => {
          const targetColumnIndex = selectedCell.column + j;
          if (targetColumnIndex === 0) { // 日付列
            if (isValidDate(cellData)) {
              newRows[targetRowIndex].date = cellData;
            } else {
              newRows[targetRowIndex].date = newRows[targetRowIndex].date;
            }
          } else if (targetColumnIndex === 1) { // テキスト列
            newRows[targetRowIndex].title = cellData;
          } else if (targetColumnIndex === 2) { // 数値列
            newRows[targetRowIndex].count = parseInt(cellData, 10) || newRows[targetRowIndex].count;
          }
        });
      }
    });
    setRows(newRows);
  };
```

スプレッドシートやExcelからコピーするとタブ区切りでデータを取得できるので、その前提でデータを扱っています。
今回はテスト的に日付型、テキスト型、数値型の３列で実装しているので、日付型と数値列はバリデーションチェックを実施しています。

### スプレッドシートにコピーする
```typescript
  const handleCopy = (e: ClipboardEvent) => {
    e.preventDefault();
    const { start, end } = selectionRange;
    const copiedData = rows.slice(start.row, end.row + 1).map(row => {
      const rowData = [];
      for (let i = start.column; i <= end.column; i++) {
        if (i === 0) rowData.push(row.date);
        if (i === 1) rowData.push(row.title);
        if (i === 2) rowData.push(row.count.toString());
      }
      return rowData.join('\t');
    }).join('\n');
    if (e.clipboardData) {
      e.clipboardData.setData('text/plain', copiedData);
    }
  };
```

同じくタブ区切りでデータをコピーしてクリップボードに持たせておくと、スプレッドシートに対して貼り付けた際セルごとに値を貼り付けてくれるので、タブ区切りでクリップボードにデータを持たせています。

### キーボード操作でセル選択をする
以下のあたりを実装したものです。
>+ コピーするために選択範囲をCommand(WindowsはCtrl), Shift, 矢印キーで操作できる
>    + Command + 矢印は今のセルから端のセルに移動する
>    + Shift + 矢印は今のセルから矢印方向のセルに選択範囲を拡張する
>    + Command + Shift + 矢印は上記2つを組み合わせたもの。端のセルまで全てが選択される
>+ Enterで選択セルを編集状態にできる
>+ 編集状態のセルからEnterを押すと1つ下のセルが選択状態になる

```typescript
  const handleKeyDown = (e: KeyboardEvent) => {
    if (editingCell) {
      // 編集状態の場合、矢印キーの動作を変更しない
      if (e.key === 'Enter') {
        const { row, column } = editingCell;
        setEditingCell(null);
  
        // 1つ下のセルを選択
        const newRow = Math.min(row + 1, rows.length - 1);
        setSelectedCell({ row: newRow, column });
        setSelectionRange({ start: { row: newRow, column }, end: { row: newRow, column } });
      }
      return;
    }
  
    if (e.shiftKey) {
      const { row, column } = lastSelectedCell;
      let newRow = row;
      let newColumn = column;
    
      if (e.metaKey || e.ctrlKey) {
        e.preventDefault(); // デフォルトのブラウザ動作を防ぐ
        if (e.key === 'ArrowUp') {
          newRow = 0;
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: newRow, column: selectionRange.start.column }, end: { row: selectionRange.end.row, column: selectionRange.end.column } });
        }
        if (e.key === 'ArrowDown') {
          newRow = rows.length - 1;
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: selectionRange.start.row, column: selectionRange.start.column }, end: { row: newRow, column: selectionRange.end.column } });
        }
        if (e.key === 'ArrowLeft') {
          newColumn = 0;
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: selectionRange.start.row, column: newColumn }, end: { row: selectionRange.end.row, column: selectionRange.end.column } });
        }
        if (e.key === 'ArrowRight') {
          newColumn = 2;
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: selectionRange.start.row, column: selectionRange.start.column }, end: { row: selectionRange.end.row, column: newColumn } });
        }
      } else {
        const { row: startRow, column: startColumn } = selectionRange.start;
        const { row: endRow, column: endColumn} = selectionRange.end;
        let newRowStart = newRow;
        let newRowEnd = newRow;
        let newColumnStart = newColumn;
        let newColumnEnd = newColumn;
        if (e.key === 'ArrowUp') {
          newRow = Math.max(row - 1, 0);
          if (lastSelectedCell.row > startRow) {
            newRowStart = startRow;
            newRowEnd = Math.min(newRow, endRow);
          } else {
            newRowStart = Math.min(newRow, startRow);
            newRowEnd = Math.max(newRow, endRow);
          }
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: newRowStart, column: startColumn }, end: { row: newRowEnd, column: endColumn } });
          console.log({ start: { row: newRowStart, column: newColumn }, end: { row: newRowEnd, column: endColumn } });
        }
        if (e.key === 'ArrowDown') {
          newRow = Math.min(row + 1, rows.length - 1);
          newRowEnd = Math.max(newRow, endRow);
          if (lastSelectedCell.row < endRow) {
            newRowStart = Math.max(newRow, startRow);
          } else {
            newRowStart = Math.min(newRow, startRow);
          }
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: newRowStart, column: startColumn }, end: { row: newRowEnd, column: endColumn } });
        }
        if (e.key === 'ArrowLeft') {
          newColumn = Math.max(column - 1, 0);
          if (lastSelectedCell.column > startColumn) {
            newColumnStart = startColumn;
            newColumnEnd = Math.min(newColumn, endColumn);
          } else {
            newColumnStart = Math.min(newColumn, startColumn);
            newColumnEnd = Math.max(newColumn, endColumn);
          }
          setLastSelectedCell({ row: newRow, column: newColumn });
          setSelectionRange({ start: { row: startRow, column: newColumnStart }, end: { row: endRow, column: newColumnEnd } });
        }
        if (e.key === 'ArrowRight') {
            newColumn = Math.min(column + 1, 2);
            newColumnEnd = Math.max(newColumn, endColumn);
            if (lastSelectedCell.column < endColumn) {
              newColumnStart = Math.max(newColumn, startColumn);
            } else {
              newColumnStart = Math.min(newColumn, startColumn);
            }
            setLastSelectedCell({ row: newRow, column: newColumn });
            setSelectionRange({ start: { row: startRow, column: newColumnStart }, end: { row: endRow, column: newColumnEnd } });
        }
      }
    } else if (!e.metaKey && !e.ctrlKey) {
      const { row, column } = selectedCell;
      let newRow = row;
      let newColumn = column;
  
      if (e.key === 'ArrowUp') newRow = Math.max(row - 1, 0);
      if (e.key === 'ArrowDown') newRow = Math.min(row + 1, rows.length - 1);
      if (e.key === 'ArrowLeft') newColumn = Math.max(column - 1, 0);
      if (e.key === 'ArrowRight') newColumn = Math.min(column + 1, 2);
  
      // 選択が変更されたときに編集状態を解除
      if (newRow !== row || newColumn !== column) {
        setEditingCell(null);
      }
  
      setSelectedCell({ row: newRow, column: newColumn });
      setLastSelectedCell({ row: newRow, column: newColumn });
      setSelectionRange({ start: { row: newRow, column: newColumn }, end: { row: newRow, column: newColumn } });
    } else if (e.metaKey || e.ctrlKey) {
      if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
        e.preventDefault(); // デフォルトのブラウザ動作を防ぐ
        const { row, column } = selectedCell;
        let newRow = row;
        let newColumn = column;
  
        if (e.key === 'ArrowUp') newRow = 0;
        if (e.key === 'ArrowDown') newRow = rows.length - 1;
        if (e.key === 'ArrowLeft') newColumn = 0;
        if (e.key === 'ArrowRight') newColumn = 2;
  
        setSelectedCell({ row: newRow, column: newColumn });
        setLastSelectedCell({ row: newRow, column: newColumn });
        setSelectionRange({ start: { row: newRow, column: newColumn }, end: { row: newRow, column: newColumn } });
      }
    }
  
    if (e.key === 'Enter') {
      setEditingCell(selectedCell);
    }
  };
```

少し冗長なコードになってしまったのと、３列前提として実装してしまったのは反省です。気分が向いたらリファクタリングしてみようと思います。

## まとめ
Cursorで先日リリースされたGPT-4oを使用しながら実装してみました。フロントエンドは素人ですが、思ったより短時間で作れたので良かったです。