Jump to content
Main menu
Main menu
move to sidebar
hide
Navigation
Main page
Recent changes
Random page
freem
Search
Search
Appearance
Create account
Log in
Personal tools
Create account
Log in
Pages for logged out editors
learn more
Contributions
Talk
Editing
Openai/6926aad9-faf0-8007-bcb8-9e64cd1f86b1
Add languages
Page
Discussion
English
Read
Edit
Edit source
View history
Tools
Tools
move to sidebar
hide
Actions
Read
Edit
Edit source
View history
General
What links here
Related changes
Special pages
Page information
Appearance
move to sidebar
hide
Warning:
You are not logged in. Your IP address will be publicly visible if you make any edits. If you
log in
or
create an account
, your edits will be attributed to your username, along with other benefits.
Anti-spam check. Do
not
fill this in!
{{DISPLAYTITLE:フロントエンドメモアプリ}} ''This conversation was exported from ChatGPT''<ref>{{cite web|title=フロントエンドメモアプリ|url=https://chatgpt.com/share/6926aad9-faf0-8007-bcb8-9e64cd1f86b1|publisher=ChatGPT|access-date=2025-11-27}}</ref> == gpt-5-1-pro == === User: フロントエンド完結のメモアプリをつくりたい === フロントエンド完結のメモアプリをつくりたい データはブラウザのストレージに保存する メモは複数個作成でき、トップ画面は2ペインで、左はメモ一覧表示とCRUDをサポート、右がメモ編集画面 メモは自動保存する メモの名前は先頭から流用し、内部的には機械的なIDでkvで保存すればいい === Assistant: じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 === じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 index.htmlとして保存 → ブラウザで開くだけで動きます。 * 完全フロントエンド * localStorageに保存 * 左ペイン:メモ一覧 & CRUD * 右ペイン:メモ編集 * 入力と同時に自動保存(少しデバウンス) * メモ名 = 先頭行を流用(空なら「無題メモ」) * 内部的には機械的IDでKV保存 ==== <syntaxhighlight lang="html"><!DOCTYPE html> ==== <html lang="ja"> <head> <meta charset="UTF-8" /> <title>ブラウザ完結メモアプリ</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> :root { --border-color: #ddd; --accent-color: #2563eb; --bg-sidebar: #f5f5f5; --bg-active: #e0edff; --text-muted: #666; --radius: 8px; --shadow: 0 8px 16px rgba(0,0,0,0.06); } * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f4f6; color: #111827; height: 100vh; display: flex; align-items: stretch; justify-content: center; } .app-shell { margin: 16px; background: white; border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex: 1; max-width: 1200px; min-height: 0; } .sidebar { width: 280px; min-width: 220px; max-width: 360px; border-right: 1px solid var(--border-color); background: var(--bg-sidebar); display: flex; flex-direction: column; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color); } .sidebar-title { font-size: 15px; font-weight: 600; } .new-btn { border: none; border-radius: 999px; padding: 6px 12px; font-size: 13px; cursor: pointer; background: var(--accent-color); color: white; display: inline-flex; align-items: center; gap: 4px; } .new-btn span { font-size: 16px; line-height: 1; transform: translateY(-1px); } .memo-list { flex: 1; overflow-y: auto; padding: 8px; } .memo-item { background: white; border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; transition: background 0.1s ease, border-color 0.1s ease, transform 0.05s ease; } .memo-item:hover { background: #eef2ff; transform: translateY(-1px); } .memo-item.active { background: var(--bg-active); border-color: var(--accent-color); } .memo-main { flex: 1; min-width: 0; } .memo-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .memo-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } .memo-delete { border: none; background: transparent; cursor: pointer; padding: 4px; border-radius: 999px; font-size: 14px; color: #9ca3af; flex-shrink: 0; } .memo-delete:hover { background: #fee2e2; color: #b91c1c; } .editor-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; } .editor-header { padding: 10px 16px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; gap: 8px; } .editor-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .editor-meta { font-size: 11px; color: var(--text-muted); } .editor-body { flex: 1; display: flex; flex-direction: column; min-height: 0; } .editor-textarea { flex: 1; padding: 12px 16px; border: none; resize: none; font-family: inherit; font-size: 14px; line-height: 1.6; outline: none; min-height: 0; } .editor-textarea::placeholder { color: #9ca3af; } .status-bar { font-size: 11px; color: var(--text-muted); padding: 4px 10px 8px 16px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } @media (max-width: 768px) { .app-shell { flex-direction: column; } .sidebar { width: 100%; max-width: none; border-right: none; border-bottom: 1px solid var(--border-color); } } </style> </head> <body> <div class="app-shell"> <!-- 左ペイン:メモ一覧 --> <aside class="sidebar"> <div class="sidebar-header"> <div class="sidebar-title">メモ一覧</div> <button class="new-btn" id="newMemoBtn"><span>+</span>新規</button> </div> <div class="memo-list" id="memoList"></div> </aside> <!-- 右ペイン:エディタ --> <main class="editor-pane"> <div class="editor-header"> <div class="editor-title" id="currentTitle">メモが選択されていません</div> <div class="editor-meta" id="currentMeta"></div> </div> <div class="editor-body"> <textarea id="editor" class="editor-textarea" placeholder="メモを書き始めてください…(自動保存されます)" ></textarea> <div class="status-bar"> <span id="statusText">準備完了</span> <span id="charCount">0 文字</span> </div> </div> </main> </div> <script> (function () { const STORAGE_KEY = "memoApp.v1"; /** * state: * memos: { [id]: { id, content, updatedAt } } * order: string[] (表示順) * currentId: string | null */ let state = { memos: {}, order: [], currentId: null, }; const memoListEl = document.getElementById("memoList"); const editorEl = document.getElementById("editor"); const newMemoBtn = document.getElementById("newMemoBtn"); const currentTitleEl = document.getElementById("currentTitle"); const currentMetaEl = document.getElementById("currentMeta"); const statusTextEl = document.getElementById("statusText"); const charCountEl = document.getElementById("charCount"); let saveTimer = null; let isLoading = true; // ---- ストレージ関連 ---- function loadFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return; state.memos = parsed.memos || {}; state.order = Array.isArray(parsed.order) ? parsed.order : []; state.currentId = parsed.currentId || null; } catch (e) { console.error("Failed to load memos:", e); } } function saveToStorage() { const payload = { memos: state.memos, order: state.order, currentId: state.currentId, }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { console.error("Failed to save memos:", e); setStatus("保存に失敗しました(容量オーバーかもしれません)"); } } // ---- ユーティリティ ---- function createId() { return ( "m_" + Date.now().toString(36) + "_" + Math.random().toString(16).slice(2) ); } function getTitleFromContent(content) { const firstLine = (content || "").split(/\r?\n/)[0].trim(); return firstLine || "無題メモ"; } function formatDate(ts) { const d = new Date(ts); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return <code>${yyyy}/${mm}/${dd} ${hh}:${mi}</code>; } function setStatus(text) { statusTextEl.textContent = text; } function updateCharCount() { charCountEl.textContent = <code>${editorEl.value.length} 文字</code>; } // ---- CRUD ---- function createMemo() { const id = createId(); const now = Date.now(); const memo = { id, content: "", updatedAt: now, }; state.memos[id] = memo; state.order.unshift(id); // 新しいメモを先頭に state.currentId = id; saveToStorage(); renderList(); renderCurrentMemo(); editorEl.focus(); } function deleteMemo(id) { if (!state.memos[id]) return; const ok = confirm("このメモを削除しますか?"); if (!ok) return; delete state.memos[id]; state.order = state.order.filter((mId) => mId !== id); if (state.currentId === id) { state.currentId = state.order[0] || null; } if (!state.currentId && state.order.length === 0) { // メモが一つもない場合は空のメモを1つ作る createMemo(); return; } saveToStorage(); renderList(); renderCurrentMemo(); } function selectMemo(id) { if (!state.memos[id]) return; state.currentId = id; saveToStorage(); renderList(); renderCurrentMemo(); } function updateCurrentMemoContent(content) { const id = state.currentId; if (!id) return; const memo = state.memos[id]; if (!memo) return; memo.content = content; memo.updatedAt = Date.now(); // 表示順を前に持ってくる(最近編集したものを上にしたい場合) const idx = state.order.indexOf(id); if (idx > 0) { state.order.splice(idx, 1); state.order.unshift(id); } // 自動保存(デバウンス) if (saveTimer) clearTimeout(saveTimer); setStatus("保存中…"); saveTimer = setTimeout(() => { saveToStorage(); renderList(); renderCurrentMetaOnly(); setStatus("保存しました"); }, 500); } // ---- 描画 ---- function renderList() { memoListEl.innerHTML = ""; if (state.order.length === 0) { const emptyEl = document.createElement("div"); emptyEl.style.padding = "12px"; emptyEl.style.fontSize = "13px"; emptyEl.style.color = "#9ca3af"; emptyEl.textContent = "メモがありません。「新規」ボタンから作成できます。"; memoListEl.appendChild(emptyEl); return; } state.order.forEach((id) => { const memo = state.memos[id]; if (!memo) return; const itemEl = document.createElement("div"); itemEl.className = "memo-item" + (id === state.currentId ? " active" : ""); itemEl.addEventListener("click", () => selectMemo(id)); const mainEl = document.createElement("div"); mainEl.className = "memo-main"; const titleEl = document.createElement("div"); titleEl.className = "memo-title"; titleEl.textContent = getTitleFromContent(memo.content); const metaEl = document.createElement("div"); metaEl.className = "memo-meta"; metaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; mainEl.appendChild(titleEl); mainEl.appendChild(metaEl); const deleteBtn = document.createElement("button"); deleteBtn.className = "memo-delete"; deleteBtn.innerHTML = "✕"; deleteBtn.title = "削除"; deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteMemo(id); }); itemEl.appendChild(mainEl); itemEl.appendChild(deleteBtn); memoListEl.appendChild(itemEl); }); } function renderCurrentMetaOnly() { const id = state.currentId; if (!id || !state.memos[id]) { currentMetaEl.textContent = ""; return; } const memo = state.memos[id]; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; } function renderCurrentMemo() { const id = state.currentId; if (!id || !state.memos[id]) { currentTitleEl.textContent = "メモが選択されていません"; currentMetaEl.textContent = ""; editorEl.value = ""; editorEl.disabled = true; updateCharCount(); return; } const memo = state.memos[id]; editorEl.disabled = false; editorEl.value = memo.content; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; updateCharCount(); } // ---- イベント ---- newMemoBtn.addEventListener("click", () => { createMemo(); }); editorEl.addEventListener("input", () => { if (isLoading) return; updateCharCount(); updateCurrentMemoContent(editorEl.value); renderCurrentMetaOnly(); // タイトルも更新 }); window.addEventListener("beforeunload", () => { // 念のため即保存 saveToStorage(); }); // ---- 初期化 ---- function init() { loadFromStorage(); // メモが全くなければ1つ作る if (Object.keys(state.memos).length === 0 || state.order.length === 0) { state.memos = {}; state.order = []; state.currentId = null; createMemo(); } else { // currentIdがおかしい場合は先頭を選択 if (!state.currentId || !state.memos[state.currentId]) { state.currentId = state.order[0] || null; } } renderList(); renderCurrentMemo(); isLoading = false; setStatus("自動保存が有効です"); } init(); })(); </script> </body> </html> </syntaxhighlight> ==== - データ構造(localStorage) ``<code>js { memos: { [id]: { id, content, updatedAt } }, order: [id1, id2, ...], // 表示順 currentId: "m_..." } </code>`` ==== * タイトル - getTitleFromContent で「先頭行 or 無題メモ」を生成 - 編集のたびにタイトルも再計算して左ペインに反映 * 自動保存 - textarea の input で updateCurrentMemoContent - 500ms デバウンスして localStorage に保存 - 同時にメモ一覧の最上部に移動(最近編集したものが上に来る) * CRUD - 新規: createMemo() で空メモ作成+選択 - 削除: 左ペインの「✕」ボタンで confirm → 削除 - 一覧クリックで選択 - 更新は編集+自動保存 もし「メモをタグでフィルタしたい」「Markdownプレビューを付けたい」「IndexedDBにしたい」みたいな拡張があれば、それ前提で次のバージョンも一緒に考えましょう。 === User: 10万文字のメモも保存したい。ローカルストレージで可能? === 10万文字のメモも保存したい。ローカルストレージで可能? === Assistant: 結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍 === 結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍 ==== - localStorage はブラウザにもよりますが、1オリジンあたりだいたい 5MB 前後(5〜10MB) が上限です。MDN Web Docs<ref>{{cite web|title=MDN Web Docs|url=https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria|publisher=MDN Web Docs|access-date=2025-11-27}}</ref> ==== * 文字列は内部的にだいたい 2バイト/1文字(UTF-16) と見ておくと安全です。 なので: * 10万文字 × 2バイト ≒ 200KB ≒ 0.2MB つまり、1メモ10万文字でも 5MB 制限に対して 1/25 くらい しか使いません。 アプリ全体で JSON のオーバーヘッドや他のメモを含めても、 * 10万文字メモを数個〜十数個 → まず問題なし * 10万文字メモを何十個もため込む → さすがに 5MB 近くなってくる可能性あり というイメージです。 ==== 1. 上限超えるとどうなる? localStorage.setItem したときに <code>QuotaExceededError</code> が投げられます。DEV Community<ref>{{cite web|title=DEV Community|url=https://dev.to/tommykw/testing-storage-limits-of-localstorage-and-sessionstorage-in-chrome-21ab|publisher=DEV Community|access-date=2025-11-27}}</ref> → try/catch で握って「容量オーバーです」みたいなメッセージを出すと安心。 ``<code>js try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { if (e.name === "QuotaExceededError") { alert("保存容量の上限に達しました。古いメモを削除してください。"); } } </code>`` ==== # localStorage は同期処理 読み書きが同期なので、巨大な JSON を頻繁に書き換えるとメインスレッドを少しブロックします。 ・今の規模(数百KB〜数MB)なら大体問題ないですが、 ・オフラインノートアプリ級に巨大化させるなら IndexedDB に移行を検討したほうがいいです。web.dev<ref>{{cite web|title=web.dev|url=https://web.dev/articles/storage-for-the-web|publisher=web.dev|access-date=2025-11-27}}</ref> ==== - 10万文字のメモ単体なら localStorage で余裕。 ==== * そのアプリ構成(メモを KV で保存+一覧用の index)でも特に問題なし。 * もし「10万文字メモ × 100個」みたいな世界に行きそうなら、 - 「古いメモから削除」ロジックを入れる - もしくは IndexedDB へ移行 のどちらかを考えておくと安心です。 もしよければ、「今想定してる最大メモ数」がどれくらいか教えてもらえれば、それ前提で安全ラインをざっくり見積もったり、設計アドバイスもできます! === User: indexDBに切り替えた版も欲しい === indexDBに切り替えた版も欲しい === Assistant: localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。 === localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。 index.htmlとして保存 → ブラウザで開けばそのまま動きます。 ==== IndexedDB 版 index.html ==== <syntaxhighlight lang="html"><!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <title>ブラウザ完結メモアプリ (IndexedDB)</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> :root { --border-color: #ddd; --accent-color: #2563eb; --bg-sidebar: #f5f5f5; --bg-active: #e0edff; --text-muted: #666; --radius: 8px; --shadow: 0 8px 16px rgba(0,0,0,0.06); } * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f4f6; color: #111827; height: 100vh; display: flex; align-items: stretch; justify-content: center; } .app-shell { margin: 16px; background: white; border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex: 1; max-width: 1200px; min-height: 0; } .sidebar { width: 280px; min-width: 220px; max-width: 360px; border-right: 1px solid var(--border-color); background: var(--bg-sidebar); display: flex; flex-direction: column; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color); } .sidebar-title { font-size: 15px; font-weight: 600; } .new-btn { border: none; border-radius: 999px; padding: 6px 12px; font-size: 13px; cursor: pointer; background: var(--accent-color); color: white; display: inline-flex; align-items: center; gap: 4px; } .new-btn span { font-size: 16px; line-height: 1; transform: translateY(-1px); } .memo-list { flex: 1; overflow-y: auto; padding: 8px; } .memo-item { background: white; border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; transition: background 0.1s ease, border-color 0.1s ease, transform 0.05s ease; } .memo-item:hover { background: #eef2ff; transform: translateY(-1px); } .memo-item.active { background: var(--bg-active); border-color: var(--accent-color); } .memo-main { flex: 1; min-width: 0; } .memo-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .memo-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } .memo-delete { border: none; background: transparent; cursor: pointer; padding: 4px; border-radius: 999px; font-size: 14px; color: #9ca3af; flex-shrink: 0; } .memo-delete:hover { background: #fee2e2; color: #b91c1c; } .editor-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; } .editor-header { padding: 10px 16px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; gap: 8px; } .editor-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .editor-meta { font-size: 11px; color: var(--text-muted); } .editor-body { flex: 1; display: flex; flex-direction: column; min-height: 0; } .editor-textarea { flex: 1; padding: 12px 16px; border: none; resize: none; font-family: inherit; font-size: 14px; line-height: 1.6; outline: none; min-height: 0; } .editor-textarea::placeholder { color: #9ca3af; } .status-bar { font-size: 11px; color: var(--text-muted); padding: 4px 10px 8px 16px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } @media (max-width: 768px) { .app-shell { flex-direction: column; } .sidebar { width: 100%; max-width: none; border-right: none; border-bottom: 1px solid var(--border-color); } } </style> </head> <body> <div class="app-shell"> <!-- 左ペイン:メモ一覧 --> <aside class="sidebar"> <div class="sidebar-header"> <div class="sidebar-title">メモ一覧</div> <button class="new-btn" id="newMemoBtn"><span>+</span>新規</button> </div> <div class="memo-list" id="memoList"></div> </aside> <!-- 右ペイン:エディタ --> <main class="editor-pane"> <div class="editor-header"> <div class="editor-title" id="currentTitle">メモが選択されていません</div> <div class="editor-meta" id="currentMeta"></div> </div> <div class="editor-body"> <textarea id="editor" class="editor-textarea" placeholder="メモを書き始めてください…(自動保存されます)" ></textarea> <div class="status-bar"> <span id="statusText">準備完了</span> <span id="charCount">0 文字</span> </div> </div> </main> </div> <script> (function () { // ==== IndexedDB 設定 ==== const DB_NAME = "memoAppDB.v1"; const DB_VERSION = 1; const MEMOS_STORE = "memos"; const STATE_STORE = "state"; let dbPromise = null; function openDB() { if (!("indexedDB" in window)) { alert("このブラウザは IndexedDB に対応していません。"); throw new Error("IndexedDB not supported"); } if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = request.result; if (!db.objectStoreNames.contains(MEMOS_STORE)) { db.createObjectStore(MEMOS_STORE, { keyPath: "id" }); } if (!db.objectStoreNames.contains(STATE_STORE)) { db.createObjectStore(STATE_STORE, { keyPath: "key" }); } }; request.onsuccess = () => { resolve(request.result); }; request.onerror = () => { reject(request.error); }; }); return dbPromise; } async function getAllMemos() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(MEMOS_STORE, "readonly"); const store = tx.objectStore(MEMOS_STORE); const req = store.getAll(); req.onsuccess = () => { resolve(req.result || []); }; req.onerror = () => reject(req.error); }); } async function putMemo(memo) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(MEMOS_STORE, "readwrite"); const store = tx.objectStore(MEMOS_STORE); store.put(memo); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function deleteMemoFromDB(id) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(MEMOS_STORE, "readwrite"); const store = tx.objectStore(MEMOS_STORE); store.delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function getState(key) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STATE_STORE, "readonly"); const store = tx.objectStore(STATE_STORE); const req = store.get(key); req.onsuccess = () => { const result = req.result; resolve(result ? result.value : null); }; req.onerror = () => reject(req.error); }); } async function setState(key, value) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STATE_STORE, "readwrite"); const store = tx.objectStore(STATE_STORE); store.put({ key, value }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } // ==== アプリ状態 ==== let state = { memos: {}, // id -> { id, content, updatedAt } order: [], // 表示順 id[] currentId: null, }; const memoListEl = document.getElementById("memoList"); const editorEl = document.getElementById("editor"); const newMemoBtn = document.getElementById("newMemoBtn"); const currentTitleEl = document.getElementById("currentTitle"); const currentMetaEl = document.getElementById("currentMeta"); const statusTextEl = document.getElementById("statusText"); const charCountEl = document.getElementById("charCount"); let saveTimer = null; let isLoading = true; // ==== ユーティリティ ==== function createId() { return ( "m_" + Date.now().toString(36) + "_" + Math.random().toString(16).slice(2) ); } function getTitleFromContent(content) { const firstLine = (content || "").split(/\r?\n/)[0].trim(); return firstLine || "無題メモ"; } function formatDate(ts) { const d = new Date(ts); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return <code>${yyyy}/${mm}/${dd} ${hh}:${mi}</code>; } function setStatus(text) { statusTextEl.textContent = text; } function updateCharCount() { charCountEl.textContent = <code>${editorEl.value.length} 文字</code>; } function rebuildOrder() { const arr = Object.values(state.memos); arr.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); state.order = arr.map((m) => m.id); } // ==== CRUD ==== async function createMemo(initialContent = "") { const id = createId(); const now = Date.now(); const memo = { id, content: initialContent, updatedAt: now, }; state.memos[id] = memo; rebuildOrder(); state.currentId = id; renderList(); renderCurrentMemo(); setStatus("保存中…"); try { await putMemo(memo); await setState("currentId", id); setStatus("保存しました"); } catch (e) { console.error("Failed to create memo:", e); setStatus("保存に失敗しました"); } } async function deleteMemo(id) { if (!state.memos[id]) return; const ok = confirm("このメモを削除しますか?"); if (!ok) return; setStatus("保存中…"); try { await deleteMemoFromDB(id); } catch (e) { console.error("Failed to delete from DB:", e); // DB 失敗しても UI は進めてしまう(シンプル優先) } delete state.memos[id]; rebuildOrder(); if (state.order.length === 0) { state.currentId = null; renderList(); renderCurrentMemo(); // 1つも無くなったら新しく1つ作る await createMemo(); return; } if (state.currentId === id) { state.currentId = state.order[0] || null; } renderList(); renderCurrentMemo(); try { await setState("currentId", state.currentId); setStatus("保存しました"); } catch (e) { console.error("Failed to save currentId:", e); setStatus("保存に失敗しました"); } } function selectMemo(id) { if (!state.memos[id]) return; state.currentId = id; renderList(); renderCurrentMemo(); // currentId は裏で保存しておくだけ setState("currentId", id).catch((e) => { console.error("Failed to save currentId:", e); }); } function updateCurrentMemoContent(content) { const id = state.currentId; if (!id) return; const memo = state.memos[id]; if (!memo) return; memo.content = content; memo.updatedAt = Date.now(); renderCurrentMetaOnly(); scheduleSaveCurrentMemo(); } function scheduleSaveCurrentMemo() { if (saveTimer) clearTimeout(saveTimer); setStatus("保存中…"); saveTimer = setTimeout(() => { saveTimer = null; saveCurrentMemoToDB(); }, 500); } async function saveCurrentMemoToDB() { const id = state.currentId; if (!id) return; const memo = state.memos[id]; if (!memo) return; try { rebuildOrder(); renderList(); renderCurrentMetaOnly(); await putMemo(memo); setStatus("保存しました"); } catch (e) { console.error("Failed to save memo:", e); setStatus("保存に失敗しました"); } } // ==== 描画 ==== function renderList() { memoListEl.innerHTML = ""; if (state.order.length === 0) { const emptyEl = document.createElement("div"); emptyEl.style.padding = "12px"; emptyEl.style.fontSize = "13px"; emptyEl.style.color = "#9ca3af"; emptyEl.textContent = "メモがありません。「新規」ボタンから作成できます。"; memoListEl.appendChild(emptyEl); return; } state.order.forEach((id) => { const memo = state.memos[id]; if (!memo) return; const itemEl = document.createElement("div"); itemEl.className = "memo-item" + (id === state.currentId ? " active" : ""); itemEl.addEventListener("click", () => selectMemo(id)); const mainEl = document.createElement("div"); mainEl.className = "memo-main"; const titleEl = document.createElement("div"); titleEl.className = "memo-title"; titleEl.textContent = getTitleFromContent(memo.content); const metaEl = document.createElement("div"); metaEl.className = "memo-meta"; metaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; mainEl.appendChild(titleEl); mainEl.appendChild(metaEl); const deleteBtn = document.createElement("button"); deleteBtn.className = "memo-delete"; deleteBtn.innerHTML = "✕"; deleteBtn.title = "削除"; deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteMemo(id); }); itemEl.appendChild(mainEl); itemEl.appendChild(deleteBtn); memoListEl.appendChild(itemEl); }); } function renderCurrentMetaOnly() { const id = state.currentId; if (!id || !state.memos[id]) { currentMetaEl.textContent = ""; return; } const memo = state.memos[id]; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; } function renderCurrentMemo() { const id = state.currentId; if (!id || !state.memos[id]) { currentTitleEl.textContent = "メモが選択されていません"; currentMetaEl.textContent = ""; editorEl.value = ""; editorEl.disabled = true; updateCharCount(); return; } const memo = state.memos[id]; editorEl.disabled = false; editorEl.value = memo.content; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; updateCharCount(); } // ==== イベント ==== newMemoBtn.addEventListener("click", () => { createMemo(); }); editorEl.addEventListener("input", () => { if (isLoading) return; updateCharCount(); updateCurrentMemoContent(editorEl.value); }); window.addEventListener("beforeunload", () => { // 残っているデバウンスがあれば即保存をキック(完了は保証されない) if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; saveCurrentMemoToDB(); } }); // ==== 初期化 ==== async function init() { isLoading = true; setStatus("読み込み中…"); try { const memos = await getAllMemos(); state.memos = {}; memos.forEach((m) => { state.memos[m.id] = m; }); rebuildOrder(); const storedId = await getState("currentId"); if (storedId && state.memos[storedId]) { state.currentId = storedId; } else { state.currentId = state.order[0] || null; } if (!state.currentId) { await createMemo(); } else { renderList(); renderCurrentMemo(); } setStatus("自動保存が有効です"); } catch (e) { console.error("Failed to initialize app:", e); state.memos = {}; state.order = []; state.currentId = null; await createMemo(); setStatus("自動保存が有効です"); } finally { isLoading = false; } } init(); })(); </script> </body> </html> </syntaxhighlight> ===== ざっくり変更点 ===== * localStorage は使わず、 - DB名: memoAppDB.v1 - ストア: - memos … メモ本体(id, content, updatedAt) - state … currentId だけ保存 という IndexedDB の 2 ストア構成にしています。 * 表示順は updatedAt でソートして state.order を組み立て。 * 自動保存はこれまで通り 500ms デバウンスで、裏で putMemo() を叩く形。 このままでも 10万文字クラスのメモを複数持てるので、 「さらにタグ付けしたい」「検索入れたい」「バックアップ用にJSONエクスポートしたい」 みたいなのがあれば、それ前提の拡張版も書きます。 == gpt-5-1 == === User: このアプリはスマホのブラウザでも機能しますか === このアプリはスマホのブラウザでも機能しますか === Assistant: はい、そのままでスマホのブラウザでも普通に動きます。 === はい、そのままでスマホのブラウザでも普通に動きます。 すでに提示した設計・コードは 完全フロントエンド + localStorage + 2ペイン(レスポンシブあり) のため、スマホでも問題なく動作します。 ただし、実際にスマホで快適に動かすにはいくつかポイントがあります。 === - localStorage → スマホの Safari / Chrome などでもサポート済み === * DOM・イベント → スマホ対応 * textarea → スマホのキーボードで普通に編集可能 * 2ペインレイアウト → CSS の @media でスマホでは縦分割(上:リスト、下:エディタ)に自動切り替わる すでに公開したコードは画面幅が狭いときに: * 左右 2 ペイン → 上下レイアウトに自動変更(スマホ最適化) になるようにしているので、そのまま使えます。 === ## === * iOS Safari は理論上 5MB あるものの、実際は 2.5〜3MB 付近でエラーが出ることもあります。 * ただし 10万文字(0.2MB)なら余裕。 → 大量のメモを積まなければ問題なし ==== スマホは PC より CPU が弱いので、 ==== * メモ100個 * 保存データ 2〜3MB みたいな規模になると、 JSON.stringify → localStorage.setItem の瞬間にフリーズが起こる場合があります。 → 回避策: * メモ1つずつを別 key で保存 * setItem の頻度を減らす (必要ならコード書き替え版も作ります) ==== スマホでは IME の変換中の文字が input イベントで一気に来たりするため、 ==== * 自動保存のデバウンス(今は500ms) * IME の確定に合わせた保存 はすでに実運用で特に問題なしの設計です。 必要なら「IME変換中は保存しない版」も作れます。 ==== 画面幅が狭すぎるので ==== * 上:メモ一覧 * 下:エディタ の1 カラム UIになります(自動切り替え済み)。 === あなたが作りたいこのメモアプリは、スマホでも問題なく使えます。 === しかも、提示コードはスマホ最適化された状態になっています。 === - スマホ特化 UI(スワイプでメモ切り替え) === * PWA 化(ホーム画面アイコン、オフライン利用、フルスクリーン化) * IndexedDB 版(大量メモでも高速) * ファイルエクスポート/インポート(バックアップ) なども追加できます。 スマホファースト設計にしたい? PWA化したい? 長期運用できるデータ構造にしたい? 方向性を教えてくれたら次のコードを用意します。 == References == <references /> [[Category:ChatGPT Shares]] [[Category:openai]]
Summary:
Please note that all contributions to freem are considered to be released under the Creative Commons Attribution-ShareAlike 4.0 (see
Freem:Copyrights
for details). If you do not want your writing to be edited mercilessly and redistributed at will, then do not submit it here.
You are also promising us that you wrote this yourself, or copied it from a public domain or similar free resource.
Do not submit copyrighted work without permission!
Cancel
Editing help
(opens in new window)
Templates used on this page:
Template:Cite web
(
edit
)
Module:Citation/CS1
(
edit
)
Module:Citation/CS1/COinS
(
edit
)
Module:Citation/CS1/Configuration
(
edit
)
Module:Citation/CS1/Date validation
(
edit
)
Module:Citation/CS1/Identifiers
(
edit
)
Module:Citation/CS1/Utilities
(
edit
)
Module:Citation/CS1/Whitelist
(
edit
)
Module:Citation/CS1/styles.css
(
edit
)