Notes
ling 3.0 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 71,010 bytes · SHA-256 f1a45a6e13cc
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes</title>
<style>
/* ============================================================
CONSTANTS
============================================================ */
:root {
--bg-app: #f7f5f2;
--bg-sidebar: #ffffff;
--bg-notelist: #ffffff;
--bg-editor: #ffffff;
--bg-toolbar: #fafaf9;
--bg-hover: #f0efeb;
--bg-selected: #e8f0fe;
--bg-input: #f5f4f1;
--text-primary: #1a1a2e;
--text-secondary: #5a5a6e;
--text-muted: #9a9aae;
--accent: #4a6fa5;
--accent-light: #e8f0fe;
--border: #e2e0db;
--border-light: #eceae6;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 2px 8px rgba(0,0,0,0.06);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.08);
--radius-sm: 6px;
--radius-md: 10px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
--sidebar-width: 260px;
--notelist-width: 280px;
--toolbar-height: 44px;
--header-height: 52px;
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--autosave-delay: 600;
--snippet-length: 120;
--tag-bg: #eef2f7;
--tag-text: #4a5568;
--danger: #c53030;
--danger-bg: #fff5f5;
--success: #2f855a;
--success-bg: #f0fff4;
--warning: #b7791f;
--warning-bg: #fffff0;
}
[data-theme="dark"] {
--bg-app: #1a1a2e;
--bg-sidebar: #22223a;
--bg-notelist: #22223a;
--bg-editor: #252540;
--bg-toolbar: #2a2a44;
--bg-hover: #333350;
--bg-selected: #2a3a5c;
--bg-input: #2e2e48;
--text-primary: #e0e0e8;
--text-secondary: #a0a0b8;
--text-muted: #6a6a80;
--accent: #6b8cce;
--accent-light: #2a3a5c;
--border: #3a3a5c;
--border-light: #33334a;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow-md: 0 2px 8px rgba(0,0,0,0.3);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.4);
--tag-bg: #2a2a44;
--tag-text: #b0b0c8;
--danger-bg: #3a1a1a;
--success-bg: #1a2a1a;
--warning-bg: #3a2a1a;
}
/* ============================================================
RESET & BASE
============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-app);
transition: background var(--transition-normal), color var(--transition-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea { font-family: inherit; color: inherit; }
a { color: var(--accent); text-decoration: none; }
::selection { background: var(--accent); color: #fff; }
/* ============================================================
APP LAYOUT
============================================================ */
#app {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* ============================================================
SIDEBAR
============================================================ */
#sidebar {
width: var(--sidebar-width);
min-width: 0;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width var(--transition-normal), min-width var(--transition-normal), opacity var(--transition-normal);
overflow: hidden;
flex-shrink: 0;
z-index: 10;
}
#sidebar.collapsed {
width: 0;
border-right: none;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
height: var(--header-height);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.sidebar-header h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
flex: 1;
color: var(--text-primary);
}
.sidebar-header .icon-btn {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
font-size: 16px;
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.sidebar-header .icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.sidebar-header .icon-btn.active { color: var(--accent); }
.sidebar-search {
padding: 8px 12px;
flex-shrink: 0;
}
.sidebar-search input {
width: 100%;
padding: 7px 10px 7px 30px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
font-size: 13px;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.sidebar-search input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
.sidebar-search {
position: relative;
}
.sidebar-search::before {
content: '⌕';
position: absolute;
left: 22px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
}
.sidebar-nav {
padding: 4px 8px;
flex-shrink: 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
user-select: none;
}
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active { background: var(--accent-light); color: var(--accent); font-weight: 600; }
.nav-item .nav-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
.nav-item .nav-count {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
background: var(--bg-input);
padding: 1px 6px;
border-radius: 10px;
}
.sidebar-notebooks {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.notebook-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
font-size: 13px;
color: var(--text-secondary);
}
.notebook-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.notebook-item.active { background: var(--accent-light); color: var(--accent); font-weight: 600; }
.notebook-item .nb-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.notebook-item .nb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notebook-item .nb-actions {
display: flex; gap: 2px; opacity: 0;
transition: opacity var(--transition-fast);
}
.notebook-item:hover .nb-actions { opacity: 1; }
.notebook-item .nb-actions button {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
font-size: 12px;
color: var(--text-muted);
}
.notebook-item .nb-actions button:hover { background: var(--bg-hover); color: var(--text-primary); }
.sidebar-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
}
.btn-new-notebook {
width: 100%;
padding: 7px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--accent);
background: transparent;
transition: background var(--transition-fast);
text-align: left;
}
.btn-new-notebook:hover { background: var(--accent-light); }
/* ============================================================
RESIZE HANDLES
============================================================ */
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background var(--transition-fast);
flex-shrink: 0;
z-index: 5;
}
.resize-handle:hover, .resize-handle.active { background: var(--accent); }
/* ============================================================
NOTE LIST
============================================================ */
#note-list {
width: var(--notelist-width);
min-width: 0;
background: var(--bg-notelist);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width var(--transition-normal), min-width var(--transition-normal), opacity var(--transition-normal);
overflow: hidden;
flex-shrink: 0;
}
.note-list-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.note-list-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.note-list-controls {
display: flex;
gap: 6px;
align-items: center;
}
.note-list-controls .sort-btn, .note-list-controls .new-note-btn {
padding: 5px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-input);
transition: background var(--transition-fast), color var(--transition-fast);
}
.note-list-controls .sort-btn:hover, .note-list-controls .new-note-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.note-list-controls .new-note-btn {
margin-left: auto;
background: var(--accent);
color: #fff;
}
.note-list-controls .new-note-btn:hover { opacity: 0.9; }
.note-list-items {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.note-item {
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
border: 1px solid transparent;
margin-bottom: 2px;
}
.note-item:hover { background: var(--bg-hover); border-color: var(--border-light); }
.note-item.selected { background: var(--accent-light); border-color: var(--accent); }
.note-item .note-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-item .note-snippet {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.note-item .note-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.note-item .note-meta .note-time { flex: 1; }
.note-item .note-meta .note-tag {
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
background: var(--tag-bg);
color: var(--tag-text);
font-weight: 500;
}
.note-item .note-pin {
font-size: 12px;
color: var(--warning);
}
.note-item.pinned .note-title::before {
content: '📌 ';
font-size: 11px;
}
.note-item .note-actions {
display: flex; gap: 2px; opacity: 0;
transition: opacity var(--transition-fast);
margin-top: 4px;
}
.note-item:hover .note-actions { opacity: 1; }
.note-item .note-actions button {
padding: 2px 6px;
font-size: 11px;
border-radius: 3px;
color: var(--text-muted);
}
.note-item .note-actions button:hover { background: var(--bg-hover); color: var(--text-primary); }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--text-muted);
}
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.4; }
.empty-state .empty-title { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.empty-state .empty-desc { font-size: 13px; line-height: 1.5; }
/* ============================================================
EDITOR PANE
============================================================ */
#editor-pane {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-editor);
min-width: 0;
overflow: hidden;
}
/* Toolbar */
#toolbar {
display: flex;
align-items: center;
gap: 1px;
padding: 4px 8px;
height: var(--toolbar-height);
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
#toolbar::-webkit-scrollbar { display: none; }
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
padding: 0 4px;
}
.toolbar-group:not(:last-child) {
border-right: 1px solid var(--border-light);
margin-right: 4px;
padding-right: 8px;
}
.toolbar-btn {
width: 30px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
position: relative;
}
.toolbar-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.toolbar-btn.active { background: var(--accent-light); color: var(--accent); }
.toolbar-btn .shortcut {
position: absolute;
bottom: 1px; right: 2px;
font-size: 8px;
color: var(--text-muted);
font-weight: 400;
}
.toolbar-btn.large { width: auto; padding: 0 8px; font-size: 12px; gap: 4px; }
/* Tag input in toolbar */
.tag-input-wrap {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
flex: 1;
min-width: 0;
}
.tag-input-wrap input {
border: none;
background: transparent;
font-size: 12px;
color: var(--text-primary);
outline: none;
width: 100%;
min-width: 60px;
}
.tag-input-wrap input::placeholder { color: var(--text-muted); }
.tag-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: var(--tag-bg);
color: var(--tag-text);
cursor: pointer;
transition: background var(--transition-fast);
}
.tag-chip:hover { background: var(--bg-hover); }
.tag-chip .tag-remove {
font-size: 13px;
line-height: 1;
opacity: 0.6;
}
.tag-chip .tag-remove:hover { opacity: 1; }
/* Editor content */
#editor {
flex: 1;
padding: 32px 48px;
overflow-y: auto;
outline: none;
font-family: var(--font-serif);
font-size: 16px;
line-height: 1.75;
color: var(--text-primary);
tab-size: 2;
}
#editor:empty::before {
content: 'Select or create a note';
color: var(--text-muted);
pointer-events: none;
font-style: italic;
font-family: var(--font-sans);
font-size: 15px;
}
#editor h1 { font-size: 2em; margin: 0.67em 0 0.3em; font-weight: 700; letter-spacing: -0.5px; }
#editor h2 { font-size: 1.5em; margin: 0.83em 0 0.3em; font-weight: 600; }
#editor h3 { font-size: 1.17em; margin: 1em 0 0.3em; font-weight: 600; }
#editor p { margin: 0.5em 0; }
#editor ul, #editor ol { padding-left: 1.5em; margin: 0.5em 0; }
#editor li { margin: 0.25em 0; }
#editor blockquote {
border-left: 3px solid var(--accent);
padding: 4px 16px;
margin: 0.5em 0;
color: var(--text-secondary);
background: var(--bg-input);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
#editor hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
#editor code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--bg-input);
padding: 1px 5px;
border-radius: 3px;
}
#editor input[type="checkbox"] {
margin-right: 6px;
cursor: pointer;
transform: scale(1.15);
accent-color: var(--accent);
}
#editor mark {
background: #fef08a;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
[data-theme="dark"] #editor mark {
background: #3a3a1a;
color: inherit;
}
/* Save indicator */
#save-indicator {
padding: 4px 16px;
font-size: 11px;
color: var(--text-muted);
text-align: right;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
transition: opacity var(--transition-fast);
height: 24px;
line-height: 24px;
}
#save-indicator.saved { color: var(--success); }
#save-indicator.unsaved { color: var(--warning); }
/* ============================================================
CONTEXT MENU
============================================================ */
#context-menu {
position: fixed;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 4px;
z-index: 100;
min-width: 160px;
display: none;
}
#context-menu .menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: background var(--transition-fast);
color: var(--text-primary);
}
#context-menu .menu-item:hover { background: var(--bg-hover); }
#context-menu .menu-item.danger { color: var(--danger); }
#context-menu .menu-item .menu-icon { width: 18px; text-align: center; }
#context-menu .menu-separator { height: 1px; background: var(--border-light); margin: 4px 0; }
/* ============================================================
MODAL
============================================================ */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 200;
display: none;
align-items: center;
justify-content: center;
}
#modal-overlay.visible { display: flex; }
#modal {
background: var(--bg-sidebar);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 24px;
min-width: 320px;
max-width: 400px;
}
#modal h3 { font-size: 16px; margin-bottom: 12px; font-weight: 600; }
#modal input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--bg-input);
outline: none;
margin-bottom: 16px;
}
#modal input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
#modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
#modal .modal-actions button {
padding: 6px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
}
#modal .btn-cancel { color: var(--text-secondary); }
#modal .btn-cancel:hover { background: var(--bg-hover); }
#modal .btn-confirm { background: var(--accent); color: #fff; }
#modal .btn-confirm:hover { opacity: 0.9; }
/* ============================================================
ANIMATIONS
============================================================ */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.fade-in { animation: fadeIn 0.2s ease; }
.slide-in { animation: slideIn 0.2s ease; }
/* ============================================================
SCROLLBAR
============================================================ */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ============================================================
FOCUS STATES
============================================================ */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button:focus-visible { outline-offset: 1px; }
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 900px) {
:root { --sidebar-width: 220px; --notelist-width: 220px; }
#editor { padding: 20px 24px; }
}
@media (max-width: 640px) {
:root { --sidebar-width: 0px; --notelist-width: 0px; }
#sidebar, #note-list { display: none; }
.resize-handle { display: none; }
}
</style>
</head>
<body>
<div id="app">
<!-- Sidebar -->
<aside id="sidebar">
<div class="sidebar-header">
<h1>Notes</h1>
<button class="icon-btn" id="btn-theme" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button class="icon-btn" id="btn-collapse" title="Collapse sidebar" aria-label="Collapse sidebar">◀</button>
</div>
<div class="sidebar-search">
<input type="text" id="global-search" placeholder="Search notes…" aria-label="Search notes">
</div>
<nav class="sidebar-nav">
<div class="nav-item active" data-view="all"><span class="nav-icon">📄</span>All Notes<span class="nav-count" id="count-all">0</span></div>
<div class="nav-item" data-view="trash"><span class="nav-icon">🗑</span>Trash<span class="nav-count" id="count-trash">0</span></div>
</nav>
<div class="sidebar-notebooks" id="notebook-list"></div>
<div class="sidebar-footer">
<button class="btn-new-notebook" id="btn-new-notebook">+ New Notebook</button>
</div>
</aside>
<!-- Resize handle: sidebar ↔ note list -->
<div class="resize-handle" id="resize-sidebar"></div>
<!-- Note List -->
<div id="note-list">
<div class="note-list-header">
<h2 id="note-list-title">All Notes</h2>
<div class="note-list-controls">
<button class="sort-btn" id="btn-sort" title="Toggle sort order">Modified ↓</button>
<button class="new-note-btn" id="btn-new-note" title="New note (Ctrl+N)">+ New</button>
</div>
</div>
<div class="note-list-items" id="note-list-items"></div>
</div>
<!-- Resize handle: note list ↔ editor -->
<div class="resize-handle" id="resize-notelist"></div>
<!-- Editor Pane -->
<div id="editor-pane">
<div id="toolbar">
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bold" title="Bold (Ctrl+B)" aria-label="Bold"><b>B</b><span class="shortcut">⌘B</span></button>
<button class="toolbar-btn" data-cmd="italic" title="Italic (Ctrl+I)" aria-label="Italic"><i>I</i><span class="shortcut">⌘I</span></button>
<button class="toolbar-btn" data-cmd="underline" title="Underline (Ctrl+U)" aria-label="Underline"><u>U</u><span class="shortcut">⌘U</span></button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="strikeThrough" title="Strikethrough" aria-label="Strikethrough"><s>S</s></button>
<button class="toolbar-btn" data-cmd="formatBlock" data-value="code" title="Inline Code" aria-label="Inline Code" style="font-family:var(--font-mono);font-size:12px;">code</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="formatBlock" data-value="h1" title="Heading 1" aria-label="Heading 1">H1</button>
<button class="toolbar-btn" data-cmd="formatBlock" data-value="h2" title="Heading 2" aria-label="Heading 2">H2</button>
<button class="toolbar-btn" data-cmd="formatBlock" data-value="h3" title="Heading 3" aria-label="Heading 3">H3</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="insertUnorderedList" title="Bullet List" aria-label="Bullet List">•≡</button>
<button class="toolbar-btn" data-cmd="insertOrderedList" title="Numbered List" aria-label="Numbered List">1.</button>
<button class="toolbar-btn" data-cmd="insertCheckList" title="Checklist" aria-label="Checklist">☑</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="formatBlock" data-value="blockquote" title="Blockquote" aria-label="Blockquote">❝</button>
<button class="toolbar-btn" data-cmd="insertHorizontalRule" title="Horizontal Rule" aria-label="Horizontal Rule">―</button>
</div>
<div class="tag-input-wrap">
<input type="text" id="tag-input" placeholder="Add tags (comma)…" aria-label="Add tags">
</div>
</div>
<div id="editor" contenteditable="true" spellcheck="false" aria-label="Note editor"></div>
<div id="save-indicator">Saved</div>
</div>
</div>
<!-- Context Menu -->
<div id="context-menu">
<div class="menu-item" data-action="pin"><span class="menu-icon">📌</span>Pin/Unpin</div>
<div class="menu-item" data-action="duplicate"><span class="menu-icon">📋</span>Duplicate</div>
<div class="menu-item" data-action="move"><span class="menu-icon">📁</span>Move to…</div>
<div class="menu-separator"></div>
<div class="menu-item danger" data-action="delete"><span class="menu-icon">🗑</span>Delete</div>
</div>
<!-- Modal -->
<div id="modal-overlay">
<div id="modal">
<h3 id="modal-title">Rename Notebook</h3>
<input type="text" id="modal-input" placeholder="Name…" aria-label="Input">
<div class="modal-actions">
<button class="btn-cancel" id="modal-cancel">Cancel</button>
<button class="btn-confirm" id="modal-confirm">OK</button>
</div>
</div>
</div>
<script>
/* ============================================================
CONSTANTS
============================================================ */
const CONFIG = {
autosaveDelay: 600,
snippetLength: 120,
maxUndoStack: 50,
allowedTags: ['p','br','strong','em','u','s','code','h1','h2','h3','ul','ol','li','blockquote','hr','input'],
allowedAttributes: {
'input': ['type','checked'],
'*': ['style']
},
allowedStyles: [],
storageKey: 'notes-app-state',
themeKey: 'notes-app-theme',
};
/* ============================================================
DATA MODEL — Single Source of Truth
============================================================ */
const Store = {
notebooks: [], // [{id, name, color, createdAt}]
notes: [], // [{id, notebookId, title, content, tags, pinned, createdAt, updatedAt}]
trash: [], // [{id, notebookId, title, content, tags, pinned, createdAt, updatedAt}]
selectedNotebookId: 'all', // 'all', 'trash', or notebook id
selectedNoteId: null,
sortOrder: 'modified', // 'modified' | 'created' | 'alpha'
sidebarCollapsed: false,
sidebarWidth: 260,
notelistWidth: 280,
theme: 'light',
searchQuery: '',
activeTag: null,
// --- CRUD ---
nextId: 1,
_generateId() { return 'id_' + (this.nextId++); },
createNotebook(name) {
const nb = { id: this._generateId(), name, color: this._randomColor(), createdAt: Date.now() };
this.notebooks.push(nb);
this._save();
return nb;
},
renameNotebook(id, name) {
const nb = this.notebooks.find(n => n.id === id);
if (nb) { nb.name = name; this._save(); }
},
deleteNotebook(id) {
// Move all notes to trash first
const notes = this.notebooks.find(n => n.id === id);
if (!notes) return;
this.notes.filter(n => n.notebookId === id).forEach(n => this._moveToTrash(n.id));
this.notebooks = this.notebooks.filter(n => n.id !== id);
if (this.selectedNotebookId === id) this.selectedNotebookId = 'all';
this._save();
},
createNote(notebookId) {
const note = {
id: this._generateId(),
notebookId: notebookId || this.selectedNotebookId === 'all' ? (this.notebooks[0]?.id || 'default') : this.selectedNotebookId,
title: 'Untitled Note',
content: '',
tags: [],
pinned: false,
createdAt: Date.now(),
updatedAt: Date.now(),
};
this.notes.push(note);
this.selectNote(note.id);
this._save();
return note;
},
updateNoteContent(id, content) {
const note = this.notes.find(n => n.id === id);
if (note) {
note.content = content;
note.updatedAt = Date.now();
note.title = this._extractTitle(content) || 'Untitled Note';
this._save();
}
},
updateNoteTitle(id, title) {
const note = this.notes.find(n => n.id === id);
if (note) { note.title = title; note.updatedAt = Date.now(); this._save(); }
},
updateNoteTags(id, tags) {
const note = this.notes.find(n => n.id === id);
if (note) { note.tags = tags; this._save(); }
},
deleteNote(id) {
this._moveToTrash(id);
if (this.selectedNoteId === id) this.selectedNoteId = null;
this._save();
},
_moveToTrash(id) {
const idx = this.notes.findIndex(n => n.id === id);
if (idx === -1) return;
const note = this.notes.splice(idx, 1)[0];
this.trash.push(note);
},
restoreNote(id) {
const idx = this.trash.findIndex(n => n.id === id);
if (idx === -1) return;
const note = this.trash.splice(idx, 1)[0];
this.notes.push(note);
this._save();
},
permanentlyDelete(id) {
this.trash = this.trash.filter(n => n.id !== id);
this._save();
},
duplicateNote(id) {
const note = this.notes.find(n => n.id === id);
if (!note) return;
const dup = { ...note, id: this._generateId(), title: note.title + ' (copy)', content: note.content, createdAt: Date.now(), updatedAt: Date.now(), pinned: false };
this.notes.push(dup);
this._save();
return dup;
},
togglePin(id) {
const note = this.notes.find(n => n.id === id);
if (note) { note.pinned = !note.pinned; this._save(); }
},
moveNote(id, notebookId) {
const note = this.notes.find(n => n.id === id);
if (note) { note.notebookId = notebookId; this._save(); }
},
setSortOrder(order) {
this.sortOrder = order;
this._save();
},
setSelectedNotebook(id) {
this.selectedNotebookId = id;
this.selectedNoteId = null;
this._save();
},
selectNote(id) {
this.selectedNoteId = id;
this._save();
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
this._save();
},
setTheme(theme) {
this.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
this._save();
},
setSearchQuery(q) {
this.searchQuery = q;
},
setActiveTag(tag) {
this.activeTag = tag;
},
// --- Helpers ---
_extractTitle(html) {
const text = htmlToPlainText(html).trim();
if (!text) return '';
// First line, truncated
const firstLine = text.split('\n')[0].substring(0, 60);
return firstLine;
},
_randomColor() {
const colors = ['#4a6fa5','#5b8c5a','#c53030','#b7791f','#6b46c1','#2b6cb0','#2f855a','#c05621','#63b3ed','#fc8181'];
return colors[Math.floor(Math.random() * colors.length)];
},
// --- Persistence ---
_save() {
try {
const state = {
notebooks: this.notebooks,
notes: this.notes,
trash: this.trash,
selectedNotebookId: this.selectedNotebookId,
selectedNoteId: this.selectedNoteId,
sortOrder: this.sortOrder,
sidebarCollapsed: this.sidebarCollapsed,
sidebarWidth: this.sidebarWidth,
notelistWidth: this.notelistWidth,
theme: this.theme,
nextId: this.nextId,
};
localStorage.setItem(CONFIG.storageKey, JSON.stringify(state));
} catch(e) { /* quota exceeded, etc */ }
},
load() {
try {
const raw = localStorage.getItem(CONFIG.storageKey);
if (!raw) return false;
const state = JSON.parse(raw);
this.notebooks = state.notebooks || [];
this.notes = state.notes || [];
this.trash = state.trash || [];
this.selectedNotebookId = state.selectedNotebookId || 'all';
this.selectedNoteId = state.selectedNoteId || null;
this.sortOrder = state.sortOrder || 'modified';
this.sidebarCollapsed = state.sidebarCollapsed || false;
this.sidebarWidth = state.sidebarWidth || 260;
this.notelistWidth = state.notelistWidth || 280;
this.theme = state.theme || 'light';
this.nextId = state.nextId || 1;
return true;
} catch(e) { return false; }
},
getNotebook(id) { return this.notebooks.find(n => n.id === id); },
getNote(id) { return this.notes.find(n => n.id === id) || this.trash.find(n => n.id === id); },
getFilteredNotes() {
let list;
if (this.selectedNotebookId === 'trash') {
list = [...this.trash];
} else if (this.selectedNotebookId === 'all') {
list = [...this.notes];
} else {
list = this.notes.filter(n => n.notebookId === this.selectedNotebookId);
}
// Tag filter
if (this.activeTag) {
list = list.filter(n => n.tags.includes(this.activeTag));
}
// Search filter
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(n => {
const title = n.title.toLowerCase();
const body = htmlToPlainText(n.content).toLowerCase();
return title.includes(q) || body.includes(q);
});
}
// Sort
const order = this.sortOrder;
list.sort((a, b) => {
if (order === 'pinned') {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
}
if (order === 'alpha') return a.title.localeCompare(b.title);
if (order === 'created') return b.createdAt - a.createdAt;
// modified (default)
return b.updatedAt - a.updatedAt;
});
// Pinned always on top (unless sort is alpha/created)
if (order !== 'alpha' && order !== 'created') {
const pinned = list.filter(n => n.pinned);
const unpinned = list.filter(n => !n.pinned);
list = [...pinned, ...unpinned];
}
return list;
},
};
/* ============================================================
SANITIZER & TEXT EXTRACTION
============================================================ */
function sanitizeHTML(html) {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
const allowed = new Set(CONFIG.allowedTags);
const allowedAttrs = CONFIG.allowedAttributes;
function cleanNode(node) {
if (node.nodeType === Node.TEXT_NODE) return;
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
if (!allowed.has(tag)) {
// Replace disallowed element with its children
while (node.firstChild) {
node.parentNode.insertBefore(node.firstChild, node);
}
node.parentNode.removeChild(node);
return;
}
// Clean attributes
const attrs = Array.from(node.attributes);
const allowedForTag = allowedAttrs[tag] || [];
const globalAllowed = allowedAttrs['*'] || [];
attrs.forEach(attr => {
const name = attr.name.toLowerCase();
// Remove event handlers
if (name.startsWith('on')) {
node.removeAttribute(name);
return;
}
// Remove style attribute (except for allowed styles)
if (name === 'style' && CONFIG.allowedStyles.length === 0) {
node.removeAttribute(name);
return;
}
if (!allowedForTag.includes(name) && !globalAllowed.includes(name)) {
node.removeAttribute(name);
}
});
// For checkboxes, ensure type is checkbox
if (tag === 'input') {
if (node.getAttribute('type') !== 'checkbox') {
node.setAttribute('type', 'checkbox');
}
}
// Clean children
Array.from(node.childNodes).forEach(cleanNode);
}
}
Array.from(doc.body.childNodes).forEach(cleanNode);
return doc.body.innerHTML;
}
function htmlToPlainText(html) {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
// Replace checkbox inputs with their checked state
doc.querySelectorAll('input[type="checkbox"]').forEach(cb => {
const text = document.createTextNode(cb.checked ? '☑ ' : '☐ ');
cb.parentNode.replaceChild(text, cb);
});
return doc.body.textContent || '';
}
/* ============================================================
UI RENDERING
============================================================ */
const $sidebar = document.getElementById('sidebar');
const $notebookList = document.getElementById('notebook-list');
const $noteListItems = document.getElementById('note-list-items');
const $noteListTitle = document.getElementById('note-list-title');
const $editor = document.getElementById('editor');
const $editorPane = document.getElementById('editor-pane');
const $toolbar = document.getElementById('toolbar');
const $saveIndicator = document.getElementById('save-indicator');
const $globalSearch = document.getElementById('global-search');
const $tagInput = document.getElementById('tag-input');
const $btnTheme = document.getElementById('btn-theme');
const $btnCollapse = document.getElementById('btn-collapse');
const $btnNewNotebook = document.getElementById('btn-new-notebook');
const $btnNewNote = document.getElementById('btn-new-note');
const $btnSort = document.getElementById('btn-sort');
const $contextMenu = document.getElementById('context-menu');
const $modalOverlay = document.getElementById('modal-overlay');
const $modalInput = document.getElementById('modal-input');
const $modalTitle = document.getElementById('modal-title');
const $modalConfirm = document.getElementById('modal-confirm');
const $modalCancel = document.getElementById('modal-cancel');
let autoSaveTimer = null;
let saveIndicatorTimeout = null;
function renderSidebar() {
// Update counts
document.getElementById('count-all').textContent = Store.notes.length;
document.getElementById('count-trash').textContent = Store.trash.length;
// Render notebooks
$notebookList.innerHTML = '';
Store.notebooks.forEach(nb => {
const item = document.createElement('div');
item.className = 'notebook-item' + (Store.selectedNotebookId === nb.id ? ' active' : '');
item.dataset.notebookId = nb.id;
item.draggable = true;
item.innerHTML = `
<span class="nb-dot" style="background:${nb.color}"></span>
<span class="nb-name">${escHtml(nb.name)}</span>
<span class="nb-actions">
<button data-action="rename" title="Rename" aria-label="Rename notebook">✎</button>
<button data-action="delete" title="Delete" aria-label="Delete notebook">✕</button>
</span>
`;
$notebookList.appendChild(item);
});
}
function renderNoteList() {
const notes = Store.getFilteredNotes();
const view = Store.selectedNotebookId;
// Update title
if (view === 'all') $noteListTitle.textContent = 'All Notes';
else if (view === 'trash') $noteListTitle.textContent = 'Trash';
else {
const nb = Store.getNotebook(view);
$noteListTitle.textContent = nb ? nb.name : 'Notes';
}
// Update sort button text
const sortLabels = { modified: 'Modified ↓', created: 'Created ↓', alpha: 'A → Z', pinned: 'Pinned' };
$btnSort.textContent = sortLabels[Store.sortOrder] || 'Modified ↓';
// Render notes
$noteListItems.innerHTML = '';
if (notes.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-state';
if (view === 'trash') {
empty.innerHTML = '<div class="empty-icon">🗑</div><div class="empty-title">Trash is empty</div><div class="empty-desc">Deleted notes will appear here.</div>';
} else if (Store.searchQuery) {
empty.innerHTML = '<div class="empty-icon">🔍</div><div class="empty-title">No results</div><div class="empty-desc">No notes match your search.</div>';
} else if (Store.activeTag) {
empty.innerHTML = '<div class="empty-icon">🏷</div><div class="empty-title">No notes with this tag</div>';
} else if (Store.selectedNotebookId !== 'trash' && Store.notebooks.length === 0) {
empty.innerHTML = '<div class="empty-icon">📝</div><div class="empty-title">No notebooks yet</div><div class="empty-desc">Create a notebook to get started.</div>';
} else {
empty.innerHTML = '<div class="empty-icon">📄</div><div class="empty-title">No notes yet</div><div class="empty-desc">Create a new note to get started.</div>';
}
$noteListItems.appendChild(empty);
return;
}
notes.forEach(note => {
const item = document.createElement('div');
item.className = 'note-item' + (Store.selectedNoteId === note.id ? ' selected' : '') + (note.pinned ? ' pinned' : '');
item.dataset.noteId = note.id;
item.draggable = true;
const snippet = htmlToPlainText(note.content).substring(0, CONFIG.snippetLength);
const timeAgo = formatTimeAgo(note.updatedAt);
const tagsHtml = note.tags.map(t => `<span class="note-tag">${escHtml(t)}</span>`).join('');
item.innerHTML = `
<div class="note-title">${escHtml(note.title)}</div>
<div class="note-snippet">${escHtml(snippet || 'No content')}</div>
<div class="note-meta">
<span class="note-time">${timeAgo}</span>
${tagsHtml}
</div>
<div class="note-actions">
<button data-action="pin" title="${note.pinned ? 'Unpin' : 'Pin'}">📌</button>
<button data-action="duplicate" title="Duplicate">📋</button>
<button data-action="move" title="Move">📁</button>
<button data-action="delete" title="Delete">🗑</button>
</div>
`;
$noteListItems.appendChild(item);
});
}
function renderEditor() {
const note = Store.getNote(Store.selectedNoteId);
if (!note) {
$editor.innerHTML = '';
return;
}
// Only update if content differs (avoid cursor jump)
if ($editor.innerHTML !== note.content) {
$editor.innerHTML = note.content;
}
// Update tag input
$tagInput.value = note.tags.join(', ');
updateToolbarState();
}
function updateToolbarState() {
// Update active states on toolbar buttons based on current selection
const commands = ['bold','italic','underline','strikeThrough','insertUnorderedList','insertOrderedList'];
commands.forEach(cmd => {
const btn = $toolbar.querySelector(`[data-cmd="${cmd}"]`);
if (btn) {
try {
btn.classList.toggle('active', document.queryCommandState(cmd));
} catch(e) {}
}
});
// formatBlock is special
const blockBtns = $toolbar.querySelectorAll('[data-cmd="formatBlock"]');
blockBtns.forEach(btn => {
const val = btn.dataset.value;
try {
const current = document.queryCommandValue('formatBlock');
btn.classList.toggle('active', current && current.toLowerCase() === val);
} catch(e) {}
});
}
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatTimeAgo(ts) {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(ts).toLocaleDateString();
}
function showSaveIndicator(msg, type) {
$saveIndicator.textContent = msg;
$saveIndicator.className = type;
clearTimeout(saveIndicatorTimeout);
saveIndicatorTimeout = setTimeout(() => {
$saveIndicator.textContent = 'Saved';
$saveIndicator.className = 'saved';
}, 2000);
}
function triggerAutoSave() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
const note = Store.getNote(Store.selectedNoteId);
if (note && Store.selectedNoteId) {
const content = $editor.innerHTML;
Store.updateNoteContent(Store.selectedNoteId, content);
showSaveIndicator('Saved', 'saved');
}
}, CONFIG.autosaveDelay);
showSaveIndicator('Saving…', 'unsaved');
}
/* ============================================================
EDITOR CONTROLLER
============================================================ */
let undoStack = [];
let redoStack = [];
let isUndoRedo = false;
let isHandlingMarkdown = false;
function pushUndoSnapshot() {
if (isUndoRedo || isHandlingMarkdown) return;
const content = $editor.innerHTML;
undoStack.push(content);
if (undoStack.length > CONFIG.maxUndoStack) undoStack.shift();
redoStack = [];
}
function execCommand(cmd, value) {
pushUndoSnapshot();
document.execCommand(cmd, false, value || null);
$editor.focus();
triggerAutoSave();
updateToolbarState();
}
function editorKeydown(e) {
const ctrl = e.ctrlKey || e.metaKey;
// Undo/Redo
if (ctrl && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
doUndo();
return;
}
if (ctrl && e.key === 'z' && e.shiftKey) {
e.preventDefault();
doRedo();
return;
}
if (ctrl && e.key === 'y') {
e.preventDefault();
doRedo();
return;
}
// Bold/Italic/Underline shortcuts
if (ctrl && e.key === 'b') { e.preventDefault(); execCommand('bold'); return; }
if (ctrl && e.key === 'i') { e.preventDefault(); execCommand('italic'); return; }
if (ctrl && e.key === 'u') { e.preventDefault(); execCommand('underline'); return; }
// Markdown shortcuts: detect on space key at line start
if (e.key === ' ' && !ctrl && !e.altKey && !e.metaKey) {
handleMarkdownShortcut(e);
return;
}
// Enter key: handle list continuation and empty item exit
if (e.key === 'Enter' && !ctrl && !e.altKey) {
handleEnterKey(e);
return;
}
// Escape: exit empty list item
if (e.key === 'Escape') {
handleEscapeKey(e);
return;
}
}
function handleMarkdownShortcut(e) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const range = sel.getRangeAt(0);
// Find the start of the current line
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents($editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
const textBefore = preCaretRange.toString();
const lineStart = textBefore.lastIndexOf('\n') + 1;
const currentLine = textBefore.substring(lineStart);
// Check patterns (must be at line start, marker only - space hasn't been inserted yet)
const patterns = [
{ regex: /^#$/, tag: 'h1' },
{ regex: /^##$/, tag: 'h2' },
{ regex: /^###$/, tag: 'h3' },
{ regex: /^-$/, tag: 'ul' },
{ regex: /^>$/, tag: 'blockquote' },
{ regex: /^\[\]$/, tag: 'checklist' },
];
for (const p of patterns) {
if (p.regex.test(currentLine)) {
e.preventDefault();
isHandlingMarkdown = true;
pushUndoSnapshot();
isHandlingMarkdown = false;
// Delete the marker text (e.g., "#") from the line
const markerLen = currentLine.length;
const deleteRange = range.cloneRange();
deleteRange.setStart(range.startContainer, Math.max(0, range.startOffset - markerLen));
deleteRange.deleteContents();
// Apply the appropriate formatting
switch (p.tag) {
case 'h1':
case 'h2':
case 'h3':
document.execCommand('formatBlock', false, p.tag);
break;
case 'ul':
document.execCommand('insertUnorderedList');
break;
case 'blockquote':
document.execCommand('formatBlock', false, 'blockquote');
break;
case 'checklist':
document.execCommand('insertUnorderedList');
// Convert the new list item to have a checkbox
setTimeout(() => {
const lis = $editor.querySelectorAll('li');
const lastLi = lis[lis.length - 1];
if (lastLi && !lastLi.querySelector('input[type="checkbox"]')) {
const cb = document.createElement('input');
cb.type = 'checkbox';
lastLi.insertBefore(cb, lastLi.firstChild);
}
}, 0);
break;
}
$editor.focus();
updateToolbarState();
triggerAutoSave();
return;
}
}
}
function handleEnterKey(e) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
const li = getParentElement(node, 'LI');
if (li) {
// Check if the list item is effectively empty (only a checkbox or nothing)
const text = li.textContent.trim();
const hasCheckbox = li.querySelector('input[type="checkbox"]');
if (text === '' || (hasCheckbox && text.length <= 1)) {
// Empty list item — exit the list by converting to a paragraph
e.preventDefault();
pushUndoSnapshot();
const block = document.createElement('p');
li.parentNode.replaceChild(block, li);
const newRange = document.createRange();
newRange.selectNodeContents(block);
newRange.collapse(false);
sel.removeAllRanges();
sel.addRange(newRange);
triggerAutoSave();
return;
}
}
// For non-empty list items, let the browser handle Enter naturally
// (it will create a new <li> in the list)
pushUndoSnapshot();
}
function handleEscapeKey(e) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const node = sel.anchorNode;
const li = getParentElement(node, 'LI');
if (li && li.textContent.trim() === '') {
e.preventDefault();
pushUndoSnapshot();
const block = document.createElement('p');
li.parentNode.replaceChild(block, li);
const newRange = document.createRange();
newRange.selectNodeContents(block);
newRange.collapse(false);
sel.removeAllRanges();
sel.addRange(newRange);
triggerAutoSave();
}
}
function getParentElement(node, tagName) {
let el = node;
while (el && el !== $editor) {
if (el.nodeType === Node.ELEMENT_NODE && el.tagName.toLowerCase() === tagName.toLowerCase()) {
return el;
}
el = el.parentNode;
}
return null;
}
function doUndo() {
if (undoStack.length === 0) return;
redoStack.push($editor.innerHTML);
isUndoRedo = true;
$editor.innerHTML = undoStack.pop();
isUndoRedo = false;
triggerAutoSave();
updateToolbarState();
}
function doRedo() {
if (redoStack.length === 0) return;
undoStack.push($editor.innerHTML);
isUndoRedo = true;
$editor.innerHTML = redoStack.pop();
isUndoRedo = false;
triggerAutoSave();
updateToolbarState();
}
/* ============================================================
CHECKLIST CHECKBOX PERSISTENCE & SEARCH HIGHLIGHTING
============================================================ */
$editor.addEventListener('change', e => {
if (e.target.matches('input[type="checkbox"]')) {
pushUndoSnapshot();
triggerAutoSave();
}
});
// Also handle click on checkboxes to ensure model is updated
$editor.addEventListener('click', e => {
const cb = e.target.closest('input[type="checkbox"]');
if (cb) {
e.stopPropagation();
pushUndoSnapshot();
triggerAutoSave();
}
});
/* ============================================================
PASTE SANITIZATION
============================================================ */
function handlePaste(e) {
e.preventDefault();
pushUndoSnapshot();
let html = '';
let text = '';
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData) {
html = clipboardData.getData('text/html') || '';
text = clipboardData.getData('text/plain') || '';
}
if (html) {
// Sanitize the HTML
const clean = sanitizeHTML(html);
document.execCommand('insertHTML', false, clean);
} else if (text) {
// Insert plain text, escaping HTML entities
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
document.execCommand('insertHTML', false, escaped);
}
triggerAutoSave();
updateToolbarState();
}
/* ============================================================
SEARCH
============================================================ */
function performSearch(query) {
Store.setSearchQuery(query);
renderNoteList();
// Highlight matches in editor if a note is selected
highlightSearchInEditor(query);
}
function highlightSearchInEditor(query) {
// Remove existing highlights
$editor.querySelectorAll('mark').forEach(m => {
const parent = m.parentNode;
parent.replaceChild(document.createTextNode(m.textContent), m);
parent.normalize();
});
if (!query || !Store.selectedNoteId) return;
const textNodes = getTextNodes($editor);
const lowerQuery = query.toLowerCase();
textNodes.forEach(node => {
const text = node.textContent;
const lower = text.toLowerCase();
let idx = lower.indexOf(lowerQuery);
while (idx !== -1) {
const mark = document.createElement('mark');
mark.textContent = text.substring(idx, idx + query.length);
const before = text.substring(0, idx);
const after = text.substring(idx + query.length);
const parent = node.parentNode;
if (before) parent.insertBefore(document.createTextNode(before), node);
parent.insertBefore(mark, node);
if (after) parent.insertBefore(document.createTextNode(after), node);
parent.removeChild(node);
// Move to next text node
node = after ? parent.childNodes[parent.childNodes.length - 1] : null;
if (node && node.nodeType === Node.TEXT_NODE) {
const newLower = node.textContent.toLowerCase();
idx = newLower.indexOf(lowerQuery);
} else {
idx = -1;
}
}
});
}
function getTextNodes(node) {
const result = [];
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
result.push(node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Skip mark elements to avoid double-highlighting
if (node.tagName.toLowerCase() === 'mark') return result;
Array.from(node.childNodes).forEach(child => {
result.push(...getTextNodes(child));
});
}
return result;
}
/* ============================================================
DRAG & DROP — Move notes between notebooks
============================================================ */
let draggedNoteId = null;
function setupDragAndDrop() {
// Note list items are draggable
$noteListItems.addEventListener('dragstart', e => {
const noteItem = e.target.closest('.note-item');
if (!noteItem) return;
draggedNoteId = noteItem.dataset.noteId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', draggedNoteId);
noteItem.style.opacity = '0.5';
});
$noteListItems.addEventListener('dragend', e => {
const noteItem = e.target.closest('.note-item');
if (noteItem) noteItem.style.opacity = '';
draggedNoteId = null;
});
// Sidebar notebooks are drop targets
$notebookList.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const notebookItem = e.target.closest('.notebook-item');
if (notebookItem) {
notebookItem.style.background = 'var(--accent-light)';
}
});
$notebookList.addEventListener('dragleave', e => {
const notebookItem = e.target.closest('.notebook-item');
if (notebookItem) notebookItem.style.background = '';
});
$notebookList.addEventListener('drop', e => {
e.preventDefault();
const notebookItem = e.target.closest('.notebook-item');
if (notebookItem) notebookItem.style.background = '';
const notebookId = notebookItem?.dataset.notebookId;
if (draggedNoteId && notebookId && notebookId !== 'all' && notebookId !== 'trash') {
Store.moveNote(draggedNoteId, notebookId);
renderNoteList();
renderSidebar();
}
draggedNoteId = null;
});
}
/* ============================================================
CONTEXT MENU
============================================================ */
function showContextMenu(x, y) {
const note = Store.getNote(Store.selectedNoteId);
if (!note) return;
const menu = $contextMenu;
// Update pin label
const pinItem = menu.querySelector('[data-action="pin"]');
pinItem.innerHTML = `<span class="menu-icon">${note.pinned ? '📌' : '📍'}</span>${note.pinned ? 'Unpin' : 'Pin'}`;
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.style.display = 'block';
}
function hideContextMenu() {
$contextMenu.style.display = 'none';
}
/* ============================================================
MODAL
============================================================ */
let modalCallback = null;
function showModal(title, placeholder, value, callback) {
$modalTitle.textContent = title;
$modalInput.value = value || '';
$modalInput.placeholder = placeholder || '';
$modalOverlay.classList.add('visible');
$modalInput.focus();
modalCallback = callback;
}
function hideModal() {
$modalOverlay.classList.remove('visible');
modalCallback = null;
}
/* ============================================================
EVENT HANDLERS
============================================================ */
// --- Sidebar notebook list ---
$notebookList.addEventListener('click', e => {
const renameBtn = e.target.closest('[data-action="rename"]');
const deleteBtn = e.target.closest('[data-action="delete"]');
const notebookItem = e.target.closest('.notebook-item');
if (renameBtn && notebookItem) {
const nb = Store.getNotebook(notebookItem.dataset.notebookId);
if (nb) {
showModal('Rename Notebook', 'Notebook name…', nb.name, (name) => {
if (name.trim()) Store.renameNotebook(nb.id, name.trim());
renderSidebar();
renderNoteList();
});
}
return;
}
if (deleteBtn && notebookItem) {
const nb = Store.getNotebook(notebookItem.dataset.notebookId);
if (nb && confirm(`Delete notebook "${nb.name}"? Notes will be moved to trash.`)) {
Store.deleteNotebook(nb.id);
renderSidebar();
renderNoteList();
}
return;
}
if (notebookItem) {
Store.setSelectedNotebook(notebookItem.dataset.notebookId);
renderSidebar();
renderNoteList();
renderEditor();
}
});
// --- Note list ---
$noteListItems.addEventListener('click', e => {
// Check for action buttons
const actionBtn = e.target.closest('[data-action]');
if (actionBtn) {
e.stopPropagation();
const noteItem = e.target.closest('.note-item');
const noteId = noteItem?.dataset.noteId;
if (!noteId) return;
switch(actionBtn.dataset.action) {
case 'pin':
Store.togglePin(noteId);
renderNoteList();
break;
case 'duplicate':
const dup = Store.duplicateNote(noteId);
if (dup) { Store.selectNote(dup.id); renderNoteList(); renderEditor(); }
break;
case 'move':
showMoveMenu(noteId, e.target.closest('.note-item'));
break;
case 'delete':
Store.deleteNote(noteId);
renderNoteList();
renderEditor();
break;
}
return;
}
// Select note
const noteItem = e.target.closest('.note-item');
if (noteItem) {
Store.selectNote(noteItem.dataset.noteId);
renderNoteList();
renderEditor();
$editor.focus();
}
});
// Note list drag and drop (for reordering within list)
$noteListItems.addEventListener('dragstart', e => {
const noteItem = e.target.closest('.note-item');
if (!noteItem) return;
draggedNoteId = noteItem.dataset.noteId;
e.dataTransfer.effectAllowed = 'move';
noteItem.style.opacity = '0.5';
});
$noteListItems.addEventListener('dragend', e => {
const noteItem = e.target.closest('.note-item');
if (noteItem) noteItem.style.opacity = '';
draggedNoteId = null;
});
function showMoveMenu(noteId, anchorEl) {
// Show a simple move-to notebook menu
const menu = $contextMenu;
menu.innerHTML = '';
// Add notebook options
Store.notebooks.forEach(nb => {
const item = document.createElement('div');
item.className = 'menu-item';
item.innerHTML = `<span class="menu-icon" style="color:${nb.color}">●</span>${escHtml(nb.name)}`;
item.addEventListener('click', () => {
Store.moveNote(noteId, nb.id);
renderNoteList();
renderSidebar();
hideContextMenu();
});
menu.appendChild(item);
});
// Separator
const sep = document.createElement('div');
sep.className = 'menu-separator';
menu.appendChild(sep);
// Trash option
const trashItem = document.createElement('div');
trashItem.className = 'menu-item danger';
trashItem.innerHTML = '<span class="menu-icon">🗑</span>Trash';
trashItem.addEventListener('click', () => {
Store.deleteNote(noteId);
renderNoteList();
renderEditor();
hideContextMenu();
});
menu.appendChild(trashItem);
// Position and show
const rect = anchorEl.getBoundingClientRect();
menu.style.left = rect.left + 'px';
menu.style.top = rect.bottom + 'px';
menu.style.display = 'block';
}
// --- Toolbar ---
$toolbar.addEventListener('click', e => {
const btn = e.target.closest('.toolbar-btn');
if (!btn) return;
const cmd = btn.dataset.cmd;
const value = btn.dataset.value;
if (cmd === 'insertCheckList') {
// Custom checklist handling
pushUndoSnapshot();
document.execCommand('insertUnorderedList');
// Convert the new list item to a checkbox
setTimeout(() => {
const lis = $editor.querySelectorAll('li');
const lastLi = lis[lis.length - 1];
if (lastLi && !lastLi.querySelector('input[type="checkbox"]')) {
const cb = document.createElement('input');
cb.type = 'checkbox';
lastLi.insertBefore(cb, lastLi.firstChild);
}
}, 0);
triggerAutoSave();
updateToolbarState();
return;
}
if (cmd === 'insertHorizontalRule') {
pushUndoSnapshot();
document.execCommand('insertHorizontalRule');
triggerAutoSave();
return;
}
if (cmd === 'formatBlock' && value) {
execCommand(cmd, value);
return;
}
if (cmd) {
execCommand(cmd);
}
});
// --- Editor events ---
$editor.addEventListener('input', () => {
triggerAutoSave();
updateToolbarState();
});
$editor.addEventListener('keydown', editorKeydown);
$editor.addEventListener('paste', handlePaste);
$editor.addEventListener('mouseup', updateToolbarState);
$editor.addEventListener('keyup', updateToolbarState);
$editor.addEventListener('focus', updateToolbarState);
// --- Global search ---
$globalSearch.addEventListener('input', e => {
performSearch(e.target.value.trim());
});
// --- Tag input ---
$tagInput.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const raw = $tagInput.value.trim().replace(/,/g, '');
if (!raw || !Store.selectedNoteId) return;
const note = Store.getNote(Store.selectedNoteId);
if (!note) return;
const tags = [...new Set([...note.tags, raw.toLowerCase()])];
Store.updateNoteTags(Store.selectedNoteId, tags);
$tagInput.value = '';
renderNoteList();
triggerAutoSave();
}
if (e.key === 'Backspace' && !$tagInput.value && Store.selectedNoteId) {
const note = Store.getNote(Store.selectedNoteId);
if (note && note.tags.length > 0) {
const tags = note.tags.slice(0, -1);
Store.updateNoteTags(Store.selectedNoteId, tags);
renderNoteList();
triggerAutoSave();
}
}
});
// --- Sidebar collapse ---
$btnCollapse.addEventListener('click', () => {
Store.toggleSidebar();
$sidebar.classList.toggle('collapsed', Store.sidebarCollapsed);
// Trigger resize
setTimeout(() => window.dispatchEvent(new Event('resize')), 300);
});
// --- Theme toggle ---
$btnTheme.addEventListener('click', () => {
const newTheme = Store.theme === 'light' ? 'dark' : 'light';
Store.setTheme(newTheme);
$btnTheme.textContent = newTheme === 'light' ? '🌙' : '☀';
});
// --- New notebook ---
$btnNewNotebook.addEventListener('click', () => {
showModal('New Notebook', 'Notebook name…', '', (name) => {
if (name.trim()) {
Store.createNotebook(name.trim());
renderSidebar();
renderNoteList();
}
});
});
// --- New note ---
$btnNewNote.addEventListener('click', () => {
const note = Store.createNote();
renderNoteList();
renderEditor();
$editor.focus();
// Place cursor at end
placeCursorAtEnd($editor);
});
// --- Sort toggle ---
$btnSort.addEventListener('click', () => {
const orders = ['modified', 'created', 'alpha', 'pinned'];
const idx = orders.indexOf(Store.sortOrder);
const next = orders[(idx + 1) % orders.length];
Store.setSortOrder(next);
renderNoteList();
});
// --- Context menu ---
$noteListItems.addEventListener('contextmenu', e => {
e.preventDefault();
const noteItem = e.target.closest('.note-item');
if (!noteItem) return;
Store.selectNote(noteItem.dataset.noteId);
renderNoteList();
renderEditor();
showContextMenu(e.clientX, e.clientY);
});
$contextMenu.addEventListener('click', e => {
const item = e.target.closest('.menu-item');
if (!item) return;
const action = item.dataset.action;
const noteId = Store.selectedNoteId;
switch(action) {
case 'pin':
if (noteId) Store.togglePin(noteId);
break;
case 'duplicate':
if (noteId) {
const dup = Store.duplicateNote(noteId);
if (dup) { Store.selectNote(dup.id); }
}
break;
case 'move':
if (noteId) {
const noteItem = document.querySelector('.note-item.selected');
showMoveMenu(noteId, noteItem);
}
break;
case 'delete':
if (noteId) {
Store.deleteNote(noteId);
renderNoteList();
renderEditor();
}
break;
}
hideContextMenu();
renderNoteList();
renderSidebar();
});
document.addEventListener('click', () => hideContextMenu());
// --- Modal ---
$modalConfirm.addEventListener('click', () => {
if (modalCallback) modalCallback($modalInput.value);
hideModal();
});
$modalCancel.addEventListener('click', hideModal);
$modalOverlay.addEventListener('click', e => {
if (e.target === $modalOverlay) hideModal();
});
$modalInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
if (modalCallback) modalCallback($modalInput.value);
hideModal();
}
if (e.key === 'Escape') hideModal();
});
// --- Resize handles ---
function setupResize(handle, pane, isSidebar) {
let startX, startWidth;
handle.addEventListener('mousedown', e => {
e.preventDefault();
startX = e.clientX;
startWidth = isSidebar ? pane.offsetWidth : pane.offsetWidth;
handle.classList.add('active');
const onMouseMove = e => {
const diff = e.clientX - startX;
const newWidth = isSidebar ? startWidth + diff : startWidth - diff;
const minW = 180;
const maxW = 500;
const clamped = Math.max(minW, Math.min(maxW, newWidth));
pane.style.width = clamped + 'px';
if (isSidebar) Store.sidebarWidth = clamped;
else Store.notelistWidth = clamped;
};
const onMouseUp = () => {
handle.classList.remove('active');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
Store._save();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
setupResize(document.getElementById('resize-sidebar'), $sidebar, true);
setupResize(document.getElementById('resize-notelist'), document.getElementById('note-list'), false);
// --- Notebook item drag for reordering (not implemented in detail, skip) ---
/* ============================================================
NAV ITEM CLICK (All Notes / Trash)
============================================================ */
document.querySelector('.sidebar-nav').addEventListener('click', e => {
const navItem = e.target.closest('.nav-item');
if (!navItem) return;
const view = navItem.dataset.view;
Store.setSelectedNotebook(view);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
navItem.classList.add('active');
renderNoteList();
renderEditor();
});
/* ============================================================
INITIALIZATION
============================================================ */
function init() {
// Load theme
const savedTheme = Store.theme || 'light';
Store.setTheme(savedTheme);
$btnTheme.textContent = savedTheme === 'light' ? '🌙' : '☀';
// Load persisted state
const loaded = Store.load();
// Seed default data if first load
if (!loaded || (Store.notebooks.length === 0 && Store.notes.length === 0)) {
const inbox = Store.createNotebook('Inbox');
const personal = Store.createNotebook('Personal');
const work = Store.createNotebook('Work');
Store.createNote(inbox.id);
const welcome = Store.notes[Store.notes.length - 1];
welcome.title = 'Welcome to Notes';
welcome.content = '<h1>Welcome to Notes</h1><p>This is your <strong>new notes app</strong>. Start writing to see the magic.</p><ul><li><input type="checkbox"> Try checking this box</li><li>Edit this note with the toolbar above</li><li>Press <code>Ctrl+B</code> for bold</li></ul><blockquote>Every great note starts with a single word.</blockquote>';
welcome.pinned = true;
welcome.updatedAt = Date.now();
Store.createNote(work.id);
const meeting = Store.notes[Store.notes.length - 1];
meeting.title = 'Meeting Notes';
meeting.content = '<h2>Sprint Planning</h2><p>Discussed the new features for next sprint:</p><ol><li>Three-pane layout</li><li>Rich text editor</li><li>Search & tags</li></ol><p><em>Meeting ended at 3pm</em></p>';
meeting.updatedAt = Date.now();
Store.createNote(personal.id);
const ideas = Store.notes[Store.notes.length - 1];
ideas.title = 'Project Ideas';
ideas.content = '<h3>Ideas to explore</h3><ul><li><input type="checkbox"> Build a canvas drawing app</li><li><input type="checkbox"> Create a markdown parser</li><li>Study WebGL shaders</li></ul><p>Tags: <span style="color:var(--accent)">#creative</span></p>';
ideas.tags = ['creative'];
ideas.updatedAt = Date.now();
Store._save();
}
// Restore layout
$sidebar.classList.toggle('collapsed', Store.sidebarCollapsed);
$sidebar.style.width = Store.sidebarWidth + 'px';
document.getElementById('note-list').style.width = Store.notelistWidth + 'px';
// Render all
renderSidebar();
renderNoteList();
renderEditor();
setupDragAndDrop();
// Set active nav item
document.querySelectorAll('.nav-item').forEach(n => {
n.classList.toggle('active', n.dataset.view === Store.selectedNotebookId);
});
// Restore sort button text
const sortLabels = { modified: 'Modified ↓', created: 'Created ↓', alpha: 'A → Z', pinned: 'Pinned' };
$btnSort.textContent = sortLabels[Store.sortOrder] || 'Modified ↓';
// Restore tag input
if (Store.selectedNoteId) {
const note = Store.getNote(Store.selectedNoteId);
if (note) $tagInput.value = note.tags.join(', ');
}
// Show save indicator
showSaveIndicator('Ready', 'saved');
}
function placeCursorAtEnd(el) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
// Initialize
init();
// Auto-save on beforeunload
window.addEventListener('beforeunload', () => {
const note = Store.getNote(Store.selectedNoteId);
if (note && Store.selectedNoteId) {
Store.updateNoteContent(Store.selectedNoteId, $editor.innerHTML);
}
});
</script>
</body>
</html>
<!-- agent-meta {"model":"inclusionai/ling-3.0-flash","provider":"openrouter","persona":"full","sessionId":"3c265fbf-d482-40da-be55-a120dbe587fc","tokensIn":2094337,"tokensOut":63262,"tokensTotal":2157599,"cost":0.0165356898,"turns":34,"toolCalls":33,"failedToolCalls":0,"timestamp":"2026-08-24T23:07:11.780Z"} -->