見出し画像

はじめてのJavaScript #34|メモ帳アプリを作ろう ── 自動保存機能を実装する

今回のゴール

入力するたびに自動でLocalStorageに保存されるメモ帳アプリを完成させる。


今回作るものの仕様

  • 複数のメモをタブで切り替えられる

  • メモを入力すると自動でLocalStorageに保存される

  • 最後に編集したメモを記憶して次回開いたとき同じタブを表示する

  • メモのタイトルを編集できる

  • メモを削除できる

  • 新しいメモを追加できる

自動保存の仕組み

前回のToDoリストはボタンを押したタイミングで保存していました。今回は入力するたびに自動で保存します。

input イベントを使うと、テキストエリアの内容が変わるたびに処理を実行できます。

js

textarea.addEventListener("input", function() {
  // 文字を入力するたびに呼ばれる
  saveData();
});

ただしこのままでは1文字入力するたびに保存処理が走り、パフォーマンスが下がる可能性があります。そこでデバウンスという仕組みを使います。

デバウンスってなに?

デバウンスとは「一定時間操作がなかったら処理を実行する」という仕組みです。

入力 → 入力 → 入力 → 0.5秒待つ → 保存

こうすることで、入力中は保存せず、入力が止まった0.5秒後に1回だけ保存するため無駄な処理を減らせます。

js

let saveTimer = null;

textarea.addEventListener("input", function() {
  clearTimeout(saveTimer); // 前のタイマーをリセット
  saveTimer = setTimeout(function() {
    saveData(); // 0.5秒後に保存
  }, 500);
});

実際に書いてみよう

index.html

