Openai/6926aad9-faf0-8007-bcb8-9e64cd1f86b1
This conversation was exported from ChatGPT[1]
gpt-5-1-pro[edit | edit source]
User: フロントエンド完結のメモアプリをつくりたい[edit | edit source]
フロントエンド完結のメモアプリをつくりたい データはブラウザのストレージに保存する メモは複数個作成でき、トップ画面は2ペインで、左はメモ一覧表示とCRUDをサポート、右がメモ編集画面 メモは自動保存する メモの名前は先頭から流用し、内部的には機械的なIDでkvで保存すればいい
Assistant: じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。[edit | edit source]
じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 index.htmlとして保存 → ブラウザで開くだけで動きます。
- 完全フロントエンド
- localStorageに保存
- 左ペイン:メモ一覧 & CRUD
- 右ペイン:メモ編集
- 入力と同時に自動保存(少しデバウンス)
- メモ名 = 先頭行を流用(空なら「無題メモ」)
- 内部的には機械的IDでKV保存
====
<!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>
- データ構造(localStorage) ``js { memos: { [id]: { id, content, updatedAt } }, order: [id1, id2, ...], // 表示順 currentId: "m_..." } ``[edit | edit source]
- タイトル - getTitleFromContent で「先頭行 or 無題メモ」を生成 - 編集のたびにタイトルも再計算して左ペインに反映
- 自動保存 - textarea の input で updateCurrentMemoContent - 500ms デバウンスして localStorage に保存 - 同時にメモ一覧の最上部に移動(最近編集したものが上に来る)
- CRUD - 新規: createMemo() で空メモ作成+選択 - 削除: 左ペインの「✕」ボタンで confirm → 削除 - 一覧クリックで選択 - 更新は編集+自動保存
もし「メモをタグでフィルタしたい」「Markdownプレビューを付けたい」「IndexedDBにしたい」みたいな拡張があれば、それ前提で次のバージョンも一緒に考えましょう。
User: 10万文字のメモも保存したい。ローカルストレージで可能?[edit | edit source]
10万文字のメモも保存したい。ローカルストレージで可能?
Assistant: 結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍[edit | edit source]
結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍
- localStorage はブラウザにもよりますが、1オリジンあたりだいたい 5MB 前後(5〜10MB) が上限です。MDN Web Docs[2][edit | edit source]
- 文字列は内部的にだいたい 2バイト/1文字(UTF-16) と見ておくと安全です。
なので:
- 10万文字 × 2バイト ≒ 200KB ≒ 0.2MB
つまり、1メモ10万文字でも 5MB 制限に対して 1/25 くらい しか使いません。
アプリ全体で JSON のオーバーヘッドや他のメモを含めても、
- 10万文字メモを数個〜十数個 → まず問題なし
- 10万文字メモを何十個もため込む → さすがに 5MB 近くなってくる可能性あり
というイメージです。
1. 上限超えるとどうなる? localStorage.setItem したときに QuotaExceededError が投げられます。DEV Community[3] → try/catch で握って「容量オーバーです」みたいなメッセージを出すと安心。 ``js try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { if (e.name === "QuotaExceededError") { alert("保存容量の上限に達しました。古いメモを削除してください。"); } } ``[edit | edit source]
- localStorage は同期処理 読み書きが同期なので、巨大な JSON を頻繁に書き換えるとメインスレッドを少しブロックします。 ・今の規模(数百KB〜数MB)なら大体問題ないですが、 ・オフラインノートアプリ級に巨大化させるなら IndexedDB に移行を検討したほうがいいです。web.dev[4]
- 10万文字のメモ単体なら localStorage で余裕。[edit | edit source]
- そのアプリ構成(メモを KV で保存+一覧用の index)でも特に問題なし。
- もし「10万文字メモ × 100個」みたいな世界に行きそうなら、 - 「古いメモから削除」ロジックを入れる - もしくは IndexedDB へ移行 のどちらかを考えておくと安心です。
もしよければ、「今想定してる最大メモ数」がどれくらいか教えてもらえれば、それ前提で安全ラインをざっくり見積もったり、設計アドバイスもできます!
User: indexDBに切り替えた版も欲しい[edit | edit source]
indexDBに切り替えた版も欲しい
Assistant: localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。[edit | edit source]
localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。 index.htmlとして保存 → ブラウザで開けばそのまま動きます。
IndexedDB 版 index.html[edit | edit source]
<!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>
ざっくり変更点[edit | edit source]
- localStorage は使わず、 - DB名: memoAppDB.v1 - ストア: - memos … メモ本体(id, content, updatedAt) - state … currentId だけ保存 という IndexedDB の 2 ストア構成にしています。
- 表示順は updatedAt でソートして state.order を組み立て。
- 自動保存はこれまで通り 500ms デバウンスで、裏で putMemo() を叩く形。
このままでも 10万文字クラスのメモを複数持てるので、 「さらにタグ付けしたい」「検索入れたい」「バックアップ用にJSONエクスポートしたい」 みたいなのがあれば、それ前提の拡張版も書きます。
gpt-5-1[edit | edit source]
User: このアプリはスマホのブラウザでも機能しますか[edit | edit source]
このアプリはスマホのブラウザでも機能しますか
Assistant: はい、そのままでスマホのブラウザでも普通に動きます。[edit | edit source]
はい、そのままでスマホのブラウザでも普通に動きます。 すでに提示した設計・コードは 完全フロントエンド + localStorage + 2ペイン(レスポンシブあり) のため、スマホでも問題なく動作します。
ただし、実際にスマホで快適に動かすにはいくつかポイントがあります。
- localStorage → スマホの Safari / Chrome などでもサポート済み[edit | edit source]
- DOM・イベント → スマホ対応
- textarea → スマホのキーボードで普通に編集可能
- 2ペインレイアウト → CSS の @media でスマホでは縦分割(上:リスト、下:エディタ)に自動切り替わる
すでに公開したコードは画面幅が狭いときに:
- 左右 2 ペイン → 上下レイアウトに自動変更(スマホ最適化)
になるようにしているので、そのまま使えます。
##[edit | edit source]
- iOS Safari は理論上 5MB あるものの、実際は 2.5〜3MB 付近でエラーが出ることもあります。
- ただし 10万文字(0.2MB)なら余裕。
→ 大量のメモを積まなければ問題なし
スマホは PC より CPU が弱いので、[edit | edit source]
- メモ100個
- 保存データ 2〜3MB
みたいな規模になると、 JSON.stringify → localStorage.setItem の瞬間にフリーズが起こる場合があります。
→ 回避策:
- メモ1つずつを別 key で保存
- setItem の頻度を減らす
(必要ならコード書き替え版も作ります)
スマホでは IME の変換中の文字が input イベントで一気に来たりするため、[edit | edit source]
- 自動保存のデバウンス(今は500ms)
- IME の確定に合わせた保存
はすでに実運用で特に問題なしの設計です。
必要なら「IME変換中は保存しない版」も作れます。
画面幅が狭すぎるので[edit | edit source]
- 上:メモ一覧
- 下:エディタ
の1 カラム UIになります(自動切り替え済み)。
あなたが作りたいこのメモアプリは、スマホでも問題なく使えます。[edit | edit source]
しかも、提示コードはスマホ最適化された状態になっています。
- スマホ特化 UI(スワイプでメモ切り替え)[edit | edit source]
- PWA 化(ホーム画面アイコン、オフライン利用、フルスクリーン化)
- IndexedDB 版(大量メモでも高速)
- ファイルエクスポート/インポート(バックアップ)
なども追加できます。
スマホファースト設計にしたい?
PWA化したい?
長期運用できるデータ構造にしたい?
方向性を教えてくれたら次のコードを用意します。
References[edit | edit source]
- ↑ "フロントエンドメモアプリ". ChatGPT. Retrieved 2025-11-27.
- ↑ "MDN Web Docs". MDN Web Docs. Retrieved 2025-11-27.
- ↑ "DEV Community". DEV Community. Retrieved 2025-11-27.
- ↑ "web.dev". web.dev. Retrieved 2025-11-27.