index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ノート管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<h1 class="app-title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
ノート管理
</h1>
<div class="header-actions">
<button id="viewModeBtn" class="compact-action-btn" title="閲覧モード (Esc)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6"></path>
<path d="M9 21H3v-6"></path>
<path d="M21 3l-7 7"></path>
<path d="M3 21l7-7"></path>
</svg>
</button>
<button id="editModeBtn" class="compact-action-btn" title="編集モード">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button id="newNoteBtn" class="compact-action-btn" title="新規ノート">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span class="plus-icon">+</span>
</button>
<button id="newFolderBtn" class="compact-action-btn" title="新規フォルダ">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z">
</path>
</svg>
<span class="plus-icon">+</span>
</button>
<div class="toolbar-separator"></div>
<div class="formatting-toolbar">
<button id="heading2Btn" class="text-btn" title="見出し2">
<span>h2</span>
</button>
<button id="heading3Btn" class="text-btn" title="見出し3">
<span>h3</span>
</button>
<button id="heading4Btn" class="text-btn" title="見出し4">
<span>h4</span>
</button>
<div class="toolbar-separator"></div>
<button id="grayTextBtn" class="text-btn" title="グレー文字">
<span>灰</span>
</button>
<button id="redTextBtn" class="text-btn" title="赤文字">
<span>赤</span>
</button>
<button id="blackTextBtn" class="text-btn" title="黒文字">
<span>黒</span>
</button>
<button id="boldTextBtn" class="text-btn" title="太字">
<span>B</span>
</button>
<div class="toolbar-separator"></div>
<button id="insertBoxBtn" class="icon-btn" title="グレーボックス">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button id="insertBoxBlueBtn" class="icon-btn" title="青ボックス">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="rgba(99, 102, 241, 0.2)">
</rect>
</svg>
</button>
<button id="insertGrayBoxBtn" class="icon-btn" title="グレーボックス2">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="rgba(250, 250, 250, 0.5)">
</rect>
<text x="12" y="16" font-size="10" text-anchor="middle" fill="currentColor"
font-weight="bold">2</text>
</svg>
</button>
<button id="insertCheckHeadingBtn" class="icon-btn" title="チェック見出し">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
<button id="insertEmptyLineBtn" class="icon-btn" title="空行挿入">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 12h8m-8 4h8m-8-8h8"></path>
<polyline points="17 9 20 12 17 15"></polyline>
</svg>
</button>
<div class="toolbar-separator"></div>
<button id="insertLinkBtn" class="icon-btn" title="リンク挿入">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
<button id="insertImageBtn" class="icon-btn" title="画像挿入">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</button>
<input type="file" id="imageInput" accept="image/*" style="display: none;">
<div class="toolbar-separator"></div>
<select id="folderSelect" class="folder-select-header">
<option value="">フォルダ...</option>
</select>
<div class="toolbar-separator"></div>
<button id="codeEditorBtn" class="icon-btn" title="HTMLコード編集">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</button>
<button id="viewCodeBtn" class="text-icon-btn" title="選択範囲をコード表示">
<span>🧩</span>
</button>
<button id="paragraphBtn" class="icon-btn" title="段落にする">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 4h7"></path>
<path d="M13 8h7"></path>
<path d="M13 12h7"></path>
<path d="M3 4h6"></path>
<path d="M3 8h6"></path>
<path d="M3 12h6"></path>
<path d="M3 16h16"></path>
<path d="M3 20h16"></path>
</svg>
</button>
<button id="clearFormatBtn" class="icon-btn" title="書式をクリア">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 7 4 4 20 4 20 7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</svg>
</button>
</div>
<div class="export-dropdown">
<button id="exportBtn" class="icon-btn" title="エクスポート">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<div id="exportMenu" class="export-menu hidden">
<button id="exportMarkdownBtn" class="export-menu-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
Markdownでエクスポート
</button>
<button id="exportHTMLBtn" class="export-menu-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
HTMLでエクスポート
</button>
<button id="exportPDFBtn" class="export-menu-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
PDFでエクスポート
</button>
<div class="export-menu-divider"></div>
<button id="exportAllBtn" class="export-menu-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
全ノートをZIPでエクスポート
</button>
</div>
</div>
<button id="themeToggle" class="icon-btn" title="テーマ切替">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
</header>
<div class="main-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="notes-list" id="notesList">
<!-- Notes will be dynamically added here -->
</div>
<div class="sidebar-resizer" id="sidebarResizer"></div>
</aside>
<!-- Main Content -->
<main class="main-content">
<button id="exitViewBtn" class="exit-view-btn hidden" title="閲覧モード終了 (Esc)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="editor-container">
<div class="editor-header">
<div class="content-centered-wrapper">
<input type="text" id="noteTitle" class="note-title-input" placeholder="タイトルを入力...">
</div>
</div>
<div id="editor" class="editor" contenteditable="true" data-placeholder="ここに入力を開始..."></div>
</div>
<div class="empty-state" id="emptyState">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<h2>ノートを選択するか、新規作成してください</h2>
<p>左のサイドバーから既存のノートを選択するか、「新規ノート」ボタンをクリックして新しいノートを作成できます。</p>
</div>
</main>
</div>
</div>
<!-- Floating Edit Toolbar -->
<div id="floatingToolbar" class="floating-toolbar hidden">
<button id="floatingUndoBtn" class="floating-btn" title="元に戻す">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6"></path>
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path>
</svg>
</button>
<button id="floatingRedoBtn" class="floating-btn" title="やり直す">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 7v6h-6"></path>
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 3.7"></path>
</svg>
</button>
<div class="floating-separator"></div>
<button id="floatingMoveUpBtn" class="floating-btn" title="上に移動">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button id="floatingMoveDownBtn" class="floating-btn" title="下に移動">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<!-- Load JavaScript modules in order -->
<script src="js/database.js"></script>
<script src="js/image.js"></script>
<script src="js/ui.js"></script>
<script src="js/editor.js"></script>
<script src="js/export.js"></script>
<script src="js/main.js"></script>
</body>
</html>
app.js
// ===== IndexedDB Setup =====
const DB_NAME = 'NotesDB';
const DB_VERSION = 3;
const STORE_NAME = 'notes';
const FOLDER_STORE = 'folders';
const DEFAULT_FOLDER_ID = 1;
let db;
let currentNoteId = null;
let currentFolderId = null;
let saveTimeout = null;
let collapsedFolders = new Set();
// Initialize IndexedDB
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
db = event.target.result;
const oldVersion = event.oldVersion;
// Notes store
if (!db.objectStoreNames.contains(STORE_NAME)) {
const notesStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
notesStore.createIndex('folderId', 'folderId', { unique: false });
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false }); // Changed from timestamp
} else if (oldVersion < 3) { // Upgrade existing notes store for version 3
const transaction = event.target.transaction;
const notesStore = transaction.objectStore(STORE_NAME);
if (!notesStore.indexNames.contains('folderId')) {
notesStore.createIndex('folderId', 'folderId', { unique: false });
}
// If 'timestamp' index exists, it might need to be removed or replaced,
// but for simplicity, we'll just add 'updatedAt' if it doesn't exist.
if (!notesStore.indexNames.contains('updatedAt')) {
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
}
// Folders store
if (!db.objectStoreNames.contains(FOLDER_STORE)) {
const folderStore = db.createObjectStore(FOLDER_STORE, { keyPath: 'id', autoIncrement: true });
folderStore.createIndex('timestamp', 'timestamp', { unique: false });
// Add default folder
folderStore.add({
id: DEFAULT_FOLDER_ID,
name: '未分類',
timestamp: Date.now()
});
}
// Images store (new in version 3)
if (!db.objectStoreNames.contains('images')) {
const imagesStore = db.createObjectStore('images', { keyPath: 'id', autoIncrement: true });
imagesStore.createIndex('noteId', 'noteId', { unique: false });
}
};
});
}
// ===== CRUD Operations =====
// Create a new note
async function createNote(folderId = DEFAULT_FOLDER_ID) {
const note = {
title: '新しいノート',
content: '',
folderId: folderId,
timestamp: Date.now()
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.add(note);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all notes
async function getAllNotes() {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.getAll();
request.onsuccess = () => {
const notes = request.result;
notes.sort((a, b) => b.timestamp - a.timestamp);
resolve(notes);
};
request.onerror = () => reject(request.error);
});
}
// Get a single note by ID
async function getNote(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update a note
async function updateNote(id, updates) {
const note = await getNote(id);
if (!note) return;
const updatedNote = { ...note, ...updates, timestamp: Date.now() };
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.put(updatedNote);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Delete a note
async function deleteNote(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// ===== Folder CRUD Operations =====
// Create a new folder
async function createFolder(name = '新しいフォルダ') {
const folder = {
name: name,
timestamp: Date.now()
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.add(folder);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all folders
async function getAllFolders() {
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readonly');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.getAll();
request.onsuccess = () => {
const folders = request.result;
folders.sort((a, b) => {
if (a.id === DEFAULT_FOLDER_ID) return -1;
if (b.id === DEFAULT_FOLDER_ID) return 1;
return a.timestamp - b.timestamp;
});
resolve(folders);
};
request.onerror = () => reject(request.error);
});
}
// Get folder by ID
async function getFolder(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readonly');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update folder
async function updateFolder(id, updates) {
const folder = await getFolder(id);
if (!folder) return;
const updatedFolder = { ...folder, ...updates, timestamp: Date.now() };
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.put(updatedFolder);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Delete folder
async function deleteFolder(id) {
if (id === DEFAULT_FOLDER_ID) {
alert('デフォルトフォルダは削除できません。');
return;
}
// Move all notes in this folder to default folder
const notes = await getAllNotes();
const notesInFolder = notes.filter(note => note.folderId === id);
for (const note of notesInFolder) {
await updateNote(note.id, { folderId: DEFAULT_FOLDER_ID });
}
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Get notes by folder
async function getNotesByFolder(folderId) {
const allNotes = await getAllNotes();
return allNotes.filter(note => (note.folderId || DEFAULT_FOLDER_ID) === folderId);
}
// ===== Image Storage Functions =====
function saveImage(blob, noteId) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['images'], 'readwrite');
const store = transaction.objectStore('images');
const image = {
blob: blob,
noteId: noteId || currentNoteId,
createdAt: new Date().toISOString()
};
const request = store.add(image);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function getImage(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['images'], 'readonly');
const store = transaction.objectStore('images');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function deleteImagesByNoteId(noteId) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['images'], 'readwrite');
const store = transaction.objectStore('images');
const index = store.index('noteId');
const request = index.openCursor(IDBKeyRange.only(noteId));
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
}
// ===== UI Functions =====
// Render folders and notes list
async function renderFoldersAndNotes() {
const notesList = document.getElementById('notesList');
const folders = await getAllFolders();
notesList.innerHTML = '';
for (const folder of folders) {
const folderElement = createFolderElement(folder);
notesList.appendChild(folderElement);
const notes = await getNotesByFolder(folder.id);
const notesContainer = document.createElement('div');
notesContainer.className = 'folder-notes';
notesContainer.dataset.folderId = folder.id;
if (collapsedFolders.has(folder.id)) {
notesContainer.classList.add('collapsed');
}
notes.forEach(note => {
const noteItem = createNoteElement(note);
notesContainer.appendChild(noteItem);
});
notesList.appendChild(notesContainer);
}
}
// Create folder element
function createFolderElement(folder) {
const div = document.createElement('div');
div.className = 'folder-item';
div.dataset.id = folder.id;
const isCollapsed = collapsedFolders.has(folder.id);
const noteCount = 0; // Will be updated by CSS counter or manually
div.innerHTML = `
<div class="folder-item-header">
<div class="folder-item-left">
<svg class="folder-chevron ${isCollapsed ? 'collapsed' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<svg class="folder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<div class="folder-item-title">${escapeHtml(folder.name)}</div>
</div>
<div class="folder-item-actions">
${folder.id !== DEFAULT_FOLDER_ID ? `
<button class="note-action-btn rename" title="名前を変更">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="note-action-btn delete" title="削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
` : ''}
</div>
</div>
`;
// Toggle collapse
const header = div.querySelector('.folder-item-header');
header.addEventListener('click', (e) => {
if (!e.target.closest('.note-action-btn')) {
toggleFolder(folder.id);
}
});
// Rename button
if (folder.id !== DEFAULT_FOLDER_ID) {
div.querySelector('.rename').addEventListener('click', (e) => {
e.stopPropagation();
startFolderRename(div, folder.id);
});
// Delete button
div.querySelector('.delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm('このフォルダを削除しますか?\n(中のノートは「未分類」に移動されます)')) {
await deleteFolder(folder.id);
await renderFoldersAndNotes();
}
});
}
return div;
}
// Toggle folder collapse
function toggleFolder(folderId) {
if (collapsedFolders.has(folderId)) {
collapsedFolders.delete(folderId);
} else {
collapsedFolders.add(folderId);
}
renderFoldersAndNotes();
}
// Start renaming a folder
function startFolderRename(folderElement, folderId) {
const titleElement = folderElement.querySelector('.folder-item-title');
const currentTitle = titleElement.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-rename-input';
input.value = currentTitle;
titleElement.replaceWith(input);
input.focus();
input.select();
const finishRename = async () => {
const newTitle = input.value.trim() || '無題のフォルダ';
await updateFolder(folderId, { name: newTitle });
await renderFoldersAndNotes();
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
renderFoldersAndNotes();
}
});
}
// Render notes list (legacy - now using renderFoldersAndNotes)
async function renderNotesList() {
await renderFoldersAndNotes();
}
// Create note element
function createNoteElement(note) {
const div = document.createElement('div');
div.className = 'note-item';
if (currentNoteId === note.id) {
div.classList.add('active');
}
div.dataset.id = note.id;
const date = new Date(note.timestamp);
const dateStr = date.toLocaleDateString('ja-JP', { month: 'short', day: 'numeric' });
div.innerHTML = `
<div class="note-item-header">
<div class="note-item-title">${escapeHtml(note.title)}</div>
<div class="note-item-actions">
<button class="note-action-btn rename" title="名前を変更">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="note-action-btn delete" title="削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
<div class="note-item-date">${dateStr}</div>
`;
// Click to select note
div.addEventListener('click', (e) => {
if (!e.target.closest('.note-action-btn')) {
loadNote(note.id);
}
});
// Rename button
div.querySelector('.rename').addEventListener('click', (e) => {
e.stopPropagation();
startRename(div, note.id);
});
// Delete button
div.querySelector('.delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm('このノートを削除しますか?')) {
await deleteNote(note.id);
if (currentNoteId === note.id) {
currentNoteId = null;
showEmptyState();
}
await renderNotesList();
}
});
return div;
}
// Start renaming a note
function startRename(noteElement, noteId) {
const titleElement = noteElement.querySelector('.note-item-title');
const currentTitle = titleElement.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-rename-input';
input.value = currentTitle;
titleElement.replaceWith(input);
input.focus();
input.select();
const finishRename = async () => {
const newTitle = input.value.trim() || '無題のノート';
await updateNote(noteId, { title: newTitle });
await renderNotesList();
if (currentNoteId === noteId) {
document.getElementById('noteTitle').value = newTitle;
}
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
renderNotesList();
}
});
}
// Load a note into the editor
async function loadNote(noteId) {
const note = await getNote(noteId);
if (!note) return;
currentNoteId = noteId;
document.getElementById('noteTitle').value = note.title;
const editor = document.getElementById('editor');
editor.innerHTML = note.content;
// Load images from IndexedDB
const images = editor.querySelectorAll('img[data-image-id]');
for (const img of images) {
const imageId = parseInt(img.getAttribute('data-image-id'));
try {
const imageData = await getImage(imageId);
if (imageData && imageData.blob) {
const blobUrl = URL.createObjectURL(imageData.blob);
img.src = blobUrl;
}
} catch (error) {
console.error('Failed to load image:', error);
}
}
// Update folder select
const folderSelect = document.getElementById('folderSelect');
if (note.folderId) {
folderSelect.value = note.folderId;
} else {
folderSelect.value = '';
}
// Highlight active note
document.querySelectorAll('.note-item').forEach(item => {
item.classList.remove('active');
});
const activeItem = document.querySelector(`[data-id="${noteId}"]`); // Changed data-note-id to data-id based on createNoteElement
if (activeItem) {
activeItem.classList.add('active');
}
// Show editor and hide empty state
document.querySelector('.editor-container').classList.add('active');
document.getElementById('emptyState').classList.add('hidden');
await renderNotesList();
await updateFolderSelect();
}
// Show empty state
function showEmptyState() {
const editorContainer = document.querySelector('.editor-container');
const emptyState = document.getElementById('emptyState');
editorContainer.classList.remove('active');
emptyState.classList.remove('hidden');
document.getElementById('noteTitle').value = '';
document.getElementById('editor').innerHTML = '';
}
// Auto-save with debounce
function autoSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (!currentNoteId) return;
const title = document.getElementById('noteTitle').value.trim() || '無題のノート';
const editor = document.getElementById('editor');
// Clone editor content to preserve image IDs
const tempDiv = document.createElement('div');
tempDiv.innerHTML = editor.innerHTML;
// Convert blob URLs back to data-image-id for storage
const images = tempDiv.querySelectorAll('img[data-image-id]');
images.forEach(img => {
// Keep only the data-image-id attribute, remove src
const imageId = img.getAttribute('data-image-id');
img.removeAttribute('src');
img.setAttribute('data-image-id', imageId);
});
const content = tempDiv.innerHTML;
await updateNote(currentNoteId, { title, content });
await renderNotesList();
}, 500);
}
// Insert image
async function insertImage(file) {
if (!file || !file.type.startsWith('image/')) {
alert('有効な画像ファイルを選択してください。');
return;
}
try {
// Save image to IndexedDB
const imageId = await saveImage(file, currentNoteId);
// Create blob URL for display
const blobUrl = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = blobUrl;
img.setAttribute('data-image-id', imageId);
img.style.maxWidth = '100%';
img.style.height = 'auto';
const editor = document.getElementById('editor');
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(img);
range.setStartAfter(img);
range.collapse(true);
} else {
editor.appendChild(img);
}
autoSave();
} catch (error) {
console.error('Failed to insert image:', error);
alert('画像の挿入に失敗しました。');
}
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== Formatting Functions =====
// Format selected text as heading
function formatHeading(level) {
const editor = document.getElementById('editor');
const selection = window.getSelection();
if (selection.rangeCount === 0) {
alert('テキストを選択してください。');
return;
}
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
alert('テキストを選択してください。');
return;
}
// Create heading element
const heading = document.createElement(`h${level}`);
heading.textContent = selectedText;
// Replace selection with heading
range.deleteContents();
range.insertNode(heading);
// Add a line break after heading
const br = document.createElement('br');
heading.after(br);
// Move cursor after the heading
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// Insert text box
function insertTextBox() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create text box
const textBox = document.createElement('div');
textBox.className = 'text-box';
textBox.contentEditable = 'true';
textBox.textContent = 'ここに入力...';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(textBox);
// Add line break after box
const br = document.createElement('br');
textBox.after(br);
// Move cursor after the box
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
editor.appendChild(textBox);
editor.appendChild(document.createElement('br'));
}
// Focus on the text box
textBox.focus();
// Select placeholder text
const textRange = document.createRange();
textRange.selectNodeContents(textBox);
selection.removeAllRanges();
selection.addRange(textRange);
autoSave();
}
// Insert blue text box
function insertTextBoxBlue() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create blue text box
const textBox = document.createElement('div');
textBox.className = 'text-box-blue';
textBox.contentEditable = 'true';
textBox.textContent = 'ここに入力...';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(textBox);
// Add line break after box
const br = document.createElement('br');
textBox.after(br);
// Move cursor after the box
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
editor.appendChild(textBox);
editor.appendChild(document.createElement('br'));
}
// Focus on the text box
textBox.focus();
// Select placeholder text
const textRange = document.createRange();
textRange.selectNodeContents(textBox);
selection.removeAllRanges();
selection.addRange(textRange);
autoSave();
}
// Insert link
function insertLink() {
const selection = window.getSelection();
if (selection.rangeCount === 0) {
alert('リンクにするテキストを選択してください。');
return;
}
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
alert('リンクにするテキストを選択してください。');
return;
}
// Prompt for URL
const url = prompt('リンク先のURLを入力してください:', 'https://');
if (!url || url === 'https://') {
return;
}
// Create link element
const link = document.createElement('a');
link.href = url;
link.textContent = selectedText;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Replace selection with link
range.deleteContents();
range.insertNode(link);
// Move cursor after the link
range.setStartAfter(link);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// Clear formatting from selected text (line-based)
function clearFormatting() {
const selection = window.getSelection();
const editor = document.getElementById('editor');
if (selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
// If no selection, clear formatting of the current block element
if (range.collapsed) {
let node = range.startContainer;
// Find the parent block element
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.tagName === 'H2' || node.tagName === 'H3' || node.tagName === 'H4' ||
node.classList.contains('text-box') || node.classList.contains('text-box-blue'))) {
// Get text content
const textContent = node.textContent;
// Create plain paragraph
const p = document.createElement('p');
p.textContent = textContent;
// Replace the element
node.parentNode.replaceChild(p, node);
// Move cursor into the new paragraph
const newRange = document.createRange();
newRange.setStart(p, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
return;
}
node = node.parentNode;
}
return;
}
// Get the common ancestor container
let container = range.commonAncestorContainer;
// If it's a text node, get its parent element
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
// Find all text nodes in the selection
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
const nodeRange = document.createRange();
nodeRange.selectNodeContents(node);
return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
}
);
let textContent = '';
let node;
while (node = walker.nextNode()) {
textContent += node.textContent;
}
if (!textContent.trim()) {
return;
}
// Create a plain paragraph with the text
const p = document.createElement('p');
p.textContent = textContent;
// Replace the selection with the plain paragraph
range.deleteContents();
range.insertNode(p);
// Move cursor after the paragraph
range.setStartAfter(p);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// ===== Export Functions =====
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Helper function to download file
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Sanitize filename
function sanitizeFilename(filename) {
return filename.replace(/[<>:"/\\|?*]/g, '_');
}
// Export note as HTML
async function exportNoteAsHTML(noteId) {
const note = await getNote(noteId);
if (!note) return;
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(note.title)}</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #e0e0e0;
background: #1a1a2e;
}
h1 {
color: #ffffff;
border-bottom: 3px solid #6366f1;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
padding: 20px;
margin-bottom: 40px;
font-weight: 600;
}
h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: #333;
}
h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 1.62em;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: #333;
}
.text-box {
background: #f3f4f6;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
p {
margin-bottom: 1rem;
}
a {
color: #6366f1;
text-decoration: underline;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${note.content}
</body>
</html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
downloadFile(blob, `${sanitizeFilename(note.title)}.html`);
}
// Export note as PDF
async function exportNotePDF(noteId) {
const note = await getNote(noteId);
if (!note) return;
// Create a temporary print window
const printWindow = window.open('', '_blank');
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(note.title)}</title>
<style>
@media print {
@page {
margin: 2cm;
}
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #333;
}
h1 {
color: #1a1a2e;
border-bottom: 3px solid #6366f1;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
padding: 20px;
margin-bottom: 40px;
font-weight: 600;
}
h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: #333;
}
h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 1.62em;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: #333;
}
.text-box {
background: #f3f4f6;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
p {
margin-bottom: 1rem;
}
a {
color: #6366f1;
text-decoration: underline;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${note.content}
</body>
</html>`;
printWindow.document.write(html);
printWindow.document.close();
// Wait for content to load, then print
printWindow.onload = function () {
printWindow.print();
// Close after printing or canceling
printWindow.onafterprint = function () {
printWindow.close();
};
};
}
// Export all notes as ZIP
async function exportAllNotes() {
if (typeof JSZip === 'undefined') {
alert('JSZipライブラリが読み込まれていません。');
return;
}
const notes = await getAllNotes();
if (notes.length === 0) {
alert('エクスポートするノートがありません。');
return;
}
const zip = new JSZip();
for (const note of notes) {
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(note.title)}</title>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${note.content}
</body>
</html>`;
zip.file(`${sanitizeFilename(note.title)}.html`, html);
}
const blob = await zip.generateAsync({ type: 'blob' });
downloadFile(blob, 'notes.zip');
}
// ===== Formatting Functions =====
// Convert HTML to Markdown (simple conversion)
function htmlToMarkdown(html) {
let markdown = html;
// Convert images
markdown = markdown.replace(/<img[^>]+src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>/gi, '');
markdown = markdown.replace(/<img[^>]+src="([^"]+)"[^>]*>/gi, '');
// Convert headings
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert paragraphs
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
// Convert bold and italic
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert lists
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
markdown = markdown.replace(/<\/?[uo]l[^>]*>/gi, '\n');
// Remove remaining HTML tags
markdown = markdown.replace(/<[^>]+>/g, '');
// Decode HTML entities
const textarea = document.createElement('textarea');
textarea.innerHTML = markdown;
markdown = textarea.value;
// Clean up extra newlines
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
return markdown;
}
// Export note as Markdown
async function exportNoteAsMarkdown(noteId) {
const note = await getNote(noteId);
if (!note) return;
const markdown = `# ${note.title}\n\n${htmlToMarkdown(note.content)}`;
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
downloadFile(blob, `${sanitizeFilename(note.title)}.md`);
}
// Export note as HTML
async function exportNoteAsHTML(noteId) {
const note = await getNote(noteId);
if (!note) return;
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(note.title)}</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #333;
background: #f9fafb;
}
h1 {
color: #1a1a2e;
border-bottom: 3px solid #6366f1;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1a1a2e;
margin: 1.25rem 0 0.75rem 0;
padding-left: 0.5rem;
border-left: 4px solid #6366f1;
}
h3 {
font-size: 1.25rem;
font-weight: 600;
color: #8b5cf6;
margin: 1rem 0 0.5rem 0;
}
.text-box {
background: rgba(99, 102, 241, 0.1);
border-left: 4px solid #6366f1;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
color: #1a1a2e;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
p {
margin-bottom: 1rem;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
<div class="content">
${note.content}
</div>
</body>
</html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
downloadFile(blob, `${sanitizeFilename(note.title)}.html`);
}
// Export note as PDF
async function exportNotePDF(noteId) {
const note = await getNote(noteId);
if (!note) return;
// Create a temporary print window
const printWindow = window.open('', '_blank');
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(note.title)}</title>
<style>
@media print {
@page {
margin: 2cm;
}
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #333;
}
h1 {
color: #1a1a2e;
border-bottom: 3px solid #6366f1;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
background-size: 4px 4px;
padding: 20px 20px 20px;
margin-bottom: 40px;
font-weight: 600;
}
h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: #333;
}
h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 1.62em;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: #333;
}
.text-box {
background: #f3f4f6;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
p {
margin-bottom: 1rem;
}
a {
color: #6366f1;
text-decoration: underline;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${note.content}
</body>
</html>`;
printWindow.document.write(html);
printWindow.document.close();
// Wait for content to load, then print
printWindow.onload = function () {
printWindow.print();
// Close after printing or canceling
printWindow.onafterprint = function () {
printWindow.close();
};
};
}
// Export all notes as ZIP
async function exportAllNotes() {
if (typeof JSZip === 'undefined') {
alert('エクスポート機能を使用するには、ページをリロードしてください。');
return;
}
const zip = new JSZip();
const folders = await getAllFolders();
for (const folder of folders) {
const notes = await getNotesByFolder(folder.id);
const folderName = sanitizeFilename(folder.name);
for (const note of notes) {
const markdown = `# ${note.title}\n\n${htmlToMarkdown(note.content)}`;
const filename = `${sanitizeFilename(note.title)}.md`;
zip.file(`${folderName}/${filename}`, markdown);
}
}
const blob = await zip.generateAsync({ type: 'blob' });
const date = new Date().toISOString().split('T')[0].replace(/-/g, '');
downloadFile(blob, `notes_export_${date}.zip`);
}
// Sanitize filename
function sanitizeFilename(filename) {
return filename.replace(/[<>:"/\\|?*]/g, '_').substring(0, 200);
}
// Download file
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ===== Event Listeners =====
// New note button
document.getElementById('newNoteBtn').addEventListener('click', async () => {
const noteId = await createNote();
await loadNote(noteId);
});
// Title input
document.getElementById('noteTitle').addEventListener('input', autoSave);
// Editor input
document.getElementById('editor').addEventListener('input', autoSave);
// Image insert button
document.getElementById('insertImageBtn').addEventListener('click', () => {
document.getElementById('imageInput').click();
});
// Image file input
document.getElementById('imageInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
insertImage(file);
}
e.target.value = '';
});
// Paste image
document.getElementById('editor').addEventListener('paste', (e) => {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
insertImage(file);
break;
}
}
});
// Handle link clicks with Ctrl key
document.getElementById('editor').addEventListener('click', (e) => {
if (e.target.tagName === 'A' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
window.open(e.target.href, '_blank', 'noopener,noreferrer');
}
});
// Code editor toggle
let isCodeEditorMode = false;
function formatHTML(html) {
// Add line breaks after closing block tags
const blockTags = ['h2', 'h3', 'h4', 'p', 'div', 'img', 'br'];
let formatted = html;
blockTags.forEach(tag => {
// Add line break after closing tags
formatted = formatted.replace(new RegExp(`</${tag}>`, 'gi'), `</${tag}>\n`);
// Add line break after self-closing img tags
if (tag === 'img' || tag === 'br') {
formatted = formatted.replace(new RegExp(`<${tag}([^>]*)>`, 'gi'), `<${tag}$1>\n`);
}
});
// Clean up multiple consecutive line breaks
formatted = formatted.replace(/\n{3,}/g, '\n\n');
return formatted.trim();
}
document.getElementById('codeEditorBtn').addEventListener('click', () => {
const editor = document.getElementById('editor');
if (!isCodeEditorMode) {
// Switch to code editor mode
const htmlContent = editor.innerHTML;
const formattedHTML = formatHTML(htmlContent);
editor.textContent = formattedHTML;
editor.style.fontFamily = 'monospace';
editor.style.whiteSpace = 'pre-wrap';
editor.style.fontSize = '0.9rem';
isCodeEditorMode = true;
document.getElementById('codeEditorBtn').style.background = 'var(--accent-primary)';
document.getElementById('codeEditorBtn').style.color = 'white';
} else {
// Switch back to visual editor mode
const codeContent = editor.textContent;
editor.innerHTML = codeContent;
editor.style.fontFamily = '';
editor.style.whiteSpace = '';
editor.style.fontSize = '';
isCodeEditorMode = false;
document.getElementById('codeEditorBtn').style.background = '';
document.getElementById('codeEditorBtn').style.color = '';
// Reload images after switching back
if (currentNoteId) {
loadNote(currentNoteId);
}
}
});
// View code for selected range with modal editor
document.getElementById('viewCodeBtn').addEventListener('click', () => {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('コード表示する範囲を選択してください。');
return;
}
const range = selection.getRangeAt(0);
const container = document.createElement('div');
container.appendChild(range.cloneContents());
const html = formatHTML(container.innerHTML);
// Create modal
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: var(--bg-secondary);
border-radius: 12px;
padding: 2rem;
width: 90%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
`;
const title = document.createElement('h3');
title.textContent = '選択範囲のHTMLコード';
title.style.cssText = `
margin: 0 0 1rem 0;
color: var(--text-primary);
font-size: 1.25rem;
`;
const textarea = document.createElement('textarea');
textarea.value = html;
textarea.style.cssText = `
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
resize: none;
outline: none;
margin-bottom: 1rem;
`;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 1rem;
justify-content: flex-end;
`;
const applyBtn = document.createElement('button');
applyBtn.textContent = '適用';
applyBtn.style.cssText = `
padding: 0.75rem 1.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`;
applyBtn.onmouseover = () => applyBtn.style.background = 'var(--accent-secondary)';
applyBtn.onmouseout = () => applyBtn.style.background = 'var(--accent-primary)';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'キャンセル';
cancelBtn.style.cssText = `
padding: 0.75rem 1.5rem;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`;
cancelBtn.onmouseover = () => cancelBtn.style.background = 'var(--bg-hover)';
cancelBtn.onmouseout = () => cancelBtn.style.background = 'var(--bg-tertiary)';
applyBtn.onclick = () => {
const newHTML = textarea.value;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHTML;
// Replace selection with new content
const newRange = document.createRange();
newRange.setStart(range.startContainer, range.startOffset);
newRange.setEnd(range.endContainer, range.endOffset);
newRange.deleteContents();
while (tempDiv.firstChild) {
newRange.insertNode(tempDiv.lastChild);
}
document.body.removeChild(modal);
autoSave();
};
cancelBtn.onclick = () => {
document.body.removeChild(modal);
};
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(applyBtn);
modalContent.appendChild(title);
modalContent.appendChild(textarea);
modalContent.appendChild(buttonContainer);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Focus textarea
textarea.focus();
textarea.select();
});
// Heading format buttons
document.getElementById('heading2Btn').addEventListener('click', () => {
formatHeading(2);
});
document.getElementById('heading3Btn').addEventListener('click', () => {
formatHeading(3);
});
document.getElementById('heading4Btn').addEventListener('click', () => {
formatHeading(4);
});
// Insert text box button
document.getElementById('insertBoxBtn').addEventListener('click', () => {
insertTextBox();
});
// Insert blue text box button
document.getElementById('insertBoxBlueBtn').addEventListener('click', () => {
insertTextBoxBlue();
});
// Insert link button
document.getElementById('insertLinkBtn').addEventListener('click', () => {
insertLink();
});
// Clear formatting button
document.getElementById('clearFormatBtn').addEventListener('click', () => {
clearFormatting();
});
// Theme toggle (optional enhancement)
document.getElementById('themeToggle').addEventListener('click', () => {
// Could implement light/dark theme toggle here
console.log('Theme toggle clicked');
});
// New folder button
document.getElementById('newFolderBtn').addEventListener('click', async () => {
const folderId = await createFolder();
await renderFoldersAndNotes();
});
// Export button and menu
const exportBtn = document.getElementById('exportBtn');
const exportMenu = document.getElementById('exportMenu');
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.toggle('hidden');
});
// Close export menu when clicking outside
document.addEventListener('click', (e) => {
if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) {
exportMenu.classList.add('hidden');
}
});
// Export Markdown
document.getElementById('exportMarkdownBtn').addEventListener('click', async () => {
if (currentNoteId) {
await exportNoteAsMarkdown(currentNoteId);
exportMenu.classList.add('hidden');
} else {
alert('エクスポートするノートを選択してください。');
}
});
// Export HTML
document.getElementById('exportHTMLBtn').addEventListener('click', async () => {
if (currentNoteId) {
await exportNoteAsHTML(currentNoteId);
exportMenu.classList.add('hidden');
} else {
alert('エクスポートするノートを選択してください。');
}
});
// Export all notes
document.getElementById('exportAllBtn').addEventListener('click', async () => {
await exportAllNotes();
exportMenu.classList.add('hidden');
});
// Export PDF
document.getElementById('exportPDFBtn').addEventListener('click', async () => {
if (currentNoteId) {
await exportNotePDF(currentNoteId);
exportMenu.classList.add('hidden');
} else {
alert('エクスポートするノートを選択してください。');
}
});
// Folder select change
document.getElementById('folderSelect').addEventListener('change', async (e) => {
if (!currentNoteId) return;
const folderId = parseInt(e.target.value);
if (folderId) {
await updateNote(currentNoteId, { folderId });
await renderFoldersAndNotes();
}
});
// Update folder select options
async function updateFolderSelect() {
const folderSelect = document.getElementById('folderSelect');
const folders = await getAllFolders();
folderSelect.innerHTML = '<option value="">フォルダ...</option>';
folders.forEach(folder => {
const option = document.createElement('option');
option.value = folder.id;
option.textContent = folder.name;
folderSelect.appendChild(option);
});
// Set current folder if note is loaded
if (currentNoteId) {
const note = await getNote(currentNoteId);
if (note && note.folderId) {
folderSelect.value = note.folderId;
}
}
}
// Theme toggle
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
// ===== Initialize App =====
async function initApp() {
try {
initTheme();
await initDB();
await updateFolderSelect();
await renderNotesList();
const notes = await getAllNotes();
if (notes.length > 0) {
await loadNote(notes[0].id);
} else {
showEmptyState();
}
} catch (error) {
console.error('Failed to initialize app:', error);
alert('アプリケーションの初期化に失敗しました。');
}
}
// Start the app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}
database.js
// ===== IndexedDB Setup =====
const DB_NAME = 'NotesDB';
const DB_VERSION = 4;
const STORE_NAME = 'notes';
const FOLDER_STORE = 'folders';
const DEFAULT_FOLDER_ID = 1;
let db;
// Initialize IndexedDB
function initDB() {
return new Promise((resolve, reject) => {
console.log('Initializing database:', DB_NAME, 'version:', DB_VERSION);
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('Database initialization failed:', request.error);
reject(request.error);
};
request.onsuccess = () => {
db = request.result;
console.log('Database opened successfully. Version:', db.version);
console.log('Available object stores:', Array.from(db.objectStoreNames));
// Check if images store exists
if (!db.objectStoreNames.contains('images')) {
console.error('Images store not found! Database needs to be upgraded.');
console.log('Current database version:', db.version);
console.log('Expected version:', DB_VERSION);
// Close the database and try to delete and recreate
db.close();
if (confirm('画像ストアが見つかりません。データベースを再作成しますか?\n(既存のノートは保持されます)')) {
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
deleteRequest.onsuccess = () => {
console.log('Database deleted successfully');
location.reload();
};
deleteRequest.onerror = () => {
console.error('Failed to delete database');
reject(new Error('Failed to delete database'));
};
} else {
reject(new Error('Images store not found'));
}
return;
}
resolve(db);
};
request.onupgradeneeded = (event) => {
console.log('Database upgrade needed. Old version:', event.oldVersion, 'New version:', event.newVersion);
db = event.target.result;
const transaction = event.target.transaction;
// 1. Notes Store
let notesStore;
if (!db.objectStoreNames.contains(STORE_NAME)) {
console.log('Creating notes store');
notesStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
notesStore.createIndex('folderId', 'folderId', { unique: false });
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false });
} else {
// Store exists, check for upgrades
console.log('Checking notes store for upgrades');
notesStore = transaction.objectStore(STORE_NAME);
if (!notesStore.indexNames.contains('folderId')) {
notesStore.createIndex('folderId', 'folderId', { unique: false });
}
if (!notesStore.indexNames.contains('updatedAt')) {
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
}
// 2. Folders Store
let folderStore;
if (!db.objectStoreNames.contains(FOLDER_STORE)) {
console.log('Creating folders store');
folderStore = db.createObjectStore(FOLDER_STORE, { keyPath: 'id', autoIncrement: true });
folderStore.createIndex('timestamp', 'timestamp', { unique: false });
folderStore.createIndex('parentId', 'parentId', { unique: false }); // Add parentId index
// Add default folder
folderStore.add({
id: DEFAULT_FOLDER_ID,
name: '未分類',
timestamp: Date.now(),
parentId: null // Top level
});
} else {
// Upgrade folders store if needed
folderStore = transaction.objectStore(FOLDER_STORE);
if (!folderStore.indexNames.contains('parentId')) {
console.log('Adding parentId index to folders store');
folderStore.createIndex('parentId', 'parentId', { unique: false });
// Note: Existing folders will have undefined parentId, which is treated as null/top-level usually
// or we might need to iterate and set them. But undefined in a query for 'null' might be tricky.
// However, we only need to query if specific parentId.
// Top level folders will have parentId == null (or undefined).
}
}
// 3. Images Store
if (!db.objectStoreNames.contains('images')) {
console.log('Creating images store');
const imagesStore = db.createObjectStore('images', { keyPath: 'id', autoIncrement: true });
imagesStore.createIndex('noteId', 'noteId', { unique: false });
}
console.log('Database upgrade completed');
};
});
}
// ===== Note CRUD Operations =====
// Create a new note
async function createNote(folderId = null) {
const note = {
title: '無題のノート',
content: '<p><br></p>',
folderId: folderId || DEFAULT_FOLDER_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.add(note);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all notes
function getAllNotes() {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.getAll();
request.onsuccess = () => {
const notes = request.result.sort((a, b) =>
new Date(b.updatedAt) - new Date(a.updatedAt)
);
resolve(notes);
};
request.onerror = () => reject(request.error);
});
}
// Get a single note by ID
function getNote(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update a note
async function updateNote(id, updates) {
const note = await getNote(id);
if (!note) return;
const updatedNote = {
...note,
...updates,
updatedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.put(updatedNote);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Delete a note
function deleteNote(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// ===== Folder CRUD Operations =====
// Get all folders
function getAllFolders() {
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readonly');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.getAll();
request.onsuccess = () => {
const folders = request.result.sort((a, b) => a.timestamp - b.timestamp);
resolve(folders);
};
request.onerror = () => reject(request.error);
});
}
// Create a new folder
// Create new folder
async function createFolder(name = '新規フォルダ', parentId = null) {
let folderName = name;
// If using default name, prompt user (legacy behavior preserved but flexible)
if (name === '新規フォルダ' && !parentId) { // Prompt only if triggered from main button without arg, approx
// Actually better to handle prompt in UI. But for now let's keep it adaptable.
// If called with just '新規フォルダ', it behaves like before?
// The prompt was: const folderName = prompt...
}
// We'll move the prompt to UI layer mostly, but here:
if (name === '新規フォルダ' && arguments.length === 0) {
folderName = prompt('フォルダ名を入力してください:', '新しいフォルダ');
}
if (!folderName || !folderName.trim()) return null;
const folder = {
name: folderName.trim(),
timestamp: Date.now(),
parentId: parentId
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.add(folder);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update folder name
async function updateFolderName(id, newName) {
const folders = await getAllFolders();
const folder = folders.find(f => f.id === id);
if (!folder) return;
// If newName is not provided, prompt for it
if (!newName) {
newName = prompt('フォルダ名を入力してください:', folder.name);
}
if (!newName || !newName.trim()) return;
folder.name = newName.trim();
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.put(folder);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Delete a folder
async function deleteFolder(id) {
if (id === DEFAULT_FOLDER_ID) {
alert('デフォルトフォルダは削除できません。');
return;
}
if (!confirm('このフォルダを削除しますか?フォルダ内のノートは「未分類」に移動されます。')) {
return;
}
const notes = await getNotesByFolder(id);
for (const note of notes) {
await updateNote(note.id, { folderId: DEFAULT_FOLDER_ID });
}
return new Promise((resolve, reject) => {
const transaction = db.transaction([FOLDER_STORE], 'readwrite');
const objectStore = transaction.objectStore(FOLDER_STORE);
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Get notes by folder
async function getNotesByFolder(folderId) {
const allNotes = await getAllNotes();
return allNotes.filter(note => (note.folderId || DEFAULT_FOLDER_ID) === folderId);
}
editor.js
// ===== Editor Functions =====
// Custom Undo/Redo Stack
let undoStack = [];
let redoStack = [];
let isUndoRedoOperation = false;
const MAX_UNDO_STACK = 50;
function saveToUndoStack() {
if (isUndoRedoOperation) return;
const editor = document.getElementById('editor');
if (!editor) return;
const state = {
html: editor.innerHTML,
timestamp: Date.now()
};
undoStack.push(state);
if (undoStack.length > MAX_UNDO_STACK) {
undoStack.shift();
}
// Clear redo stack when new action is performed
redoStack = [];
}
function performUndo() {
if (undoStack.length === 0) return;
const editor = document.getElementById('editor');
if (!editor) return;
// Save current state to redo stack
redoStack.push({
html: editor.innerHTML,
timestamp: Date.now()
});
// Restore previous state
const previousState = undoStack.pop();
isUndoRedoOperation = true;
editor.innerHTML = previousState.html;
isUndoRedoOperation = false;
// Reload images if needed
if (typeof loadImagesInEditor === 'function') {
loadImagesInEditor();
}
}
function performRedo() {
if (redoStack.length === 0) return;
const editor = document.getElementById('editor');
if (!editor) return;
// Save current state to undo stack
undoStack.push({
html: editor.innerHTML,
timestamp: Date.now()
});
// Restore redo state
const redoState = redoStack.pop();
isUndoRedoOperation = true;
editor.innerHTML = redoState.html;
isUndoRedoOperation = false;
// Reload images if needed
if (typeof loadImagesInEditor === 'function') {
loadImagesInEditor();
}
}
// Auto-save with debounce
function autoSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (!currentNoteId) return;
// Don't auto-save if in code editor mode to prevent saving escaped HTML
if (isCodeEditorMode) return;
const title = document.getElementById('noteTitle').value.trim() || '無題のノート';
const editor = document.getElementById('editor');
// Clone editor content to preserve image IDs
const tempDiv = document.createElement('div');
tempDiv.innerHTML = editor.innerHTML;
// Convert blob URLs back to data-image-id for storage
const images = tempDiv.querySelectorAll('img[data-image-id]');
images.forEach(img => {
// Keep only the data-image-id attribute, remove src
const imageId = img.getAttribute('data-image-id');
img.removeAttribute('src');
img.setAttribute('data-image-id', imageId);
});
const content = tempDiv.innerHTML;
await updateNote(currentNoteId, { title, content });
await renderNotesList();
}, 500);
}
// ... (skipping lines)
// Code editor toggle
let isCodeEditorMode = false;
function toggleCodeEditor() {
const editor = document.getElementById('editor');
if (!isCodeEditorMode) {
// Switch to code editor mode
const htmlContent = editor.innerHTML;
const formattedHTML = formatHTML(htmlContent);
editor.textContent = formattedHTML;
editor.style.fontFamily = 'monospace';
editor.style.whiteSpace = 'pre-wrap';
editor.style.fontSize = '0.9rem';
isCodeEditorMode = true;
document.getElementById('codeEditorBtn').style.background = 'var(--accent-primary)';
document.getElementById('codeEditorBtn').style.color = 'white';
} else {
// Switch back to visual editor mode
const codeContent = editor.textContent;
editor.innerHTML = codeContent;
editor.style.fontFamily = '';
editor.style.whiteSpace = '';
editor.style.fontSize = '';
isCodeEditorMode = false;
document.getElementById('codeEditorBtn').style.background = '';
document.getElementById('codeEditorBtn').style.color = '';
// Reload images after switching back (blobs need to be restored)
if (typeof loadImagesInEditor === 'function') {
loadImagesInEditor();
}
// Save the updated HTML
autoSave();
}
}
// ===== Formatting Functions =====
// Format selected text or current line as heading
function formatHeading(level) {
const editor = document.getElementById('editor');
const selection = window.getSelection();
if (selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
let selectedText = range.toString();
// If no text is selected, get the current block element
if (!selectedText) {
let node = range.startContainer;
// If we're in a text node, get its parent
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement;
}
// Find the closest block element
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.tagName === 'P' || node.tagName === 'DIV' ||
node.tagName === 'H1' || node.tagName === 'H2' ||
node.tagName === 'H3' || node.tagName === 'H4' ||
node.classList.contains('text-box') ||
node.classList.contains('text-box-blue'))) {
selectedText = node.textContent;
// Create heading element
const heading = document.createElement(`h${level}`);
heading.textContent = selectedText;
// Replace the block element with heading
node.parentNode.replaceChild(heading, node);
// Add a line break after heading
const br = document.createElement('br');
heading.after(br);
// Move cursor to the heading
const newRange = document.createRange();
newRange.setStart(heading, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
return;
}
node = node.parentElement;
}
// If we couldn't find a block element, just return
return;
}
// Create heading element
const heading = document.createElement(`h${level}`);
heading.textContent = selectedText;
// Replace selection with heading
range.deleteContents();
range.insertNode(heading);
// Add a line break after heading
const br = document.createElement('br');
heading.after(br);
// Move cursor after the heading
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// Insert empty line
// Insert empty line
function insertEmptyLine() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const editor = document.getElementById('editor');
const range = selection.getRangeAt(0);
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
// Find the closest block element
let block = container.closest('p, h1, h2, h3, h4, li, .text-box, .text-box-blue, .graybox, .check');
// If no specific block found, or if the block is the editor itself (which shouldn't happen with above selector but safe check)
// We should check if the found block is actually inside the editor
if (block && !editor.contains(block)) {
block = null;
}
// Create paragraph
const p = document.createElement('p');
// We must use <br> for the paragraph to be visible and focusable in contenteditable
// An empty <p></p> has 0 height and cannot hold a cursor in many browsers
p.innerHTML = '<br>';
if (block) {
block.after(p);
} else {
// Fallback: insert at cursor position or append to editor
// If the cursor is strictly inside the editor but not in a block (e.g. text node directly in editor)
// We can just insert the p node at the range.
range.insertNode(p);
// After insertion, we might want to ensure it's a block behavior (breaks line)
// Since p is block, it breaks automatically.
// We don't need to do anything else.
}
// Move cursor to new line
const newRange = document.createRange();
newRange.setStart(p, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
}
// Format selected text as paragraph
function formatParagraph() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
if (selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
alert('テキストを選択してください。');
return;
}
// Create paragraph element
const paragraph = document.createElement('p');
paragraph.textContent = selectedText;
// Replace selection with paragraph
range.deleteContents();
range.insertNode(paragraph);
// Add a line break after paragraph
const br = document.createElement('br');
paragraph.after(br);
// Move cursor after the paragraph
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// Insert text box
function insertTextBox() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create text box
const textBox = document.createElement('div');
textBox.className = 'text-box';
textBox.contentEditable = 'true';
textBox.textContent = 'ここに入力...';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(textBox);
// Add line break after box
const br = document.createElement('br');
textBox.after(br);
// Move cursor after the box
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
editor.appendChild(textBox);
editor.appendChild(document.createElement('br'));
}
// Focus on the text box
textBox.focus();
// Select placeholder text
const textRange = document.createRange();
textRange.selectNodeContents(textBox);
selection.removeAllRanges();
selection.addRange(textRange);
autoSave();
}
// Insert blue text box
function insertTextBoxBlue() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create blue text box
const textBox = document.createElement('div');
textBox.className = 'text-box-blue';
textBox.contentEditable = 'true';
textBox.textContent = 'ここに入力...';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(textBox);
// Add line break after box
const br = document.createElement('br');
textBox.after(br);
// Move cursor after the box
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
editor.appendChild(textBox);
editor.appendChild(document.createElement('br'));
}
// Focus on the text box
textBox.focus();
// Select placeholder text
const textRange = document.createRange();
textRange.selectNodeContents(textBox);
selection.removeAllRanges();
selection.addRange(textRange);
autoSave();
}
// Insert gray box (div)
function insertGrayBox() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create gray box (div)
const grayBox = document.createElement('div');
grayBox.className = 'graybox';
grayBox.textContent = 'グレーボックス';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// Check if we are inside another block to prevent nesting issues
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
// If inside a p tag or similar, try to insert after it
if (container.closest('p, h1, h2, h3, h4, .text-box, .text-box-blue, .graybox, .check')) {
const block = container.closest('p, h1, h2, h3, h4, .text-box, .text-box-blue, .graybox, .check');
block.after(grayBox);
} else {
range.insertNode(grayBox);
}
// Add line break after box
const br = document.createElement('br');
grayBox.after(br);
// Move cursor inside the box
const newRange = document.createRange();
newRange.selectNodeContents(grayBox);
selection.removeAllRanges();
selection.addRange(newRange);
} else {
editor.appendChild(grayBox);
editor.appendChild(document.createElement('br'));
}
autoSave();
}
// Insert check heading
function insertCheckHeading() {
const editor = document.getElementById('editor');
const selection = window.getSelection();
// Create check heading
const checkHeading = document.createElement('div');
checkHeading.className = 'check';
checkHeading.contentEditable = 'true';
checkHeading.textContent = 'チェック見出し';
// Insert at cursor or append
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(checkHeading);
// Add line break after
const br = document.createElement('br');
checkHeading.after(br);
// Move cursor after
range.setStartAfter(br);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
editor.appendChild(checkHeading);
editor.appendChild(document.createElement('br'));
}
// Focus on the heading
checkHeading.focus();
// Select placeholder text
const textRange = document.createRange();
textRange.selectNodeContents(checkHeading);
selection.removeAllRanges();
selection.addRange(textRange);
autoSave();
}
// Insert link
function insertLink() {
const selection = window.getSelection();
if (selection.rangeCount === 0) {
alert('リンクにするテキストを選択してください。');
return;
}
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
alert('リンクにするテキストを選択してください。');
return;
}
// Prompt for URL
const url = prompt('リンク先のURLを入力してください:', 'https://');
if (!url || url === 'https://') {
return;
}
// Create link element
const link = document.createElement('a');
link.href = url;
link.textContent = selectedText;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Replace selection with link
range.deleteContents();
range.insertNode(link);
// Move cursor after the link
range.setStartAfter(link);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
autoSave();
}
// Clear formatting from selected text (line-based)
function clearFormatting() {
const selection = window.getSelection();
const editor = document.getElementById('editor');
if (selection.rangeCount === 0) {
return;
}
// Save current state to undo stack
saveToUndoStack();
const range = selection.getRangeAt(0);
// If no selection, clear formatting of the current block element
if (range.collapsed) {
let node = range.startContainer;
// Find the parent block element
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.tagName === 'H2' || node.tagName === 'H3' || node.tagName === 'H4' ||
node.classList.contains('text-box') || node.classList.contains('text-box-blue'))) {
// Get text content
const textContent = node.textContent;
// Create plain paragraph
const p = document.createElement('p');
p.textContent = textContent;
// Replace the element
node.parentNode.replaceChild(p, node);
// Move cursor into the new paragraph
const newRange = document.createRange();
newRange.setStart(p, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
return;
}
node = node.parentNode;
}
return;
}
// For selected text, extract contents and strip formatting
const fragment = range.extractContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Recursively remove all span.red, span.gray, strong, b tags
function unwrapFormattingTags(element) {
const toUnwrap = element.querySelectorAll('span.red, span.gray, strong, b, em, i');
toUnwrap.forEach(tag => {
const parent = tag.parentNode;
while (tag.firstChild) {
parent.insertBefore(tag.firstChild, tag);
}
parent.removeChild(tag);
});
}
unwrapFormattingTags(tempDiv);
// Create a new fragment from the cleaned content
const cleanedFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
cleanedFragment.appendChild(tempDiv.firstChild);
}
// Insert the cleaned fragment back
range.insertNode(cleanedFragment);
autoSave();
}
// ===== Code Editor Modal =====
// Format HTML with line breaks
function formatHTML(html) {
// Add line breaks after closing block tags
const blockTags = ['h2', 'h3', 'h4', 'p', 'div', 'img', 'br'];
let formatted = html;
blockTags.forEach(tag => {
// Add line break after closing tags
formatted = formatted.replace(new RegExp(`</${tag}>`, 'gi'), `</${tag}>\n`);
// Add line break after self-closing img tags
if (tag === 'img' || tag === 'br') {
formatted = formatted.replace(new RegExp(`<${tag}([^>]*)>`, 'gi'), `<${tag}$1>\n`);
}
});
// Clean up multiple consecutive line breaks
formatted = formatted.replace(/\n{3,}/g, '\n\n');
return formatted.trim();
}
// View and edit code for selected range
function showCodeEditorModal() {
const selection = window.getSelection();
let savedRange = null;
let container = document.createElement('div');
let targetNode = null;
let isOuterHTML = false;
if (selection.rangeCount === 0 || selection.isCollapsed) {
// If no selection, try to get the current line/block
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// Find the closest block element or box
// Priority: surrounding box > heading/p
targetNode = currentNode.closest('.text-box, .text-box-blue, .graybox, .check, p, h1, h2, h3, h4, div:not(.editor), li') || currentNode;
// If we found a specific target node (not just the editor div), use its Outer HTML
if (targetNode && targetNode.id !== 'editor') {
isOuterHTML = true;
// We use outerHTML to allow editing the tag attributes and class
container.textContent = targetNode.outerHTML; // Store raw HTML in textContent to avoid parsing
} else {
alert('コード表示する範囲を選択してください。');
return;
}
} else {
alert('コード表示する範囲を選択してください。');
return;
}
} else {
// Range selection
savedRange = selection.getRangeAt(0).cloneRange();
// Check if the common ancestor is a box
let commonAncestor = savedRange.commonAncestorContainer;
if (commonAncestor.nodeType === Node.TEXT_NODE) {
commonAncestor = commonAncestor.parentElement;
}
const box = commonAncestor.closest('.text-box, .text-box-blue, .graybox, .check');
if (box) {
// If selection is inside a box, edit the WHOLE box without confirmation
targetNode = box;
isOuterHTML = true;
container.textContent = targetNode.outerHTML;
} else {
container.appendChild(savedRange.cloneContents());
}
}
// Get HTML content
let html;
if (isOuterHTML) {
html = formatHTML(container.textContent);
} else {
html = formatHTML(container.innerHTML);
}
// Create modal
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: var(--bg-secondary);
border-radius: 12px;
padding: 2rem;
width: 90%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
`;
const title = document.createElement('h3');
title.textContent = isOuterHTML ? '要素のHTMLコード (外側も編集可能)' : '選択範囲のHTMLコード';
title.style.cssText = `
margin: 0 0 1rem 0;
color: var(--text-primary);
font-size: 1.25rem;
`;
const textarea = document.createElement('textarea');
textarea.value = html;
textarea.rows = 20; // Set number of visible rows
textarea.style.cssText = `
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
resize: vertical;
outline: none;
margin-bottom: 1rem;
min-height: 400px;
`;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 1rem;
justify-content: flex-end;
`;
const applyBtn = document.createElement('button');
applyBtn.textContent = '適用';
applyBtn.style.cssText = `
padding: 0.75rem 1.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`;
applyBtn.onmouseover = () => applyBtn.style.background = 'var(--accent-secondary)';
applyBtn.onmouseout = () => applyBtn.style.background = 'var(--accent-primary)';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'キャンセル';
cancelBtn.style.cssText = `
padding: 0.75rem 1.5rem;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`;
cancelBtn.onmouseover = () => cancelBtn.style.background = 'var(--bg-hover)';
cancelBtn.onmouseout = () => cancelBtn.style.background = 'var(--bg-tertiary)';
applyBtn.onclick = () => {
const newHTML = textarea.value;
if (targetNode && isOuterHTML) {
// Replace the outerHTML of the target node
// Since we can't set outerHTML directly to a text string effectively if it has multiple siblings or invalid structure easily without parsing
// We create a temp div
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHTML;
// Check if parsing worked
if (tempDiv.childNodes.length === 0 && newHTML.trim() !== '') {
// Maybe text only?
targetNode.outerHTML = newHTML;
} else {
targetNode.replaceWith(...tempDiv.childNodes);
}
} else if (targetNode) {
// Should not happen with current logic but fallback
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHTML;
targetNode.replaceWith(...tempDiv.childNodes);
} else if (savedRange) {
// If we edited a selection range logic (fallback)
// Use the saved range
savedRange.deleteContents();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHTML;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
savedRange.insertNode(fragment);
}
document.body.removeChild(modal);
autoSave();
};
cancelBtn.onclick = () => {
document.body.removeChild(modal);
};
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(applyBtn);
modalContent.appendChild(title);
modalContent.appendChild(textarea);
modalContent.appendChild(buttonContainer);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Focus textarea
textarea.focus();
textarea.select();
}
// ===== Text Formatting Functions =====
// Helper function to apply a class to all text nodes while preserving structure
function applyClassToTextNodes(element, className) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
null
);
const nodesToWrap = [];
let node;
while (node = walker.nextNode()) {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
nodesToWrap.push(node);
}
}
// Wrap each text node in a span with the class
nodesToWrap.forEach(textNode => {
const span = document.createElement('span');
span.className = className;
span.textContent = textNode.textContent;
textNode.parentNode.replaceChild(span, textNode);
});
}
// Format selected text as gray
function formatGrayText() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('テキストを選択してください。');
return;
}
// Save current state to undo stack
saveToUndoStack();
const range = selection.getRangeAt(0);
// Extract the selected content preserving structure
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Apply gray class to all text nodes while preserving structure
applyClassToTextNodes(tempDiv, 'gray');
// Create a document fragment to hold the formatted content
const formattedFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
formattedFragment.appendChild(tempDiv.firstChild);
}
// Delete the selected content and insert the formatted fragment
range.deleteContents();
range.insertNode(formattedFragment);
autoSave();
}
// Format selected text as red
function formatRedText() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('テキストを選択してください。');
return;
}
// Save current state to undo stack
saveToUndoStack();
const range = selection.getRangeAt(0);
// Extract the selected content preserving structure
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Apply red class to all text nodes while preserving structure
applyClassToTextNodes(tempDiv, 'red');
// Create a document fragment to hold the formatted content
const formattedFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
formattedFragment.appendChild(tempDiv.firstChild);
}
// Delete the selected content and insert the formatted fragment
range.deleteContents();
range.insertNode(formattedFragment);
autoSave();
}
// Format selected text as black
function formatBlackText() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('テキストを選択してください。');
return;
}
// Save current state to undo stack
saveToUndoStack();
const range = selection.getRangeAt(0);
// Extract the selected content preserving structure
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Apply black class to all text nodes while preserving structure
applyClassToTextNodes(tempDiv, 'black');
// Create a document fragment to hold the formatted content
const formattedFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
formattedFragment.appendChild(tempDiv.firstChild);
}
// Delete the selected content and insert the formatted fragment
range.deleteContents();
range.insertNode(formattedFragment);
autoSave();
}
// Format selected text as bold (with toggle)
function formatBoldText() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('テキストを選択してください。');
return;
}
// Save current state to undo stack
saveToUndoStack();
// Use execCommand for bold which automatically handles toggle and undo/redo
document.execCommand('bold', false, null);
autoSave();
}
// Code editor toggle moved to top to fix autoSave dep
// (Removed duplicate)
// ===== Element Movement Functions =====
// Move element up
function moveElementUp() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const editor = document.getElementById('editor');
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// If we're in a text node, get its parent
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// Find the closest block element
const element = currentNode.closest('p, h1, h2, h3, h4, .text-box, .text-box-blue, .graybox, .check, div:not(#editor)');
if (!element || element === editor) return;
// Get the previous sibling (skip text nodes that are just whitespace)
let previousSibling = element.previousElementSibling;
// Skip BR elements
while (previousSibling && previousSibling.tagName === 'BR') {
previousSibling = previousSibling.previousElementSibling;
}
if (!previousSibling) return; // Already at the top
// Save to undo stack before making changes
saveToUndoStack();
// Move the element before its previous sibling
element.parentNode.insertBefore(element, previousSibling);
// Restore selection/cursor to the moved element
const newRange = document.createRange();
newRange.selectNodeContents(element);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
}
// Move element down
function moveElementDown() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const editor = document.getElementById('editor');
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// If we're in a text node, get its parent
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// Find the closest block element
const element = currentNode.closest('p, h1, h2, h3, h4, .text-box, .text-box-blue, .graybox, .check, div:not(#editor)');
if (!element || element === editor) return;
// Get the next sibling (skip text nodes that are just whitespace)
let nextSibling = element.nextElementSibling;
// Skip BR elements
while (nextSibling && nextSibling.tagName === 'BR') {
nextSibling = nextSibling.nextElementSibling;
}
if (!nextSibling) return; // Already at the bottom
// Save to undo stack before making changes
saveToUndoStack();
// Move the element after its next sibling
if (nextSibling.nextSibling) {
element.parentNode.insertBefore(element, nextSibling.nextSibling);
} else {
element.parentNode.appendChild(element);
}
// Restore selection/cursor to the moved element
const newRange = document.createRange();
newRange.selectNodeContents(element);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
}
export.js
// ===== Export Functions =====
// Helper function to download file
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Sanitize filename
function sanitizeFilename(filename) {
return filename.replace(/[<>:"/\\|?*]/g, '_');
}
// Convert blob to base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// Process content to embed images as base64
async function processContentForExport(content) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
// Find all images with data-image-id
const images = tempDiv.querySelectorAll('img[data-image-id]');
for (const img of images) {
const imageId = parseInt(img.getAttribute('data-image-id'));
try {
const imageData = await getImage(imageId);
if (imageData && imageData.blob) {
// Convert blob to base64
const base64 = await blobToBase64(imageData.blob);
img.src = base64;
// Remove data-image-id attribute for export
img.removeAttribute('data-image-id');
}
} catch (error) {
console.error('Failed to load image for export:', imageId, error);
}
}
return tempDiv.innerHTML;
}
// Export note as HTML
async function exportNoteAsHTML(noteId) {
const note = await getNote(noteId);
if (!note) return;
// Process content to embed images as base64
const processedContent = await processContentForExport(note.content);
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(note.title)}</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #333;
background: #ffffff;
}
h1 {
color: #1a1a2e;
text-align: center;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
padding: 20px;
margin-bottom: 40px;
font-weight: 600;
}
h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: #333;
}
h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 40px;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: #333;
}
.text-box {
background: #f3f4f6;
border: 1px solid #ddd;
border-radius: 2px;
padding: 1rem;
margin: 1rem 0;
}
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 2px;
padding: 1rem;
margin: 1rem 0;
}
/* Gray Box (User Custom) */
.graybox {
background-color: rgba(250, 250, 250, 0.48);
outline: 1px solid rgba(228, 228, 228, 0.87);
color: #444;
overflow: auto;
display: block;
padding: 20px 20px 25px;
margin-bottom: 40px;
line-height: 1.7;
border-radius: 2px;
}
.graybox p {
margin-bottom: 0;
}
/* Check Heading */
.check {
position: relative;
padding-left: 32px;
margin: 32px 0 40px;
font-weight: 600;
font-size: 1.2rem;
color: #333;
line-height: 1.5;
}
.check::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 14px;
height: 8px;
border-left: 3px solid #4b6cb7;
border-bottom: 3px solid #4b6cb7;
transform: translateY(-65%) rotate(-45deg);
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
p {
margin-bottom: 40px;
}
a {
color: #6366f1;
text-decoration: underline;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${processedContent}
</body>
</html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
downloadFile(blob, `${sanitizeFilename(note.title)}.html`);
}
// Export note as PDF
async function exportNotePDF(noteId) {
const note = await getNote(noteId);
if (!note) return;
// Process content to embed images as base64
const processedContent = await processContentForExport(note.content);
// Create a temporary print window
const printWindow = window.open('', '_blank');
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(note.title)}</title>
<style>
@media print {
@page {
margin-top: 00mm;
margin-bottom: 10mm;
margin-left: 20mm;
margin-right: 20mm;
}
body {
padding-top: 0;
margin-top: 0;
}
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #333;
background: #ffffff;
}
h1 {
color: #1a1a2e;
text-align: center;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
padding: 20px;
margin-bottom: 40px;
font-weight: 600;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: #333;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 1.62em;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: #333;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.text-box {
background: #f3f4f6;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
p {
margin-bottom: 1rem;
}
a {
color: #6366f1;
text-decoration: underline;
}
</style>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${processedContent}
</body>
</html>`;
printWindow.document.write(html);
printWindow.document.close();
// Wait for content to load, then print
printWindow.onload = function () {
printWindow.print();
// Close after printing or canceling
printWindow.onafterprint = function () {
printWindow.close();
};
};
}
// Export all notes as ZIP
async function exportAllNotes() {
if (typeof JSZip === 'undefined') {
alert('JSZipライブラリが読み込まれていません。');
return;
}
const notes = await getAllNotes();
if (notes.length === 0) {
alert('エクスポートするノートがありません。');
return;
}
const zip = new JSZip();
for (const note of notes) {
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(note.title)}</title>
</head>
<body>
<h1>${escapeHtml(note.title)}</h1>
${note.content}
</body>
</html>`;
zip.file(`${sanitizeFilename(note.title)}.html`, html);
}
const blob = await zip.generateAsync({ type: 'blob' });
downloadFile(blob, 'notes.zip');
}
image.js
// ===== Image Storage Functions =====
// Save image to IndexedDB
function saveImage(blob, noteId) {
console.log('saveImage called with:', { blob, noteId, currentNoteId });
if (!db) {
console.error('Database not initialized');
return Promise.reject(new Error('Database not initialized'));
}
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(['images'], 'readwrite');
const store = transaction.objectStore('images');
const image = {
blob: blob,
noteId: noteId || currentNoteId,
createdAt: new Date().toISOString()
};
console.log('Saving image to IndexedDB:', image);
const request = store.add(image);
request.onsuccess = () => {
console.log('Image saved successfully with ID:', request.result);
resolve(request.result);
};
request.onerror = () => {
console.error('Error saving image:', request.error);
reject(request.error);
};
} catch (error) {
console.error('Exception in saveImage:', error);
reject(error);
}
});
}
// Get image from IndexedDB
function getImage(id) {
console.log('getImage called with ID:', id);
if (!db) {
console.error('Database not initialized');
return Promise.reject(new Error('Database not initialized'));
}
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(['images'], 'readonly');
const store = transaction.objectStore('images');
const request = store.get(id);
request.onsuccess = () => {
console.log('Image retrieved:', request.result);
resolve(request.result);
};
request.onerror = () => {
console.error('Error getting image:', request.error);
reject(request.error);
};
} catch (error) {
console.error('Exception in getImage:', error);
reject(error);
}
});
}
// Delete all images for a note
function deleteImagesByNoteId(noteId) {
console.log('deleteImagesByNoteId called with noteId:', noteId);
if (!db) {
console.error('Database not initialized');
return Promise.reject(new Error('Database not initialized'));
}
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(['images'], 'readwrite');
const store = transaction.objectStore('images');
const index = store.index('noteId');
const request = index.openCursor(IDBKeyRange.only(noteId));
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
console.log('Deleting image:', cursor.value);
cursor.delete();
cursor.continue();
} else {
console.log('All images deleted for note:', noteId);
resolve();
}
};
request.onerror = () => {
console.error('Error deleting images:', request.error);
reject(request.error);
};
} catch (error) {
console.error('Exception in deleteImagesByNoteId:', error);
reject(error);
}
});
}
// ===== Image Insertion Functions =====
// Insert image into editor
async function insertImage(file) {
console.log('insertImage called with file:', file);
if (!file || !file.type.startsWith('image/')) {
console.error('Invalid file type:', file);
alert('有効な画像ファイルを選択してください。');
return;
}
if (!currentNoteId) {
console.error('No note selected');
alert('ノートを選択してから画像を挿入してください。');
return;
}
try {
console.log('Starting image insertion process...');
// Save image to IndexedDB
const imageId = await saveImage(file, currentNoteId);
console.log('Image saved with ID:', imageId);
// Create blob URL for display
const blobUrl = URL.createObjectURL(file);
console.log('Blob URL created:', blobUrl);
const img = document.createElement('img');
img.src = blobUrl;
img.setAttribute('data-image-id', imageId);
img.style.maxWidth = '100%';
img.style.height = 'auto';
console.log('Image element created:', img);
const editor = document.getElementById('editor');
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.insertNode(img);
range.setStartAfter(img);
range.collapse(true);
console.log('Image inserted at cursor position');
} else {
editor.appendChild(img);
console.log('Image appended to editor');
}
autoSave();
console.log('Image insertion completed successfully');
} catch (error) {
console.error('Failed to insert image:', error);
console.error('Error stack:', error.stack);
alert('画像の挿入に失敗しました: ' + error.message);
}
}
// Load images in editor content
async function loadImagesInEditor() {
console.log('loadImagesInEditor called');
const editor = document.getElementById('editor');
const images = editor.querySelectorAll('img[data-image-id]');
console.log('Found', images.length, 'images to load');
for (const img of images) {
const imageId = parseInt(img.getAttribute('data-image-id'));
console.log('Loading image with ID:', imageId);
try {
const imageData = await getImage(imageId);
if (imageData && imageData.blob) {
const blobUrl = URL.createObjectURL(imageData.blob);
img.src = blobUrl;
console.log('Image loaded successfully:', imageId);
} else {
console.warn('Image data not found for ID:', imageId);
}
} catch (error) {
console.error('Failed to load image:', imageId, error);
}
}
}
main.js
// ===== Global Variables =====
let currentNoteId = null;
let currentFolderId = null;
let saveTimeout = null;
let collapsedFolders = new Set();
let isEditMode = false;
// ===== Theme Functions =====
// Initialize theme
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
}
// Toggle theme
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// ===== Edit Mode Functions =====
// Toggle edit mode
function toggleEditMode() {
isEditMode = !isEditMode;
const toolbar = document.getElementById('floatingToolbar');
const editModeBtn = document.getElementById('editModeBtn');
if (isEditMode) {
toolbar.classList.remove('hidden');
editModeBtn.classList.add('active');
} else {
toolbar.classList.add('hidden');
editModeBtn.classList.remove('active');
}
// Save state to localStorage
localStorage.setItem('editMode', isEditMode);
}
// ===== Event Listeners =====
// New note button
document.getElementById('newNoteBtn').addEventListener('click', async () => {
const noteId = await createNote(currentFolderId);
await loadNote(noteId);
await renderNotesList();
});
// New folder button
document.getElementById('newFolderBtn').addEventListener('click', async () => {
const folderId = await createFolder();
await renderFoldersAndNotes();
});
// Note title input
document.getElementById('noteTitle').addEventListener('input', () => {
autoSave();
});
// Editor input
document.getElementById('editor').addEventListener('input', () => {
autoSave();
});
// Image click handler - open in new tab
document.getElementById('editor').addEventListener('click', (e) => {
if (e.target.tagName === 'IMG') {
window.open(e.target.src, '_blank');
}
});
// Paste handling - prevent pasting images as base64
document.getElementById('editor').addEventListener('paste', (e) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
insertImage(file);
return;
}
}
});
// Link click handling (Ctrl+Click to open)
document.getElementById('editor').addEventListener('click', (e) => {
if (e.target.tagName === 'A' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
window.open(e.target.href, '_blank', 'noopener,noreferrer');
}
});
// Image insert button
document.getElementById('insertImageBtn').addEventListener('click', () => {
document.getElementById('imageInput').click();
});
// Image upload button
document.getElementById('imageInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
insertImage(file);
e.target.value = ''; // Reset input
}
});
// Code editor toggle button
document.getElementById('codeEditorBtn').addEventListener('click', () => {
toggleCodeEditor();
});
// View code button
document.getElementById('viewCodeBtn').addEventListener('click', () => {
showCodeEditorModal();
});
// Heading format buttons
document.getElementById('heading2Btn').addEventListener('click', () => {
formatHeading(2);
});
document.getElementById('heading3Btn').addEventListener('click', () => {
formatHeading(3);
});
document.getElementById('heading4Btn').addEventListener('click', () => {
formatHeading(4);
});
// Paragraph format button
document.getElementById('paragraphBtn').addEventListener('click', () => {
formatParagraph();
});
// Insert text box button
document.getElementById('insertBoxBtn').addEventListener('click', () => {
insertTextBox();
});
// Insert blue text box button
document.getElementById('insertBoxBlueBtn').addEventListener('click', () => {
insertTextBoxBlue();
});
// Insert gray box button
document.getElementById('insertGrayBoxBtn').addEventListener('click', () => {
insertGrayBox();
});
// Insert check heading button
document.getElementById('insertCheckHeadingBtn').addEventListener('click', () => {
insertCheckHeading();
});
// Insert empty line button
document.getElementById('insertEmptyLineBtn').addEventListener('click', () => {
insertEmptyLine();
});
// Disable Ctrl+S (Browser Save)
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
// Since auto-save is implemented, we can just notify or do nothing
// autoSave() is called on input, so no need to call it explicitly here unless needed
}
});
// Insert link button
document.getElementById('insertLinkBtn').addEventListener('click', () => {
insertLink();
});
// Clear formatting button
document.getElementById('clearFormatBtn').addEventListener('click', () => {
clearFormatting();
});
// Gray text button
document.getElementById('grayTextBtn').addEventListener('click', () => {
formatGrayText();
});
// Red text button
document.getElementById('redTextBtn').addEventListener('click', () => {
formatRedText();
});
// Black text button
document.getElementById('blackTextBtn').addEventListener('click', () => {
formatBlackText();
});
// Bold text button
document.getElementById('boldTextBtn').addEventListener('click', () => {
formatBoldText();
});
// Edit mode toggle
document.getElementById('editModeBtn').addEventListener('click', () => {
toggleEditMode();
});
// Floating toolbar buttons
document.getElementById('floatingUndoBtn').addEventListener('click', () => {
performUndo();
});
document.getElementById('floatingRedoBtn').addEventListener('click', () => {
performRedo();
});
document.getElementById('floatingMoveUpBtn').addEventListener('click', () => {
moveElementUp();
});
document.getElementById('floatingMoveDownBtn').addEventListener('click', () => {
moveElementDown();
});
// Theme toggle
document.getElementById('themeToggle').addEventListener('click', () => {
toggleTheme();
});
// Export button and menu
const exportBtn = document.getElementById('exportBtn');
const exportMenu = document.getElementById('exportMenu');
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.toggle('hidden');
});
// Close export menu when clicking outside
document.addEventListener('click', (e) => {
if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) {
exportMenu.classList.add('hidden');
}
});
// Export HTML
document.getElementById('exportHTMLBtn').addEventListener('click', async () => {
if (currentNoteId) {
await exportNoteAsHTML(currentNoteId);
exportMenu.classList.add('hidden');
} else {
alert('エクスポートするノートを選択してください。');
}
});
// Export all notes
document.getElementById('exportAllBtn').addEventListener('click', async () => {
await exportAllNotes();
exportMenu.classList.add('hidden');
});
// Export PDF
document.getElementById('exportPDFBtn').addEventListener('click', async () => {
if (currentNoteId) {
await exportNotePDF(currentNoteId);
exportMenu.classList.add('hidden');
} else {
alert('エクスポートするノートを選択してください。');
}
});
// Folder select change
document.getElementById('folderSelect').addEventListener('change', async (e) => {
if (!currentNoteId) return;
const folderId = parseInt(e.target.value);
if (folderId) {
await updateNote(currentNoteId, { folderId });
await renderFoldersAndNotes();
}
});
// Sidebar Resize Logic
if (typeof setupSidebarResize === 'function') {
setupSidebarResize();
} else {
// Wait for ui.js to load if script order is tricky, but here modules are loaded.
// However, ui.js functions might not be global unless attached to window or imported.
// Since we are using standard script tags without type=module, functions are global.
// But setupSidebarResize is defined in ui.js which loads BEFORE main.js?
// Check index.html: ui.js (259) -> main.js (262). Yes, it should be available.
}
// ===== Initialize App =====
// Initialize editor to use <p> tags instead of <div>
function initEditor() {
const editor = document.getElementById('editor');
// Set default paragraph separator to <p>
document.execCommand('defaultParagraphSeparator', false, 'p');
// Handle Enter key to ensure <p> tags are used
editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// If we're in a text node, get its parent
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// Check if we're in a heading or other special element
const isInHeading = currentNode.closest('h1, h2, h3, h4, h5, h6');
const isInTextBox = currentNode.closest('.text-box, .text-box-blue');
if (isInHeading || isInTextBox) {
// For headings and text boxes, create a new paragraph after
e.preventDefault();
const p = document.createElement('p');
p.innerHTML = '<br>';
const elementToInsertAfter = isInHeading || isInTextBox;
elementToInsertAfter.parentNode.insertBefore(p, elementToInsertAfter.nextSibling);
// Move cursor to the new paragraph
const newRange = document.createRange();
newRange.setStart(p, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
autoSave();
return;
}
// For normal cases, let the browser handle it with defaultParagraphSeparator
// This will create <p> tags automatically
}
});
}
async function initApp() {
try {
initTheme();
initEditor(); // Initialize editor settings
// Restore collapsed folders state
try {
const savedCollapsed = localStorage.getItem('collapsedFolders');
if (savedCollapsed) {
collapsedFolders = new Set(JSON.parse(savedCollapsed));
}
} catch (e) {
console.error('Failed to restore collapsed folders:', e);
}
// Restore edit mode state
try {
const savedEditMode = localStorage.getItem('editMode');
if (savedEditMode === 'true') {
isEditMode = true;
document.getElementById('floatingToolbar').classList.remove('hidden');
document.getElementById('editModeBtn').classList.add('active');
}
} catch (e) {
console.error('Failed to restore edit mode:', e);
}
await initDB();
await updateFolderSelect();
await renderNotesList();
// Restore last opened note
const savedNoteId = localStorage.getItem('currentNoteId');
if (savedNoteId) {
const noteExists = await getNote(parseInt(savedNoteId));
if (noteExists) {
await loadNote(parseInt(savedNoteId));
} else {
localStorage.removeItem('currentNoteId');
// Fallback to first note if saved note doesn't exist
const notes = await getAllNotes();
if (notes.length > 0) {
await loadNote(notes[0].id);
} else {
showEmptyState();
}
}
} else {
const notes = await getAllNotes();
if (notes.length > 0) {
await loadNote(notes[0].id);
} else {
showEmptyState();
}
}
} catch (error) {
console.error('Failed to initialize app:', error);
alert(`アプリケーションの初期化に失敗しました。\nエラー詳細: ${error.message || error}`);
}
}
// Start the app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}
ui.js
// ===== UI Functions =====
// Escape HTML helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Render folders and notes list
async function renderFoldersAndNotes() {
setupViewMode(); // Initialize View Mode listeners
const notesList = document.getElementById('notesList');
const allFolders = await getAllFolders();
// Create map for easy lookup
const folderMap = new Map();
allFolders.forEach(f => {
f.children = [];
folderMap.set(f.id, f);
});
// Build tree
const rootFolders = [];
allFolders.forEach(f => {
if (f.parentId && folderMap.has(f.parentId)) {
folderMap.get(f.parentId).children.push(f);
} else {
rootFolders.push(f);
}
});
notesList.innerHTML = '';
// Recursive render function
const renderFolderTree = async (folder, level = 0) => {
const notes = await getNotesByFolder(folder.id);
// Hide Default folder if simple empty (no notes, no subfolders)
if (folder.id === DEFAULT_FOLDER_ID && notes.length === 0 && (!folder.children || folder.children.length === 0)) {
return null;
}
const folderContainer = document.createElement('div');
folderContainer.className = 'folder-container';
const folderElement = createFolderElement(folder, level);
folderContainer.appendChild(folderElement);
const contentContainer = document.createElement('div');
contentContainer.className = 'folder-content';
contentContainer.dataset.folderId = folder.id;
if (collapsedFolders.has(folder.id)) {
contentContainer.classList.add('collapsed');
folderElement.querySelector('.folder-chevron').classList.add('collapsed');
}
// Render subfolders
if (folder.children && folder.children.length > 0) {
folder.children.sort((a, b) => a.timestamp - b.timestamp);
for (const child of folder.children) {
const childNode = await renderFolderTree(child, level + 1);
if (childNode) {
contentContainer.appendChild(childNode);
}
}
}
// Render notes
if (notes.length > 0) {
const notesContainer = document.createElement('div');
notesContainer.className = 'folder-notes';
notesContainer.dataset.folderId = folder.id; // Keep strict for drag/drop if added later
// Note: In new recursive struct, folder-notes is inside contentContainer
notes.forEach(note => {
const noteItem = createNoteElement(note);
// Add indentation
noteItem.style.paddingLeft = `${(level + 1) * 1.2 + 0.5}rem`;
notesContainer.appendChild(noteItem);
});
contentContainer.appendChild(notesContainer);
}
folderContainer.appendChild(contentContainer);
return folderContainer;
};
// Render roots
rootFolders.sort((a, b) => a.id === DEFAULT_FOLDER_ID ? -1 : a.timestamp - b.timestamp);
for (const folder of rootFolders) {
const folderNode = await renderFolderTree(folder, 0);
if (folderNode) {
notesList.appendChild(folderNode);
}
}
}
// Create folder element
function createFolderElement(folder, level) {
const div = document.createElement('div');
div.className = 'folder-item';
div.dataset.id = folder.id;
div.style.paddingLeft = `${level * 1.2 + 0.5}rem`;
const isCollapsed = collapsedFolders.has(folder.id);
div.innerHTML = `
<div class="folder-item-header">
<div class="folder-item-left">
<svg class="folder-chevron ${isCollapsed ? 'collapsed' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<svg class="folder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<div class="folder-item-title">${escapeHtml(folder.name)}</div>
</div>
<div class="folder-item-actions">
<button class="note-action-btn add-subfolder" title="サブフォルダ作成">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"></path>
</svg>
</button>
${folder.id !== DEFAULT_FOLDER_ID ? `
<button class="note-action-btn rename" title="名前を変更">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="note-action-btn delete" title="削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
` : ''}
</div>
</div>
`;
// Toggle collapse
const header = div.querySelector('.folder-item-header');
header.addEventListener('click', (e) => {
if (!e.target.closest('.note-action-btn')) {
toggleFolder(folder.id);
}
});
// Add Subfolder
div.querySelector('.add-subfolder').addEventListener('click', async (e) => {
e.stopPropagation();
const subName = prompt('サブフォルダ名を入力してください:');
if (subName) {
await createFolder(subName, folder.id);
await renderFoldersAndNotes();
}
});
// Rename button
if (folder.id !== DEFAULT_FOLDER_ID) {
div.querySelector('.rename').addEventListener('click', (e) => {
e.stopPropagation();
startFolderRename(div, folder.id);
});
// Delete button
div.querySelector('.delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm('このフォルダを削除しますか?\n(中のノートとサブフォルダは「未分類」または保持されます)')) {
await deleteFolder(folder.id);
await renderFoldersAndNotes();
}
});
}
return div;
}
// Toggle folder collapse
function toggleFolder(folderId) {
if (collapsedFolders.has(folderId)) {
collapsedFolders.delete(folderId);
} else {
collapsedFolders.add(folderId);
}
// Save state to localStorage
localStorage.setItem('collapsedFolders', JSON.stringify([...collapsedFolders]));
renderFoldersAndNotes();
}
// Start renaming a folder
function startFolderRename(folderElement, folderId) {
const titleElement = folderElement.querySelector('.folder-item-title');
const currentTitle = titleElement.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-rename-input';
input.value = currentTitle;
titleElement.replaceWith(input);
input.focus();
input.select();
const finishRename = async () => {
const newTitle = input.value.trim() || '無題のフォルダ';
await updateFolderName(folderId, newTitle);
await renderFoldersAndNotes();
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
renderFoldersAndNotes();
}
});
}
// Render notes list (legacy - now using renderFoldersAndNotes)
async function renderNotesList() {
await renderFoldersAndNotes();
}
// Create note element
function createNoteElement(note) {
const div = document.createElement('div');
div.className = 'note-item';
if (currentNoteId === note.id) {
div.classList.add('active');
}
div.dataset.id = note.id;
const date = new Date(note.updatedAt || note.createdAt);
const dateStr = date.toLocaleDateString('ja-JP', { month: 'short', day: 'numeric' });
div.innerHTML = `
<div class="note-item-header">
<div class="note-item-title">${escapeHtml(note.title)}</div>
<div class="note-item-actions">
<button class="note-action-btn rename" title="名前を変更">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="note-action-btn delete" title="削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
<div class="note-item-date">${dateStr}</div>
`;
// Click to select note
div.addEventListener('click', (e) => {
if (!e.target.closest('.note-action-btn')) {
loadNote(note.id);
}
});
// Rename button
div.querySelector('.rename').addEventListener('click', (e) => {
e.stopPropagation();
startRename(div, note.id);
});
// Delete button
div.querySelector('.delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm('このノートを削除しますか?')) {
await deleteNote(note.id);
if (currentNoteId === note.id) {
currentNoteId = null;
showEmptyState();
}
await renderNotesList();
}
});
return div;
}
// Start renaming a note
function startRename(noteElement, noteId) {
const titleElement = noteElement.querySelector('.note-item-title');
const currentTitle = titleElement.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-rename-input';
input.value = currentTitle;
titleElement.replaceWith(input);
input.focus();
input.select();
const finishRename = async () => {
const newTitle = input.value.trim() || '無題のノート';
await updateNote(noteId, { title: newTitle });
// If the renamed note is the current one, update tab title
if (currentNoteId === noteId) {
document.title = newTitle;
}
await renderNotesList();
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
renderNotesList();
}
});
}
// Load a note into the editor
async function loadNote(noteId) {
const note = await getNote(noteId);
if (!note) return;
currentNoteId = noteId;
localStorage.setItem('currentNoteId', noteId); // Save current note ID
document.title = note.title; // Update tab title
document.getElementById('noteTitle').value = note.title;
const editor = document.getElementById('editor');
// If content is empty or only whitespace, start with a paragraph tag
if (!note.content || note.content.trim() === '') {
editor.innerHTML = '<p><br></p>';
} else {
editor.innerHTML = note.content;
}
// Load images from IndexedDB using the image.js function
await loadImagesInEditor();
// Update folder select
const folderSelect = document.getElementById('folderSelect');
if (note.folderId) {
folderSelect.value = note.folderId;
} else {
folderSelect.value = '';
}
// Highlight active note
document.querySelectorAll('.note-item').forEach(item => {
item.classList.remove('active');
});
const activeItem = document.querySelector(`[data-id="${noteId}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
// Show editor and hide empty state
document.querySelector('.editor-container').classList.add('active');
document.getElementById('emptyState').classList.add('hidden');
await renderNotesList();
await updateFolderSelect();
}
// Show empty state
function showEmptyState() {
document.querySelector('.editor-container').classList.remove('active');
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('noteTitle').value = '';
document.getElementById('editor').innerHTML = '<p><br></p>';
document.title = 'ノート管理'; // Reset tab title
localStorage.removeItem('currentNoteId'); // Clear saved note ID
}
// Update folder select options
async function updateFolderSelect() {
const folderSelect = document.getElementById('folderSelect');
const folders = await getAllFolders();
folderSelect.innerHTML = '<option value="">フォルダ...</option>';
folders.forEach(folder => {
const option = document.createElement('option');
option.value = folder.id;
option.textContent = folder.name;
folderSelect.appendChild(option);
});
// Set current folder if note is loaded
if (currentNoteId) {
const note = await getNote(currentNoteId);
if (note && note.folderId) {
folderSelect.value = note.folderId;
}
}
}
// Sidebar Resize Logic
function setupSidebarResize() {
const sidebar = document.getElementById('sidebar');
const resizer = document.getElementById('sidebarResizer');
// Restore width
const savedWidth = localStorage.getItem('sidebarWidth');
if (savedWidth) {
sidebar.style.width = `${savedWidth}px`;
}
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none'; // Prevent text selection
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
let newWidth = e.clientX;
// Limits
if (newWidth < 200) newWidth = 200;
if (newWidth > 600) newWidth = 600;
sidebar.style.width = `${newWidth}px`;
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Save width
localStorage.setItem('sidebarWidth', sidebar.style.width.replace('px', ''));
}
});
}
// Setup View Mode
function setupViewMode() {
const viewModeBtn = document.getElementById('viewModeBtn');
const exitViewBtn = document.getElementById('exitViewBtn');
if (viewModeBtn) {
// Remove old listener to avoid duplicates if re-running
const newBtn = viewModeBtn.cloneNode(true);
viewModeBtn.parentNode.replaceChild(newBtn, viewModeBtn);
newBtn.addEventListener('click', () => {
document.body.classList.add('view-mode');
if (exitViewBtn) exitViewBtn.classList.remove('hidden');
});
}
if (exitViewBtn) {
const newExitBtn = exitViewBtn.cloneNode(true);
exitViewBtn.parentNode.replaceChild(newExitBtn, exitViewBtn);
newExitBtn.addEventListener('click', () => {
document.body.classList.remove('view-mode');
newExitBtn.classList.add('hidden');
});
}
// Keyboard shortcut (Esc)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('view-mode')) {
document.body.classList.remove('view-mode');
if (exitViewBtn) exitViewBtn.classList.add('hidden');
}
});
}
style.css
/* ===== CSS Variables & Reset ===== */
:root {
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
}
/* Light Theme */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--bg-hover: #e5e7eb;
--accent-primary: #6366f1;
--accent-secondary: #8b5cf6;
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: rgba(0, 0, 0, 0.1);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.2);
--glass-bg: rgba(255, 255, 255, 0.8);
--glass-border: rgba(0, 0, 0, 0.1);
}
/* Dark Theme (Default) */
[data-theme="dark"] {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-tertiary: #252538;
--bg-hover: #2d2d42;
--accent-primary: #6366f1;
--accent-secondary: #8b5cf6;
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--text-primary: #e4e4e7;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--glass-bg: rgba(26, 26, 46, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
}
/* ===== App Container ===== */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ===== Header ===== */
.app-header {
background: var(--glass-bg);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--glass-border);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0.75rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-title {
font-size: 1.25rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
writing-mode: horizontal-tb;
white-space: nowrap;
flex-shrink: 0;
}
.title-icon {
width: 24px;
height: 24px;
stroke: var(--accent-primary);
-webkit-text-fill-color: initial;
}
.header-actions {
display: flex;
gap: 0.4rem;
align-items: center;
overflow-x: visible;
/* Changed from auto to visible to prevent clipping dropdown */
overflow-y: visible;
/* Changed from hidden to visible */
flex-wrap: nowrap;
}
.formatting-toolbar {
display: flex;
gap: 0.5rem;
padding-right: 0.5rem;
border-right: 1px solid var(--border-color);
margin-right: 0.5rem;
}
.icon-btn {
width: 27px;
/* Reduced from 34 */
height: 30px;
/* Reduced from 34 */
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
/* Slightly tighter radius */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-1px);
/* More subtle hover */
box-shadow: var(--shadow-sm);
}
.icon-btn svg {
width: 16px;
/* Reduced from 20 */
height: 16px;
}
/* Text Button (for h2, h3, h4) */
.text-btn {
min-width: 27px;
height: 30px;
padding: 0 0.55rem;
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.7rem;
}
.text-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
/* Text + Icon Button (for HTML編集) */
.text-icon-btn {
height: 34px;
padding: 0 0.875rem;
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
font-weight: 500;
font-size: 0.875rem;
}
.text-icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.text-icon-btn svg {
width: 18px;
height: 18px;
}
/* Compact Action Buttons (New Note, New Folder) */
.compact-action-btn {
width: 30px;
height: 30px;
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
}
.compact-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.compact-action-btn:active {
transform: translateY(0);
}
.compact-action-btn svg {
width: 20px;
height: 20px;
}
.compact-action-btn .plus-icon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: var(--accent-primary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
line-height: 1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* ===== Main Layout ===== */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* ===== Sidebar ===== */
.sidebar {
width: 280px;
/* Default, will be overridden by style attribute */
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* For resizer */
min-width: 200px;
max-width: 600px;
flex-shrink: 0;
}
.sidebar-resizer {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background: transparent;
transition: background var(--transition-fast);
z-index: 10;
}
.sidebar-resizer:hover,
.sidebar-resizer.resizing {
background: var(--accent-primary);
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 0.5rem;
}
.new-note-btn,
.new-folder-btn {
flex: 1;
width: auto;
height: 40px;
padding: 0;
background: var(--accent-gradient);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.new-note-btn:hover,
.new-folder-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.new-note-btn:active,
.new-folder-btn:active {
transform: translateY(0);
}
.new-note-btn svg,
.new-folder-btn svg {
width: 20px;
height: 20px;
}
.notes-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Custom Scrollbar */
.notes-list::-webkit-scrollbar,
.editor::-webkit-scrollbar {
width: 8px;
}
.notes-list::-webkit-scrollbar-track,
.editor::-webkit-scrollbar-track {
background: transparent;
}
.notes-list::-webkit-scrollbar-thumb,
.editor::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
.notes-list::-webkit-scrollbar-thumb:hover,
.editor::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Note Item */
.note-item {
padding: 0.25rem 0.5rem;
/* Reduced padding */
margin-bottom: 0.1rem;
/* Reduced margin */
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
/* Slightly smaller radius */
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
font-size: 0.85rem;
/* Slightly smaller text */
}
.note-item:hover {
background: var(--bg-hover);
transform: translateX(4px);
border-color: var(--accent-primary);
}
.note-item.active {
background: var(--accent-gradient);
border-color: transparent;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.note-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.note-item-title {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.note-item.active .note-item-title {
color: white;
}
.note-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity var(--transition-fast);
}
.note-item:hover .note-item-actions {
opacity: 1;
}
.note-action-btn {
width: 24px;
height: 24px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.note-action-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.note-action-btn.delete:hover {
background: #ef4444;
color: white;
}
.note-action-btn svg {
width: 12px;
height: 12px;
}
.note-item-date {
display: none;
}
/* ===== Main Content ===== */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* Added for view mode absolute elements */
transition: width var(--transition-normal);
}
/* Editor Container */
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow-y: auto;
/* Scroll parent */
}
/* Scrollable Container Content */
.editor-container.active {
display: flex;
}
.editor-header {
padding: 1.5rem 0rem 0.5rem;
/* Reduced bottom padding, no horiz padding here (in wrapper) */
border-bottom: none;
/* Remove border */
background: transparent;
/* Transparent */
display: flex;
justify-content: center;
/* Center wrapper */
flex-shrink: 0;
}
.content-centered-wrapper {
width: 100%;
max-width: 900px;
padding: 0 2rem;
/* Match editor padding */
display: flex;
flex-direction: column;
}
.note-title-input {
width: 100%;
padding: 0.4rem 0;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 2rem;
/* Larger title */
font-weight: 700;
outline: none;
margin-bottom: 10px;
line-height: 1.3;
text-align: center;
}
.note-title-input::placeholder {
color: var(--text-muted);
}
/* ===== View Mode / Focus Mode ===== */
body.view-mode .app-header {
display: none;
}
body.view-mode .sidebar {
display: none;
}
body.view-mode .floating-toolbar {
display: none;
}
/* Exit Button */
.exit-view-btn {
position: absolute;
top: 1rem;
right: 1.5rem;
z-index: 50;
background: rgba(0, 0, 0, 0.05);
border: none;
cursor: pointer;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.exit-view-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: var(--text-primary);
}
.exit-view-btn.hidden {
display: none;
}
body.view-mode .exit-view-btn.hidden {
display: flex;
/* Show when in view mode */
}
/* Toolbar Separator */
.toolbar-separator {
width: 1px;
height: 28px;
background: var(--border-color);
align-self: center;
}
/* Folder Select in Header */
.folder-select-header {
padding: 0.3rem 0.35rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.6rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
min-width: 80px;
}
.folder-select-header:hover,
.folder-select-header:focus {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-primary);
}
.folder-select-header option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Editor */
.editor {
flex: 1;
padding: 0 2rem 4rem;
/* Match header padding, extra bottom space */
overflow-y: visible;
/* Let parent scroll */
background: transparent;
color: var(--text-primary);
font-size: 1rem;
line-height: 1.7;
outline: none;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.editor:empty:before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.editor img {
max-width: 100%;
height: auto;
border-radius: 12px;
margin: 1rem 0;
box-shadow: var(--shadow-md);
cursor: pointer;
transition: box-shadow var(--transition-normal);
}
.editor img:hover {
box-shadow: var(--shadow-lg);
}
.editor p {
margin-bottom: 1rem;
}
/* Editor Headings */
.editor h2 {
font-size: 1.6rem;
color: #333;
background: #f5f6f7;
background-size: 4px 4px;
padding: 20px 20px 20px;
margin-bottom: 40px;
font-weight: 600;
}
.editor h3 {
border-left: 7px solid #888;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
font-size: 1.4rem;
padding: 11px 20px;
margin-bottom: 40px;
font-weight: 600;
color: var(--text-primary);
}
.editor h4 {
border-top: 2px solid #ddd;
border-bottom: 2px solid #ddd;
margin-bottom: 40px;
font-size: 1.2rem;
padding: 9px 10px;
font-weight: 600;
color: var(--text-primary);
}
.editor p {
margin-bottom: 40px;
}
/* Text Box - Gray */
.text-box {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 1rem;
margin: 1rem 0;
color: var(--text-primary);
transition: all var(--transition-fast);
outline: none;
}
.text-box:hover {
background: var(--bg-hover);
border-color: var(--text-muted);
}
.text-box:focus {
background: var(--bg-hover);
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
/* Text Box - Blue */
.text-box-blue {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 2px;
padding: 1rem;
margin: 1rem 0;
color: var(--text-primary);
transition: all var(--transition-fast);
outline: none;
}
.text-box-blue:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.5);
}
.text-box-blue:focus {
background: rgba(99, 102, 241, 0.2);
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
/* Gray Box (User Custom) */
.graybox {
background-color: rgba(250, 250, 250, .48);
outline: 1px solid rgba(228, 228, 228, .8705882353);
color: #444;
overflow: auto;
display: block;
padding: 20px 20px 25px;
margin-bottom: 40px;
line-height: 1.7;
border-radius: 2px;
/* Added for consistency */
}
.graybox p {
margin-bottom: 0;
}
/* Check Heading (User Custom) */
.check {
position: relative;
padding-left: 32px;
margin: 32px 0 40px;
font-weight: 600;
font-size: 1.2rem;
color: var(--text-primary);
/* Ensure visibility */
line-height: 1.5;
}
/* Check Mark */
.check::before {
content: "";
position: absolute;
left: 0;
top: 50%;
/* Adjusted centering */
width: 14px;
height: 8px;
border-left: 3px solid #4b6cb7;
border-bottom: 3px solid #4b6cb7;
transform: translateY(-65%) rotate(-45deg);
/* Adjusted transform */
}
/* Links */
.editor a {
color: var(--accent-primary);
text-decoration: underline;
transition: color var(--transition-fast);
}
.editor a:hover {
color: var(--accent-secondary);
}
/* Toolbar Divider */
.toolbar-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 0.25rem;
}
/* Text Formatting Styles */
.gray {
color: #888888;
}
.red {
color: #ef4444;
}
.black {
color: #222;
}
/* Empty State */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state.hidden {
display: none;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-state h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.empty-state p {
font-size: 1rem;
max-width: 400px;
}
/* Rename Input */
.note-rename-input {
width: 100%;
padding: 0.25rem 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--accent-primary);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 500;
outline: none;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.note-item {
animation: fadeIn var(--transition-normal);
}
/* Responsive */
@media (max-width: 1200px) {
.header-content {
padding: 0.75rem 1rem;
}
.formatting-toolbar {
gap: 0.25rem;
}
.header-actions {
gap: 0.25rem;
}
}
@media (max-width: 768px) {
.sidebar {
width: 240px;
}
.app-header {
padding: 0.5rem;
}
.header-content {
padding: 0.5rem;
gap: 0.5rem;
}
/* Hide title text on small screens, keep only icon */
.app-title {
font-size: 0;
gap: 0;
}
.title-icon {
width: 28px;
height: 28px;
}
.header-actions {
flex: 1;
justify-content: flex-start;
gap: 0.25rem;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
}
.formatting-toolbar {
gap: 0.25rem;
padding-right: 0.25rem;
margin-right: 0.25rem;
flex-wrap: nowrap;
}
/* Make buttons slightly smaller on mobile */
.icon-btn,
.text-btn,
.compact-action-btn {
width: 36px;
height: 36px;
min-width: 36px;
flex-shrink: 0;
}
.text-icon-btn {
height: 36px;
padding: 0 0.5rem;
font-size: 0.75rem;
flex-shrink: 0;
}
.text-icon-btn svg {
width: 16px;
height: 16px;
}
.editor {
padding: 1.5rem;
}
.toolbar-separator {
height: 24px;
flex-shrink: 0;
}
/* Hide some less critical buttons on very narrow screens */
#clearFormatBtn,
#paragraphBtn {
display: none;
}
.folder-select-header {
min-width: 100px;
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
}
@media (max-width: 480px) {
.sidebar {
width: 200px;
}
.editor {
padding: 1rem;
}
.header-actions {
gap: 0.2rem;
}
.formatting-toolbar {
gap: 0.2rem;
}
/* Further reduce button sizes on very small screens */
.icon-btn,
.text-btn,
.compact-action-btn {
width: 32px;
height: 32px;
min-width: 32px;
}
.icon-btn svg,
.compact-action-btn svg {
width: 16px;
height: 16px;
}
.text-btn {
font-size: 0.75rem;
padding: 0 0.5rem;
}
.text-icon-btn {
height: 32px;
padding: 0 0.4rem;
font-size: 0.7rem;
}
.text-icon-btn svg {
width: 14px;
height: 14px;
}
/* Hide more buttons on very small screens */
#insertBoxBlueBtn,
#codeEditorBtn {
display: none;
}
.folder-select-header {
min-width: 80px;
font-size: 0.7rem;
}
}
/* ===== Export Dropdown ===== */
.export-dropdown {
position: relative;
z-index: 1001;
/* Ensure dropdown container is above other elements */
}
.export-menu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
min-width: 250px;
overflow: visible;
/* Changed from hidden to visible */
z-index: 1002;
/* Increased z-index to ensure it's above everything */
backdrop-filter: blur(20px);
}
.export-menu.hidden {
display: none;
}
.export-menu-item {
width: 100%;
padding: 0.875rem 1rem;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
transition: all var(--transition-fast);
text-align: left;
}
.export-menu-item:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.export-menu-item svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.export-menu-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
/* ===== Folder Styles ===== */
.new-folder-btn {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all var(--transition-normal);
margin-top: 0.5rem;
}
.new-folder-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-primary);
transform: translateY(-2px);
}
.new-folder-btn svg {
width: 18px;
height: 18px;
}
.folder-item {
margin-bottom: 0.25rem;
animation: fadeIn var(--transition-normal);
}
.folder-item-header {
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
justify-content: space-between;
align-items: center;
}
.folder-item-header:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.folder-item-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.folder-chevron {
width: 16px;
height: 16px;
color: var(--text-secondary);
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.folder-chevron.collapsed {
transform: rotate(-90deg);
}
.folder-icon {
width: 18px;
height: 18px;
color: var(--accent-primary);
flex-shrink: 0;
}
.folder-item-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity var(--transition-fast);
}
.folder-item-header:hover .folder-item-actions {
opacity: 1;
}
.folder-notes {
margin-left: 1.5rem;
margin-top: 0;
max-height: 1000px;
overflow: hidden;
transition: max-height var(--transition-normal), opacity var(--transition-normal);
}
.folder-notes.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.folder-notes .note-item {
margin-left: 0.5rem;
border-left: 2px solid var(--border-color);
}
/* ===== Folder Select ===== */
.folder-select {
padding: 0.625rem 1rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
}
.folder-select:hover,
.folder-select:focus {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-primary);
}
.folder-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.folder-content {
transition: all var(--transition-fast);
}
.folder-content.collapsed {
display: none;
}
/* ===== Floating Edit Toolbar ===== */
.floating-toolbar {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 8px;
box-shadow: var(--shadow-md);
z-index: 1000;
transition: opacity 0.3s, transform 0.3s;
}
.floating-toolbar.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(20px);
}
.floating-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
}
.floating-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateX(-2px);
box-shadow: var(--shadow-sm);
}
.floating-btn:active {
transform: translateX(0);
}
.floating-btn svg {
width: 20px;
height: 20px;
}
.floating-separator {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
/* Edit Mode Button Active State */
.compact-action-btn.active {
background: var(--accent-primary);
color: white;
}
.compact-action-btn.active:hover {
background: var(--accent-secondary);
}
favicon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- 付箋本体 -->
<rect x="2" y="2" width="60" height="60" rx="8"
fill="#ffe66d" stroke="#111111" stroke-width="3"/>
<!-- 文字っぽい罫線 -->
<path d="M10 22h44M10 32h44M10 42h34"
stroke="#111111" stroke-width="4" stroke-linecap="round"/>
<!-- 右下めくれ -->
<path d="M44 62c8-2 14-8 16-16"
fill="none" stroke="#111111" stroke-width="3" opacity="0.7"/>
</svg>