html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>メモ帳アプリ</title>
    <style>
      * { box-sizing: border-box; margin: 0; padding: 0; }
      body {
        font-family: sans-serif;
        height: 100vh;
        display: flex;
        flex-direction: column;
        background: #f5f5f0;
      }
      header {
        background: #2c2c2c;
        color: white;
        padding: 12px 20px;
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
      header h1 { font-size: 18px; }
      .btn-add {
        padding: 6px 14px;
        background: royalblue;
        color: white;
        border: none;
        border-radius: 6px;
        cursor: pointer;
        font-size: 14px;
      }
      .main {
        display: flex;
        flex: 1;
        overflow: hidden;
      }
      .sidebar {
        width: 200px;
        background: #3c3c3c;
        overflow-y: auto;
        flex-shrink: 0;
      }
      .memo-tab {
        padding: 12px 14px;
        color: #ccc;
        cursor: pointer;
        border-bottom: 0.5px solid #555;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 6px;
      }
      .memo-tab.active {
        background: #555;
        color: white;
      }
      .memo-tab-title {
        font-size: 13px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        flex: 1;
      }
      .btn-delete-memo {
        background: none;
        border: none;
        color: #e74c3c;
        cursor: pointer;
        font-size: 14px;
        flex-shrink: 0;
      }
      .editor {
        flex: 1;
        display: flex;
        flex-direction: column;
        padding: 20px;
      }
      .editor-header {
        display: flex;
        align-items: center;
        gap: 10px;
        margin-bottom: 12px;
      }
      .title-input {
        flex: 1;
        font-size: 20px;
        font-weight: bold;
        border: none;
        border-bottom: 2px solid #ddd;
        background: transparent;
        padding: 4px 0;
        outline: none;
      }
      .title-input:focus { border-bottom-color: royalblue; }
      .save-status {
        font-size: 12px;
        color: #aaa;
      }
      textarea {
        flex: 1;
        font-size: 15px;
        line-height: 1.7;
        border: none;
        background: white;
        border-radius: 8px;
        padding: 16px;
        resize: none;
        outline: none;
        box-shadow: 0 1px 4px rgba(0,0,0,0.08);
      }
      .empty-state {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #aaa;
        font-size: 15px;
      }
    </style>
  </head>
  <body>
    <header>
      <h1>メモ帳</h1>
      <button class="btn-add" id="btnAdd">+ 新しいメモ</button>
    </header>

    <div class="main">
      <div class="sidebar" id="sidebar"></div>
      <div class="editor" id="editor"></div>
    </div>

    <script src="script.js"></script>
  </body>
</html>

script.js

js

const sidebar  = document.getElementById("sidebar");
const editor   = document.getElementById("editor");
const btnAdd   = document.getElementById("btnAdd");

// ============================================
// LocalStorage の読み書き
// ============================================
function loadMemos() {
  const saved = localStorage.getItem("memos");
  return saved ? JSON.parse(saved) : [];
}

function saveMemos(memos) {
  localStorage.setItem("memos", JSON.stringify(memos));
}

function loadCurrentId() {
  return localStorage.getItem("currentMemoId");
}

function saveCurrentId(id) {
  localStorage.setItem("currentMemoId", String(id));
}

// ============================================
// サイドバーを描画する
// ============================================
function renderSidebar(memos, currentId) {
  sidebar.innerHTML = "";

  memos.forEach(function(memo) {
    const tab = document.createElement("div");
    tab.className = "memo-tab" + (String(memo.id) === currentId ? " active" : "");

    const title       = document.createElement("span");
    title.className   = "memo-tab-title";
    title.textContent = memo.title || "無題のメモ";

    const btnDelete       = document.createElement("button");
    btnDelete.className   = "btn-delete-memo";
    btnDelete.textContent = "✕";
    btnDelete.addEventListener("click", function(e) {
      e.stopPropagation(); // タブのクリックイベントを止める
      deleteMemo(memo.id);
    });

    tab.appendChild(title);
    tab.appendChild(btnDelete);
    tab.addEventListener("click", function() {
      saveCurrentId(memo.id);
      render();
    });

    sidebar.appendChild(tab);
  });
}

// ============================================
// エディタを描画する
// ============================================
function renderEditor(memos, currentId) {
  editor.innerHTML = "";

  const memo = memos.find(function(m) { return String(m.id) === currentId; });

  if (!memo) {
    const empty       = document.createElement("div");
    empty.className   = "empty-state";
    empty.textContent = "「+ 新しいメモ」からメモを追加してください";
    editor.appendChild(empty);
    return;
  }

  // エディタのヘッダー
  const editorHeader = document.createElement("div");
  editorHeader.className = "editor-header";

  const titleInput       = document.createElement("input");
  titleInput.className   = "title-input";
  titleInput.type        = "text";
  titleInput.placeholder = "タイトルを入力";
  titleInput.value       = memo.title;

  const saveStatus       = document.createElement("span");
  saveStatus.className   = "save-status";
  saveStatus.textContent = "保存済み";

  editorHeader.appendChild(titleInput);
  editorHeader.appendChild(saveStatus);

  // テキストエリア
  const textarea       = document.createElement("textarea");
  textarea.placeholder = "メモを入力してください...";
  textarea.value       = memo.body;

  editor.appendChild(editorHeader);
  editor.appendChild(textarea);

  // ============================================
  // 自動保存(デバウンス)
  // ============================================
  let saveTimer = null;

  function autoSave() {
    saveStatus.textContent = "保存中...";
    saveStatus.style.color = "royalblue";

    clearTimeout(saveTimer);
    saveTimer = setTimeout(function() {
      const memos  = loadMemos();
      const target = memos.find(function(m) { return String(m.id) === currentId; });

      if (target) {
        target.title    = titleInput.value;
        target.body     = textarea.value;
        target.updatedAt = Date.now();
        saveMemos(memos);
      }

      saveStatus.textContent = "保存済み";
      saveStatus.style.color = "#aaa";
      renderSidebar(memos, currentId);
    }, 500);
  }

  titleInput.addEventListener("input", autoSave);
  textarea.addEventListener("input", autoSave);

  // テキストエリアにフォーカスを当てる
  textarea.focus();
}

// ============================================
// 全体を再描画する
// ============================================
function render() {
  const memos     = loadMemos();
  const currentId = loadCurrentId();
  renderSidebar(memos, currentId);
  renderEditor(memos, currentId);
}

// ============================================
// メモを追加する
// ============================================
btnAdd.addEventListener("click", function() {
  const memos  = loadMemos();
  const newMemo = {
    id:        Date.now(),
    title:     "",
    body:      "",
    updatedAt: Date.now()
  };

  memos.push(newMemo);
  saveMemos(memos);
  saveCurrentId(newMemo.id);
  render();
});

// ============================================
// メモを削除する
// ============================================
function deleteMemo(id) {
  if (!confirm("このメモを削除しますか?")) return;

  let memos      = loadMemos();
  memos          = memos.filter(function(m) { return m.id !== id; });
  saveMemos(memos);

  // 削除したメモが選択中だった場合、別のメモに切り替える
  const currentId = loadCurrentId();
  if (String(id) === currentId) {
    saveCurrentId(memos.length > 0 ? memos[0].id : "");
  }

  render();
}

// 初期描画
render();

コードのポイント解説

デバウンスで保存タイミングを制御する

js

let saveTimer = null;

function autoSave() {
  saveStatus.textContent = "保存中...";
  clearTimeout(saveTimer);
  saveTimer = setTimeout(function() {
    // 0.5秒後に保存
  }, 500);
}

clearTimeout() で前のタイマーをリセットしてから新しいタイマーをセットすることで、入力が続く間は保存を先送りにします。入力が止まった0.5秒後に1回だけ保存が走ります。

e.stopPropagation() でイベントの伝播を止める

削除ボタンはタブの中にあるため、ボタンをクリックするとタブのクリックイベントも同時に発生してしまいます。e.stopPropagation() で親要素へのイベントの伝播を止めています。

js

btnDelete.addEventListener("click", function(e) {
  e.stopPropagation(); // タブのクリックイベントを止める
  deleteMemo(memo.id);
});

currentMemoId で最後に開いたメモを記憶する

選択中のメモのIDを別のキーで保存しておくことで、ページをリロードしても最後に開いていたメモが自動で表示されます。

まとめ

  • input イベントとデバウンスを組み合わせると、入力が止まったタイミングで自動保存できる

  • e.stopPropagation() で親要素へのイベントの伝播を止められる

  • 選択状態もLocalStorageに保存しておくことでページをリロードしても状態が維持される

次回予告

次回はToDoリストやメモ帳のデータを並び替えたり検索したりする機能を追加します。LocalStorage編のまとめとなる回です。

→ #35|データを整理しよう ── 並び替えと検索機能を追加する


いいなと思ったら応援しよう!

DOI@日々学びの本質を考え続ける探求者 よろしければ応援お願いします! いただいたチップは引き続きプログラミングや学びについて、皆さんの利益になるようなよい記事を書くことで恩返しをさせていただきます!