Notes
Claude haiku 5.5 · full
◈Launch the experiment
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 5387f5bcf6
# Task: Build a Notes Application
Build a polished, fully functional notes application (vanilla JS, no external assets, no CDN, no libraries) in the spirit of UpNote, Obsidian, OneNote, or Notion. The top priority is **product-grade UI quality and editor correctness** — this should look and behave like a real shipping notes app, not a demo.
## Core Specification
**Three-Pane Layout**
- **Notebook/sidebar pane:** notebooks (or folders) grouping notes, with note lists under them; create/rename/delete notebooks; collapsible; an "All Notes" virtual view and a Trash
- **Note list pane:** notes in the selected notebook — title, snippet preview, updated timestamp, sort order toggle (modified / created / alphabetical); pinned notes float to the top
- **Editor pane:** the selected note, editing in place
- Panes are resizable via drag handles and the sidebar is collapsible; layout state persists
**Editor**
- Rich text via `contenteditable` with a formatting toolbar: bold, italic, underline, strikethrough, inline code, H1/H2/H3, bulleted and numbered lists, checklist items with clickable checkboxes, blockquote, horizontal rule
- Toolbar buttons reflect the formatting state at the cursor (bold button active when selection is bold)
- Keyboard shortcuts: Ctrl/Cmd+B/I/U, standard list continuation on Enter, Escape-empty-list-item on Enter
- **Markdown-style input shortcuts:** typing `# ` at line start converts to H1, `- ` to a bullet list, `[] ` to a checklist item, `> ` to a blockquote
- Paste is sanitized to the supported formatting subset — no foreign markup or scripts survive pasting
- Undo/redo (Ctrl+Z / Ctrl+Shift+Z) that works reliably with the custom formatting, not just browser defaults
**Organization & Search**
- Move notes between notebooks (drag-and-drop onto sidebar notebooks, or a move menu)
- Pin/unpin, duplicate, and delete notes; deleted notes go to Trash and can be restored or permanently deleted
- Global search: live filter across note titles and body text, with matched-text highlighting in results; search must cover the text content, not raw HTML tags
- Tags: add multiple tags per note; clicking a tag filters the note list
**Persistence**
- Everything — notebooks, notes (with formatting), tags, pins, trash, sort order, selection, and pane layout — serializes to `localStorage` and restores exactly on reload
- Auto-save as you type (debounced), with a subtle "Saved" indicator; never lose keystrokes
## Visual Style
- Refined productivity-app aesthetic: warm light theme (plus a dark theme toggle), elegant serif or clean sans reading typography in the editor, restrained chrome
- Clear visual hierarchy between the three panes; selected states obvious; hover states on every interactive element
- Micro-interactions: smooth sidebar collapse, note selection transitions, a tasteful empty state when no note is selected ("Select or create a note")
- This prompt is judged heavily on look and feel — sweat the spacing, typography, and focus states
## Technical Requirements
- Vanilla JS; all code organized with clear section comments
- Single source of truth: a serializable data model (notebooks, notes, tags) — the DOM is a projection of it, never the other way around
- Note content stored as sanitized HTML; a pure text-extraction function feeds search and snippets
- Tunable constants block at the top (colors, fonts, autosave debounce, snippet length)
## Quality Bar (must hit all)
- Formatting round-trips: bold a word, reload, it's still bold; checklist check states persist
- Search finds text inside formatted content (e.g. a word inside a bold span) and never matches tag names like "b" or "div"
- Enter at the end of a list item continues the list; Enter on an empty item exits it
- Deleting a note, then restoring it from Trash, returns it to its original notebook with formatting intact
- Markdown shortcuts trigger only at line start and convert immediately on the trailing space
## Self-Check Before Finishing
Verify with the browser tools (probe by setting editor content, dispatching input/keydown events, and asserting the model + DOM):
- Type a checklist item, toggle its checkbox, reload, and assert the checked state survived
- Apply bold to a word, search for that word, and assert it appears in results
- Type `# Hello` at a line start and assert an H1 is produced; type `#` mid-sentence and assert it stays literal text
- Delete a note, assert it's in Trash, restore it, and assert it reappears in its notebook
- Paste HTML containing a `<script>` tag and assert it is stripped
Inspect original source 81,786 bytes · SHA-256 4a985f26f006
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notes</title>
<style>
/* =====================================================================
1. THEME TOKENS — tune colours, fonts and radii here
===================================================================== */
:root{
--bg:#f5f1e9;
--side:#ece6da;
--list:#f9f6f0;
--editor:#fffdf9;
--ink:#2b2622;
--ink-2:#6a6056;
--ink-3:#9b9084;
--line:#e2d9c9;
--hover:rgba(110,80,40,.075);
--sel:#f0e2cf;
--accent:#b8632e;
--accent-ink:#fffaf4;
--accent-soft:rgba(184,99,46,.14);
--mark:#f5d88a;
--danger:#b4412f;
--shadow:0 1px 2px rgba(60,40,20,.06),0 6px 20px rgba(60,40,20,.08);
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
--font-serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
--font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
[data-theme="dark"]{
--bg:#171614;
--side:#1d1c1a;
--list:#211f1d;
--editor:#1c1b19;
--ink:#ece6dc;
--ink-2:#b5ada2;
--ink-3:#7f776d;
--line:#34302b;
--hover:rgba(255,240,220,.06);
--sel:#352e27;
--accent:#e08a55;
--accent-ink:#1c1a18;
--accent-soft:rgba(224,138,85,.18);
--mark:#6d5b1f;
--danger:#e56d55;
--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35);
}
/* =====================================================================
2. BASE + LAYOUT
===================================================================== */
*,*::before,*::after{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--font-ui);font-size:14px;color:var(--ink);background:var(--bg);overflow:hidden;-webkit-font-smoothing:antialiased;line-height:1.4}
button,input,select{font:inherit;color:inherit}
button{background:none;border:0;padding:0;cursor:pointer}
input{background:none;border:0;outline:none}
svg{display:block;flex:none}
.ico{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
:focus{outline:none}
:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:6px}
[hidden]{display:none!important}
#app{display:grid;height:100vh;grid-template-columns:var(--w-side,232px) 6px var(--w-list,300px) 6px minmax(0,1fr);transition:grid-template-columns .28s cubic-bezier(.3,.7,.2,1)}
#app.side-collapsed{grid-template-columns:0px 0px var(--w-list,300px) 6px minmax(0,1fr)}
.handle{position:relative;cursor:col-resize;z-index:5}
.handle::after{content:'';position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:var(--line);transition:background .15s,width .15s}
.handle:hover::after,.handle.drag::after{background:var(--accent);width:2px}
#app.side-collapsed .handle-side{visibility:hidden}
/* ---------- sidebar ---------- */
.sidebar{background:var(--side);min-width:0;overflow:hidden}
.sidebar-inner{width:var(--w-side,232px);height:100%;display:flex;flex-direction:column}
.side-head{display:flex;align-items:center;justify-content:space-between;padding:14px 10px 8px 16px}
.brand{display:flex;align-items:center;gap:9px;font-family:var(--font-serif);font-size:17px;font-weight:600;letter-spacing:.01em}
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center}
.side-scroll{flex:1;overflow-y:auto;padding:2px 8px 12px;scrollbar-width:thin}
.nav-item{display:flex;align-items:center;gap:9px;width:100%;height:32px;padding:0 10px;border-radius:7px;color:var(--ink-2);text-align:left;white-space:nowrap;transition:background .15s,color .15s}
.nav-item:hover{background:var(--hover);color:var(--ink)}
.nav-item.active{background:var(--sel);color:var(--ink);font-weight:600}
.nav-item .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.nav-item .count{font-size:12px;color:var(--ink-3);font-variant-numeric:tabular-nums;font-weight:400}
.nav-item .rename{flex:1}
.section-head{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 6px 10px;font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-3)}
.chev{width:18px;height:18px;display:grid;place-items:center;border-radius:4px;transition:transform .2s ease,background .15s;color:var(--ink-3)}
.chev:hover{background:var(--hover);color:var(--ink)}
.chev.open{transform:rotate(90deg)}
.nb-notes{padding:2px 0 6px 22px;animation:slide .22s ease}
@keyframes slide{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
.nb-note{display:block;width:100%;text-align:left;height:26px;line-height:26px;padding:0 8px;border-radius:6px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;transition:background .15s,color .15s}
.nb-note:hover{background:var(--hover);color:var(--ink-2)}
.nb-note.active{color:var(--ink);background:var(--sel)}
.nb.drop>.nav-item{background:var(--accent-soft);box-shadow:inset 0 0 0 1.5px var(--accent);color:var(--ink)}
.tag-hash{color:var(--accent);font-weight:600;width:12px;text-align:center}
.side-foot{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-top:1px solid var(--line)}
.side-foot small{font-size:11.5px;color:var(--ink-3)}
/* ---------- shared buttons ---------- */
.icon-btn{width:28px;height:28px;display:grid;place-items:center;border-radius:7px;color:var(--ink-2);transition:background .15s,color .15s,transform .1s}
.icon-btn:hover{background:var(--hover);color:var(--ink)}
.icon-btn:active{transform:scale(.94)}
.icon-btn.on{background:var(--accent-soft);color:var(--accent)}
.icon-btn:disabled{opacity:.35;pointer-events:none}
.btn{height:32px;padding:0 14px;border-radius:8px;background:var(--accent);color:var(--accent-ink);font-weight:600;font-size:13px;display:inline-flex;align-items:center;gap:7px;transition:filter .15s,transform .1s}
.btn:hover{filter:brightness(1.07)}
.btn:active{transform:scale(.98)}
.btn.ghost{background:var(--hover);color:var(--ink-2)}
.btn.ghost:hover{color:var(--ink)}
/* ---------- list pane ---------- */
.listpane{background:var(--list);display:flex;flex-direction:column;min-width:0;overflow:hidden}
.list-head{padding:14px 12px 10px 12px;display:flex;flex-direction:column;gap:10px;border-bottom:1px solid var(--line)}
.list-top{display:flex;align-items:center;gap:2px}
.list-title{flex:1;min-width:0;font-family:var(--font-serif);font-size:19px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:4px}
.search{position:relative}
.search input{width:100%;height:32px;border-radius:8px;background:var(--editor);border:1px solid var(--line);padding:0 28px 0 32px;font-size:13px;transition:border-color .15s,box-shadow .15s}
.search input::placeholder{color:var(--ink-3)}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.search>.ico{position:absolute;left:10px;top:8px;color:var(--ink-3);pointer-events:none}
.search .clear{position:absolute;right:5px;top:5px;width:22px;height:22px;display:grid;place-items:center;border-radius:6px;color:var(--ink-3)}
.search .clear:hover{background:var(--hover);color:var(--ink)}
.list-tools{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px}
.list-sub{font-size:12px;color:var(--ink-3)}
.sort select{appearance:none;-webkit-appearance:none;background:transparent;border:0;color:var(--ink-2);font-size:12px;padding:3px 18px 3px 6px;border-radius:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%239b9084' stroke-width='3'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 4px center}
.sort select:hover{background-color:var(--hover)}
.list-scroll{flex:1;overflow-y:auto;padding:6px 8px 16px;scrollbar-width:thin}
.note-item{position:relative;padding:10px 12px 10px 14px;border-radius:9px;cursor:pointer;margin-bottom:2px;transition:background .15s,box-shadow .2s}
.note-item:hover{background:var(--hover)}
.note-item.sel{background:var(--editor);box-shadow:var(--shadow)}
.note-item.sel::before{content:'';position:absolute;left:0;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--accent)}
.note-item.dragging{opacity:.45}
.ni-title{font-weight:600;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px}
.ni-snip{color:var(--ink-2);font-size:12.5px;line-height:1.45;margin-top:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
.ni-meta{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:11px;color:var(--ink-3);min-width:0;white-space:nowrap;overflow:hidden}
.ni-meta .nbn{overflow:hidden;text-overflow:ellipsis}
.ni-pin{position:absolute;right:10px;top:11px;color:var(--accent)}
.ni-more{position:absolute;right:6px;top:30px;width:24px;height:24px;display:grid;place-items:center;border-radius:6px;color:var(--ink-3);opacity:0;transition:opacity .15s,background .15s}
.note-item:hover .ni-more,.note-item.sel .ni-more{opacity:1}
.ni-more:hover{background:var(--hover);color:var(--ink)}
.note-item:hover .ni-pin{display:none}
mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 1px}
.mini-tag{background:var(--hover);padding:0 6px;border-radius:99px;font-size:10.5px;color:var(--ink-2);flex:none}
.list-empty{padding:60px 20px;text-align:center;color:var(--ink-3);font-size:13px}
.list-empty .ico{width:28px;height:28px;margin:0 auto 10px;opacity:.6}
.section-label-in{padding:10px 12px 4px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3)}
/* ---------- editor pane ---------- */
.editorpane{background:var(--editor);min-width:0;display:flex;flex-direction:column;position:relative;overflow:hidden}
.ed-top{height:46px;display:flex;align-items:center;gap:2px;padding:0 10px 0 16px;border-bottom:1px solid var(--line);flex:none}
.crumbs{flex:1;min-width:0;font-size:12.5px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crumbs b{color:var(--ink-2);font-weight:500}
.save{font-size:12px;color:var(--ink-3);display:flex;align-items:center;gap:6px;margin-right:8px;white-space:nowrap}
.save .dot{width:6px;height:6px;border-radius:50%;background:#7fae7a;transition:background .2s}
.save.pending .dot{background:var(--accent);animation:pulse 1s infinite}
@keyframes pulse{50%{opacity:.25}}
.trash-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 16px;background:var(--accent-soft);color:var(--ink-2);font-size:13px;border-bottom:1px solid var(--line);flex:none}
.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 12px;border-bottom:1px solid var(--line);flex:none}
.tb{min-width:30px;height:30px;padding:0 6px;display:inline-grid;place-items:center;border-radius:7px;color:var(--ink-2);font-size:13px;transition:background .12s,color .12s,transform .1s}
.tb:hover{background:var(--hover);color:var(--ink)}
.tb:active{transform:scale(.95)}
.tb.on{background:var(--accent-soft);color:var(--accent)}
.tb.txt{font-family:var(--font-ui);font-weight:700;font-size:12.5px;letter-spacing:.01em}
.tb.txt.i{font-style:italic;font-family:var(--font-serif);font-size:14px}
.tb.txt.u{text-decoration:underline;text-underline-offset:3px}
.tb.txt.s{text-decoration:line-through}
.tb.txt.code{font-family:var(--font-mono);font-weight:500;font-size:12px}
.tb-sep{width:1px;height:18px;background:var(--line);margin:0 6px}
.ed-scroll{flex:1;overflow-y:auto;scrollbar-width:thin}
.ed-wrap{max-width:760px;margin:0 auto;padding:40px 56px 160px;position:relative;animation:fadeIn .25s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.ed-title{width:100%;font-family:var(--font-serif);font-size:34px;font-weight:600;line-height:1.2;letter-spacing:-.01em;color:var(--ink);padding:0;margin:0}
.ed-title::placeholder{color:var(--ink-3)}
.ed-title:read-only{cursor:default}
.ed-tags{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:12px 0 24px;min-height:24px}
.tag-chip{display:inline-flex;align-items:center;gap:2px;height:22px;padding:0 3px 0 9px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:12px;font-weight:500;cursor:pointer;transition:filter .15s}
.tag-chip:hover{filter:brightness(1.05)}
.tag-chip .x{width:16px;height:16px;border-radius:50%;display:grid;place-items:center;opacity:.6}
.tag-chip .x:hover{opacity:1;background:rgba(0,0,0,.08)}
.tag-add{height:22px;width:130px;font-size:12px;color:var(--ink-2);padding:0 6px;border-radius:99px;transition:background .15s}
.tag-add:hover{background:var(--hover)}
.tag-add:focus{background:var(--hover)}
.tag-add::placeholder{color:var(--ink-3)}
.ed-body-wrap{position:relative}
.ph{position:absolute;top:0;left:0;color:var(--ink-3);font-family:var(--font-serif);font-size:17px;line-height:1.72;pointer-events:none;display:none}
.ph.show{display:block}
.ed-body{position:relative;font-family:var(--font-serif);font-size:17px;line-height:1.72;color:var(--ink);min-height:55vh;overflow-wrap:break-word;word-wrap:break-word;caret-color:var(--accent)}
.ed-body h1{font-size:30px;font-weight:700;line-height:1.25;margin:1.1em 0 .35em;letter-spacing:-.01em}
.ed-body h2{font-size:23px;font-weight:700;line-height:1.3;margin:1em 0 .3em}
.ed-body h3{font-size:19px;font-weight:600;line-height:1.35;margin:.9em 0 .25em}
.ed-body h1:first-child,.ed-body h2:first-child,.ed-body h3:first-child{margin-top:0}
.ed-body ul,.ed-body ol{margin:.35em 0;padding-left:1.6em}
.ed-body li{margin:.12em 0}
.ed-body ul.todo{list-style:none;padding-left:0}
.ed-body ul.todo>li{position:relative;padding-left:30px}
.ed-body ul.todo>li::before{content:'';position:absolute;left:2px;top:.3em;width:17px;height:17px;border:1.6px solid var(--ink-3);border-radius:5px;cursor:pointer;transition:background .15s,border-color .15s}
.ed-body ul.todo>li:hover::before{border-color:var(--accent)}
.ed-body ul.todo>li[data-done="true"]::before{background:var(--accent);border-color:var(--accent)}
.ed-body ul.todo>li[data-done="true"]::after{content:'';position:absolute;left:8px;top:calc(.3em + 3px);width:4px;height:8px;border:solid var(--accent-ink);border-width:0 2px 2px 0;transform:rotate(45deg)}
.ed-body ul.todo>li[data-done="true"]{color:var(--ink-3);text-decoration:line-through;text-decoration-color:var(--ink-3)}
.ed-body blockquote{margin:.6em 0;padding:.05em 0 .05em 16px;border-left:3px solid var(--accent);color:var(--ink-2);font-style:italic}
.ed-body blockquote>div{min-height:1.4em}
.ed-body code{font-family:var(--font-mono);font-size:.84em;background:var(--hover);border:1px solid var(--line);padding:.06em .36em;border-radius:5px}
.ed-body hr{border:0;height:1px;background:var(--line);margin:1.6em 0}
.ed-body s{text-decoration-thickness:1.5px}
/* ---------- empty state / banners / menus / toast ---------- */
.empty{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--ink-3);text-align:center;animation:fadeIn .3s ease}
.empty.show{display:flex}
.empty .big{width:58px;height:58px;border-radius:16px;background:var(--side);display:grid;place-items:center;color:var(--ink-2);margin-bottom:4px}
.empty p{font-family:var(--font-serif);font-size:18px;color:var(--ink-2);margin:0}
.empty small{font-size:12.5px}
.menu{position:fixed;z-index:100;min-width:220px;background:var(--editor);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:5px;animation:pop .14s ease}
@keyframes pop{from{opacity:0;transform:scale(.97) translateY(-2px)}to{opacity:1;transform:none}}
.menu-item{display:flex;align-items:center;gap:10px;width:100%;height:30px;padding:0 10px;border-radius:6px;text-align:left;color:var(--ink);font-size:13px;white-space:nowrap}
.menu-item:hover{background:var(--hover)}
.menu-item.danger{color:var(--danger)}
.menu-item .ck{margin-left:auto;color:var(--accent)}
.menu-item .ico{color:var(--ink-3)}
.menu-item.danger .ico{color:var(--danger)}
.menu-head{padding:7px 10px 3px;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);font-weight:600}
.menu-sep{height:1px;background:var(--line);margin:5px 4px}
.rename{width:100%;height:24px;border-radius:5px;padding:0 6px;background:var(--editor);border:1px solid var(--accent);font-size:13px;box-shadow:0 0 0 3px var(--accent-soft)}
.toast{position:fixed;bottom:18px;left:50%;transform:translate(-50%,8px);background:var(--ink);color:var(--bg);padding:8px 14px;border-radius:8px;font-size:13px;box-shadow:var(--shadow);opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;z-index:200}
.toast.show{opacity:1;transform:translate(-50%,0)}
.nav-item,.note-item,.icon-btn,.tb,.btn{-webkit-user-select:none;user-select:none}
.ed-body,.ed-title{-webkit-user-select:text;user-select:text}
</style>
</head>
<body>
<div id="app">
<aside class="sidebar" id="sidebar"><div class="sidebar-inner" id="sideInner"></div></aside>
<div class="handle handle-side" id="hSide" title="Drag to resize"></div>
<section class="listpane" id="listPane">
<div class="list-head">
<div class="list-top">
<button class="icon-btn" data-act="toggle-side" title="Toggle sidebar (Ctrl+\\)" id="sideToggle"></button>
<div class="list-title" id="listTitle">All Notes</div>
<button class="icon-btn" data-act="new-note" title="New note (Ctrl+N)" id="newNoteBtn"></button>
</div>
<div class="search">
<input id="searchInput" type="search" placeholder="Search notes…" autocomplete="off" spellcheck="false">
<button class="clear" id="searchClear" data-act="clear-search" title="Clear" hidden></button>
</div>
<div class="list-tools">
<span class="list-sub" id="listSub"></span>
<label class="sort"><select id="sortSel" title="Sort notes">
<option value="modified">Date modified</option>
<option value="created">Date created</option>
<option value="alpha">Alphabetical</option>
</select></label>
</div>
</div>
<div class="list-scroll" id="listScroll"></div>
</section>
<div class="handle" id="hList" title="Drag to resize"></div>
<main class="editorpane" id="editorPane">
<div class="ed-top">
<div class="crumbs" id="crumbs"></div>
<span class="save" id="saveState"><span class="dot"></span><span id="saveText">Saved</span></span>
<button class="icon-btn" data-act="pin" id="pinBtn" title="Pin / unpin"></button>
<button class="icon-btn" data-act="note-menu" id="moveBtn" title="Move, duplicate, more"></button>
<button class="icon-btn" data-act="dup" id="dupBtn" title="Duplicate"></button>
<button class="icon-btn" data-act="trash" id="trashBtn" title="Move to Trash (Del)"></button>
</div>
<div class="trash-banner" id="trashBanner" hidden>
<span>This note is in the Trash.</span>
<button class="btn" data-act="restore">Restore note</button>
</div>
<div class="toolbar" id="toolbar">
<button class="tb txt" data-act="bold" data-fmt="bold" title="Bold (Ctrl+B)">B</button>
<button class="tb txt i" data-act="italic" data-fmt="italic" title="Italic (Ctrl+I)">I</button>
<button class="tb txt u" data-act="underline" data-fmt="underline" title="Underline (Ctrl+U)">U</button>
<button class="tb txt s" data-act="strike" data-fmt="strike" title="Strikethrough (Ctrl+Shift+S)">S</button>
<button class="tb txt code" data-act="code" data-fmt="code" title="Inline code (Ctrl+E)"></></button>
<span class="tb-sep"></span>
<button class="tb txt" data-act="h1" data-fmt="h1" title="Heading 1">H1</button>
<button class="tb txt" data-act="h2" data-fmt="h2" title="Heading 2">H2</button>
<button class="tb txt" data-act="h3" data-fmt="h3" title="Heading 3">H3</button>
<span class="tb-sep"></span>
<button class="tb" data-act="ul" data-fmt="ul" title="Bulleted list"></button>
<button class="tb" data-act="ol" data-fmt="ol" title="Numbered list"></button>
<button class="tb" data-act="todo" data-fmt="todo" title="Checklist"></button>
<span class="tb-sep"></span>
<button class="tb" data-act="quote" data-fmt="quote" title="Blockquote"></button>
<button class="tb" data-act="hr" title="Horizontal rule"></button>
</div>
<div class="ed-scroll" id="edScroll">
<div class="ed-wrap" id="edWrap">
<input class="ed-title" id="edTitle" placeholder="Untitled" autocomplete="off" spellcheck="false">
<div class="ed-tags" id="edTags"></div>
<div class="ed-body-wrap">
<div class="ph" id="ph">Start writing, or type <b>#</b> for a heading, <b>-</b> for a list…</div>
<div class="ed-body" id="editor" contenteditable="true" spellcheck="true" aria-label="Note body"></div>
</div>
</div>
</div>
<div class="empty" id="empty">
<div class="big" id="emptyIcon"></div>
<p>Select or create a note</p>
<small>Your notes are saved automatically in this browser.</small>
<button class="btn" data-act="new-note" style="margin-top:6px">New note</button>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
/* =====================================================================
3. CONFIG — tunable constants
===================================================================== */
const CONFIG = {
STORAGE_KEY: 'notes-app.v1',
AUTOSAVE_MS: 400, // debounce for localStorage writes
HISTORY_IDLE_MS: 350, // typing bursts coalesce into one undo step
HISTORY_LIMIT: 200,
LIST_REFRESH_MS: 180, // debounce for sidebar/list re-render while typing
SNIPPET_LEN: 110,
SIDE_MIN: 180, SIDE_MAX: 360, SIDE_DEFAULT: 232,
LIST_MIN: 220, LIST_MAX: 480, LIST_DEFAULT: 300,
DEFAULT_NOTEBOOK_NAMES: ['Personal', 'Work', 'Ideas'],
};
/* ---------- Icons (inline SVG paths, stroke based) ---------- */
const ICON = {
chev:'<path d="M9 6l6 6-6 6"/>',
folder:'<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
note:'<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/>',
layers:'<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
trash:'<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
pin:'<path d="M12 17v5"/><path d="M8 3h8l-1 6 3 4H6l3-4z"/>',
tag:'<path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 12V3h9l8.6 8.6a2 2 0 0 1 0 2.8z"/><circle cx="7.5" cy="7.5" r="1.4"/>',
plus:'<path d="M12 5v14M5 12h14"/>',
search:'<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
sidebar:'<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M9 4v16"/>',
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
moon:'<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
more:'<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>',
x:'<path d="M18 6L6 18M6 6l12 12"/>',
dup:'<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
move:'<path d="M5 12h14M13 6l6 6-6 6"/>',
restore:'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
check:'<path d="M20 6L9 17l-5-5"/>',
edit:'<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
bullet:'<circle cx="4" cy="6" r="1.3"/><circle cx="4" cy="12" r="1.3"/><circle cx="4" cy="18" r="1.3"/><path d="M9 6h11M9 12h11M9 18h11"/>',
ordered:'<path d="M10 6h11M10 12h11M10 18h11"/><path d="M3.5 4.5L5 4v4M3 8h3M3.2 12.5a1.6 1.6 0 1 1 2.8 1L3 17.5h3"/>',
todoIco:'<rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M4.4 7.6l1.6 1.6 2.8-2.8"/><path d="M13 7.5h8M13 17.5h8"/><rect x="3" y="13.5" width="7" height="7" rx="1.5"/>',
quote:'<path d="M7 17h3l2-4V7H6v6h3zM14 17h3l2-4V7h-6v6h3z"/>',
hr:'<path d="M3 12h18"/>',
code:'<path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/>',
sort:'<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
back:'<path d="M15 18l-6-6 6-6"/>',
};
function ico(name, cls){ return `<svg class="ico${cls?' '+cls:''}" viewBox="0 0 24 24" aria-hidden="true">${ICON[name]||''}</svg>`; }
/* =====================================================================
4. UTILITIES
===================================================================== */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
function esc(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function escRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function uid(){ return 'n' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
function now(){ return Date.now(); }
function toast(msg){
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 1600);
}
function fmtDate(ts){
if (!ts) return '';
const d = new Date(ts), n = new Date(), diff = n - d;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return Math.floor(diff / 60000) + ' min ago';
const sameDay = d.toDateString() === n.toDateString();
if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const yest = new Date(n); yest.setDate(n.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return 'Yesterday';
if (diff < 7 * 86400000) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === n.getFullYear() ? undefined : 'numeric' });
}
/** Highlight every case-insensitive occurrence of q inside plain text; returns escaped HTML. */
function hl(text, q){
text = String(text);
if (!q) return esc(text);
const re = new RegExp(escRe(q), 'gi');
let out = '', last = 0, m;
while ((m = re.exec(text))) {
out += esc(text.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
last = m.index + m[0].length;
if (m[0].length === 0) break;
}
return out + esc(text.slice(last));
}
/* =====================================================================
5. SANITIZER + TEXT EXTRACTION
Only a small formatting subset survives. Everything else is unwrapped
(keeping its text) or dropped entirely (script/style/etc).
===================================================================== */
const ALLOWED = new Set(['P','DIV','BR','B','I','U','S','CODE','H1','H2','H3','UL','OL','LI','BLOCKQUOTE','HR']);
const INLINE = new Set(['B','I','U','S','CODE','BR']);
const DROP = new Set(['SCRIPT','STYLE','TEMPLATE','IFRAME','OBJECT','EMBED','NOSCRIPT','SVG','MATH','HEAD','TITLE','META','LINK','TEXTAREA','SELECT','BUTTON','INPUT','IMG','VIDEO','AUDIO','CANVAS','FORM']);
const RENAME_TAG = { STRONG:'B', EM:'I', STRIKE:'S', DEL:'S' };
const BLOCK_TAGS = new Set(['P','DIV','H1','H2','H3','LI','UL','OL','BLOCKQUOTE','HR']);
const BLOCK_SELECTOR = 'li,h1,h2,h3,p,div,blockquote';
function sanitizeHTML(html){
const tpl = document.createElement('template');
tpl.innerHTML = String(html || '');
cleanChildren(tpl.content);
wrapLooseInline(tpl.content);
const out = tpl.innerHTML.trim();
return out || '<div><br></div>';
}
function cleanChildren(parent){
for (const node of [...parent.childNodes]) {
if (node.nodeType === 3) continue;
if (node.nodeType !== 1) { node.remove(); continue; }
let tag = node.tagName;
if (DROP.has(tag)) { node.remove(); continue; }
if (RENAME_TAG[tag]) {
const n = document.createElement(RENAME_TAG[tag]);
n.append(...node.childNodes);
node.replaceWith(n); node = n; tag = n.tagName;
}
if (!ALLOWED.has(tag)) {
cleanChildren(node);
node.replaceWith(...node.childNodes);
continue;
}
const isTodo = node.classList && node.classList.contains('todo');
const done = node.getAttribute && node.getAttribute('data-done');
for (const a of [...node.attributes]) node.removeAttribute(a.name);
if ((tag === 'UL' || tag === 'OL') && isTodo) node.className = 'todo';
if (tag === 'LI' && done !== null) node.setAttribute('data-done', done === 'true' ? 'true' : 'false');
cleanChildren(node);
}
}
/** Wrap bare inline runs at the root in <div> so every line is a block. */
function wrapLooseInline(root){
let run = null;
for (const n of [...root.childNodes]) {
const isText = n.nodeType === 3;
if (isText && !n.textContent.trim() && !run) { n.remove(); continue; }
const inline = isText || (n.nodeType === 1 && INLINE.has(n.tagName));
if (inline) {
if (!run) { run = document.createElement('div'); n.before(run); }
run.appendChild(n);
} else run = null;
}
}
/** Pure text extraction: no tag names leak into the result. */
function htmlToText(html){
const tpl = document.createElement('template');
tpl.innerHTML = String(html || '');
let out = '';
const walk = (node) => {
for (const c of node.childNodes) {
if (c.nodeType === 3) { out += c.textContent; continue; }
if (c.nodeType !== 1) continue;
if (c.tagName === 'BR') { out += '\n'; continue; }
if (DROP.has(c.tagName)) continue;
const block = BLOCK_TAGS.has(c.tagName);
if (block) out += '\n';
walk(c);
if (block) out += '\n';
}
};
walk(tpl.content);
return out.replace(/[ \t\u00a0]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{2,}/g, '\n').trim();
}
/* =====================================================================
6. DATA MODEL — single source of truth (serializable)
db = { notebooks:[{id,name}], notes:[{id,notebookId,title,html,tags,
pinned,trashedAt,trashedNotebookName,createdAt,updatedAt}],
ui:{view,selectedNoteId,sort,sideCollapsed,sideW,listW,theme,expanded,query} }
===================================================================== */
let db = null;
function seedDB(){
const nbs = CONFIG.DEFAULT_NOTEBOOK_NAMES.map((name, i) => ({ id: 'nb' + (i + 1), name }));
const t = now();
const welcome = {
id: uid(), notebookId: 'nb1', title: 'Welcome to Notes',
html: '<h2>Welcome</h2>' +
'<div>Write freely. Try <b>bold</b>, <i>italic</i>, <u>underline</u>, <s>strike</s> or <code>inline code</code>.</div>' +
'<br><ul class="todo"><li data-done="true">Explore the toolbar</li><li data-done="false">Type <code>[] </code> at a line start for a checklist</li></ul>' +
'<blockquote><div>Markdown shortcuts: <code># </code> heading, <code>- </code> bullet, <code>> </code> quote.</div></blockquote>' +
'<div><br></div><div>Drag notes onto a notebook to move them. Deleted notes wait in the Trash.</div>',
tags: ['welcome'], pinned: true, trashedAt: null, createdAt: t - 5000, updatedAt: t - 5000,
};
const second = {
id: uid(), notebookId: 'nb2', title: 'Q3 planning', html: '<div>Kick-off agenda.</div><ul><li>Review roadmap</li><li>Hiring plan</li></ul>',
tags: ['planning'], pinned: false, trashedAt: null, createdAt: t - 90000, updatedAt: t - 90000,
};
return {
notebooks: nbs,
notes: [welcome, second],
ui: { view: { type: 'all' }, selectedNoteId: welcome.id, sort: 'modified', sideCollapsed: false,
sideW: CONFIG.SIDE_DEFAULT, listW: CONFIG.LIST_DEFAULT, theme: 'light', expanded: { nb1: true }, query: '' },
};
}
function loadDB(){
let data = null;
try { data = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY) || 'null'); } catch (e) { data = null; }
if (!data || !Array.isArray(data.notebooks) || !Array.isArray(data.notes)) return seedDB();
data.ui = Object.assign({ view: { type: 'all' }, selectedNoteId: null, sort: 'modified', sideCollapsed: false,
sideW: CONFIG.SIDE_DEFAULT, listW: CONFIG.LIST_DEFAULT, theme: 'light', expanded: {}, query: '' }, data.ui || {});
data.notes.forEach(n => {
n.html = sanitizeHTML(n.html);
n.tags = Array.isArray(n.tags) ? n.tags : [];
n.pinned = !!n.pinned;
n.trashedAt = n.trashedAt || null;
});
return data;
}
let saveTimer = null;
function persist(){
setSaveState('pending');
clearTimeout(saveTimer);
saveTimer = setTimeout(flushSave, CONFIG.AUTOSAVE_MS);
}
function flushSave(){
if (!saveTimer && !$('#saveState').classList.contains('pending')) return;
clearTimeout(saveTimer); saveTimer = null;
try { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(db)); setSaveState('saved'); }
catch (e) { setSaveState('error'); }
}
function setSaveState(s){
const el = $('#saveState');
el.classList.toggle('pending', s === 'pending');
$('#saveText').textContent = s === 'pending' ? 'Saving…' : s === 'error' ? 'Save failed' : 'Saved';
}
/* ----- model queries (pure) ----- */
const getNote = id => db.notes.find(n => n.id === id) || null;
const getNb = id => db.notebooks.find(n => n.id === id) || null;
const activeNotes = () => db.notes.filter(n => !n.trashedAt);
const trashedNotes = () => db.notes.filter(n => !!n.trashedAt);
function noteTitle(n){ return (n.title || '').trim() || htmlToText(n.html).split('\n')[0].slice(0, 80) || 'Untitled'; }
function noteBody(n){ return htmlToText(n.html); }
function noteSnippet(n, q){
const text = noteBody(n).replace(/\s+/g, ' ');
if (!q) return text.slice(0, CONFIG.SNIPPET_LEN);
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx < 0) return text.slice(0, CONFIG.SNIPPET_LEN);
const start = Math.max(0, idx - 36);
return (start > 0 ? '…' : '') + text.slice(start, start + CONFIG.SNIPPET_LEN);
}
function sortNotes(arr){
const key = db.ui.sort;
return arr.sort((a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1;
if (key === 'alpha') return noteTitle(a).localeCompare(noteTitle(b), undefined, { sensitivity: 'base' });
if (key === 'created') return b.createdAt - a.createdAt;
return b.updatedAt - a.updatedAt;
});
}
function allTags(){
const m = new Map();
activeNotes().forEach(n => n.tags.forEach(t => {
const k = t.toLowerCase(); const e = m.get(k) || { name: t, count: 0 }; e.count++; m.set(k, e);
}));
return [...m.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function matchesQuery(n, q){
if (!q) return true;
const ql = q.toLowerCase();
return noteTitle(n).toLowerCase().includes(ql) || noteBody(n).toLowerCase().includes(ql);
}
/** Notes visible in the middle pane for the current view + search. */
function visibleNotes(){
const { view, query } = db.ui;
const q = (query || '').trim();
let list;
if (q) list = activeNotes().filter(n => matchesQuery(n, q));
else if (view.type === 'trash') list = trashedNotes();
else if (view.type === 'nb') list = activeNotes().filter(n => n.notebookId === view.id);
else if (view.type === 'tag') list = activeNotes().filter(n => n.tags.some(t => t.toLowerCase() === view.tag.toLowerCase()));
else list = activeNotes();
return sortNotes([...list]);
}
/* =====================================================================
7. ICON-BUTTON STATIC LABELS (filled in at init)
===================================================================== */
function paintStaticIcons(){
$('#sideToggle').innerHTML = ico('sidebar');
$('#newNoteBtn').innerHTML = ico('plus');
$('#searchClear').innerHTML = ico('x');
$('#pinBtn').innerHTML = ico('pin');
$('#moveBtn').innerHTML = ico('more');
$('#dupBtn').innerHTML = ico('dup');
$('#trashBtn').innerHTML = ico('trash');
$('#emptyIcon').innerHTML = ico('note');
$('#toolbar [data-act="ul"]').innerHTML = ico('bullet');
$('#toolbar [data-act="ol"]').innerHTML = ico('ordered');
$('#toolbar [data-act="todo"]').innerHTML = ico('todoIco');
$('#toolbar [data-act="quote"]').innerHTML = ico('quote');
$('#toolbar [data-act="hr"]').innerHTML = ico('hr');
$('#toolbar [data-act="code"]').classList.add('code');
}
/* =====================================================================
8. SIDEBAR — notebooks, tags, trash (projection of db)
===================================================================== */
function isViewActive(type, id){
const v = db.ui.view;
if (db.ui.query) return false;
if (v.type !== type) return false;
if (type === 'nb') return v.id === id;
if (type === 'tag') return v.tag === id;
return true;
}
function navItem(type, id, iconHtml, label, count, extra = ''){
const active = isViewActive(type, id) ? ' active' : '';
const attrs = type === 'nb' ? `data-view="nb" data-id="${id}"` : type === 'tag' ? `data-view="tag" data-tag="${esc(id)}"` : `data-view="${type}"`;
return `<div class="nav-item${active}" ${attrs} ${extra}>${iconHtml}<span class="label">${label}</span>${count !== '' && count != null ? `<span class="count">${count}</span>` : ''}</div>`;
}
function renderSidebar(){
const expanded = db.ui.expanded;
const openNote = db.ui.selectedNoteId;
let h = `<div class="side-head"><div class="brand"><span class="logo">${ico('note')}</span>Notes</div>
<button class="icon-btn" data-act="toggle-side" title="Collapse sidebar">${ico('sidebar')}</button></div>
<div class="side-scroll">`;
h += navItem('all', null, ico('layers'), 'All Notes', activeNotes().length);
h += `<div class="section-head">Notebooks
<button class="icon-btn" style="width:22px;height:22px" data-act="new-nb" title="New notebook">${ico('plus')}</button></div>`;
for (const nb of db.notebooks) {
const notes = sortNotes(activeNotes().filter(n => n.notebookId === nb.id));
const open = !!expanded[nb.id];
h += `<div class="nb" data-nb="${nb.id}">
<div class="nav-item${isViewActive('nb', nb.id) ? ' active' : ''}" data-view="nb" data-id="${nb.id}" title="Double-click to rename">
<span class="chev${open ? ' open' : ''}" data-act="expand" data-id="${nb.id}">${ico('chev')}</span>
${ico('folder')}<span class="label" data-rename-nb="${nb.id}">${esc(nb.name)}</span>
<span class="count">${notes.length}</span>
<button class="icon-btn" style="width:22px;height:22px" data-act="nb-menu" data-id="${nb.id}" title="More">${ico('more')}</button>
</div>`;
if (open) {
h += `<div class="nb-notes">` + (notes.length
? notes.map(n => `<button class="nb-note${n.id === openNote ? ' active' : ''}" data-open-note="${n.id}">${esc(noteTitle(n))}</button>`).join('')
: `<div class="nb-note" style="cursor:default;opacity:.7">No notes yet</div>`) + `</div>`;
}
h += `</div>`;
}
const tags = allTags();
if (tags.length) {
h += `<div class="section-head">Tags</div>`;
for (const t of tags) {
h += `<div class="nav-item${isViewActive('tag', t.name) ? ' active' : ''}" data-view="tag" data-tag="${esc(t.name)}">
<span class="tag-hash">#</span><span class="label">${esc(t.name)}</span><span class="count">${t.count}</span></div>`;
}
}
h += `<div class="section-head" style="padding-top:14px"></div>`;
h += navItem('trash', null, ico('trash'), 'Trash', trashedNotes().length || '');
h += `</div>
<div class="side-foot"><small>Saved locally</small>
<button class="icon-btn" data-act="theme" title="Toggle dark theme">${ico(db.ui.theme === 'dark' ? 'sun' : 'moon')}</button></div>`;
$('#sideInner').innerHTML = h;
renderSidebarWidth();
}
/* =====================================================================
9. NOTE LIST — middle pane
===================================================================== */
function viewTitle(){
const q = db.ui.query.trim();
if (q) return 'Search';
const v = db.ui.view;
if (v.type === 'nb') return getNb(v.id)?.name || 'Notebook';
if (v.type === 'tag') return '#' + v.tag;
if (v.type === 'trash') return 'Trash';
return 'All Notes';
}
function noteItemHTML(n, q, opts){
const sel = n.id === db.ui.selectedNoteId ? ' sel' : '';
const tsLabel = db.ui.view.type === 'trash' && !q ? fmtDate(n.trashedAt)
: db.ui.sort === 'created' ? fmtDate(n.createdAt) : fmtDate(n.updatedAt);
const nb = getNb(n.notebookId);
const showNb = !opts.inNb && nb;
return `<div class="note-item${sel}" draggable="${n.trashedAt ? 'false' : 'true'}" data-note="${n.id}">
<div class="ni-title">${hl(noteTitle(n), q)}</div>
<div class="ni-snip">${hl(noteSnippet(n, q), q) || ' '}</div>
<div class="ni-meta"><span>${tsLabel}</span>${showNb ? `<span>·</span><span class="nbn">${esc(nb.name)}</span>` : ''}
${n.tags.slice(0, 3).map(t => `<span class="mini-tag">#${esc(t)}</span>`).join('')}</div>
${n.pinned && !n.trashedAt ? `<span class="ni-pin">${ico('pin')}</span>` : ''}
${n.trashedAt ? '' : `<button class="ni-more" data-act="list-menu" data-note="${n.id}" title="More">${ico('more')}</button>`}
</div>`;
}
function renderList(){
const q = db.ui.query.trim();
const notes = visibleNotes();
$('#listTitle').textContent = viewTitle();
$('#listSub').textContent = notes.length + (notes.length === 1 ? ' note' : ' notes');
$('#searchClear').hidden = !db.ui.query;
$('#sortSel').value = db.ui.sort;
$('#sortSel').disabled = db.ui.view.type === 'trash' && !q;
const scroll = $('#listScroll');
if (!notes.length) {
scroll.innerHTML = `<div class="list-empty">${ico(q ? 'search' : 'note')}
${q ? `No notes match “${esc(q)}”` : db.ui.view.type === 'trash' ? 'Trash is empty' : 'No notes here yet'}</div>`;
return;
}
const opts = { inNb: db.ui.view.type === 'nb' && !q };
const pinned = notes.filter(n => n.pinned && !n.trashedAt);
const rest = notes.filter(n => !(n.pinned && !n.trashedAt));
let h = '';
if (pinned.length && rest.length && !q) {
h += `<div class="section-label-in">Pinned</div>` + pinned.map(n => noteItemHTML(n, q, opts)).join('');
h += `<div class="section-label-in">Notes</div>` + rest.map(n => noteItemHTML(n, q, opts)).join('');
} else {
h = notes.map(n => noteItemHTML(n, q, opts)).join('');
}
scroll.innerHTML = h;
}
/* =====================================================================
10. EDITOR PANE — projection of the selected note
===================================================================== */
const editorEl = () => $('#editor');
function currentNote(){ return getNote(db.ui.selectedNoteId); }
function renderEditor(){
const n = currentNote();
const pane = $('#editorPane');
pane.classList.toggle('no-note', !n);
$('#empty').classList.toggle('show', !n);
if (!n) { $('#crumbs').textContent = ''; return; }
const trashed = !!n.trashedAt;
const ed = editorEl();
const title = $('#edTitle');
// Only rebuild the DOM when the note actually changed (keeps caret stable).
let switched = false;
if (ed.dataset.noteId !== n.id || ed.dataset.trashed !== String(trashed)) {
switched = true;
ed.dataset.noteId = n.id;
ed.dataset.trashed = String(trashed);
ed.innerHTML = n.html;
title.value = n.title || '';
H.reset();
}
title.readOnly = trashed;
title.placeholder = 'Untitled';
ed.contentEditable = trashed ? 'false' : 'true';
$('#edWrap').classList.remove('anim'); void $('#edWrap').offsetWidth; $('#edWrap').classList.add('anim');
$('#trashBanner').hidden = !trashed;
$$('#toolbar .tb').forEach(b => b.disabled = trashed);
$('#dupBtn').disabled = trashed;
$('#pinBtn').disabled = trashed;
$('#pinBtn').classList.toggle('on', n.pinned && !trashed);
$('#pinBtn').innerHTML = ico('pin');
$('#trashBtn').innerHTML = ico('trash');
$('#trashBtn').title = trashed ? 'Delete permanently' : 'Move to Trash (Del)';
$('#trashBtn').dataset.act = trashed ? 'purge' : 'trash';
$('#moveBtn').disabled = trashed;
const nb = getNb(n.notebookId);
$('#crumbs').innerHTML = trashed ? 'Trash' : `${esc(nb ? nb.name : 'Notebook')} / <b>${esc(noteTitle(n))}</b>`;
if (switched) renderTags();
updatePlaceholder();
updateToolbarState();
}
function renderTags(){
const n = currentNote();
const box = $('#edTags');
if (!n) { box.innerHTML = ''; return; }
const trashed = !!n.trashedAt;
box.innerHTML = n.tags.map(t => `<span class="tag-chip" data-tag="${esc(t)}" title="Filter by #${esc(t)}">#${esc(t)}
${trashed ? '' : `<span class="x" data-act="rm-tag" data-tag="${esc(t)}" title="Remove tag">${ico('x')}</span>`}</span>`).join('')
+ (trashed ? '' : `<input class="tag-add" id="tagAdd" placeholder="+ Add tag" maxlength="32" spellcheck="false">`);
}
function updatePlaceholder(){
const ed = editorEl();
const empty = !ed.textContent.trim() && !ed.querySelector('hr,ul,ol,blockquote,h1,h2,h3');
$('#ph').classList.toggle('show', !!empty && ed.contentEditable === 'true');
}
/* =====================================================================
11. NOTE & NOTEBOOK ACTIONS — all mutations go through db, then render
===================================================================== */
function renderAll(){
renderSidebar(); renderList(); renderEditor(); syncSearchBox();
}
let refreshTimer = null;
function scheduleRefresh(){
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
renderSidebar(); renderList();
const n = currentNote();
if (n) $('#crumbs').innerHTML = `${esc(getNb(n.notebookId)?.name || 'Notebook')} / <b>${esc(noteTitle(n))}</b>`;
}, CONFIG.LIST_REFRESH_MS);
}
function ensureSelectionVisible(){
const list = visibleNotes();
if (!list.find(n => n.id === db.ui.selectedNoteId)) db.ui.selectedNoteId = list.length ? list[0].id : null;
}
function selectNote(id){
db.ui.selectedNoteId = id;
renderAll(); persist();
if (id) requestAnimationFrame(() => $('.note-item.sel')?.scrollIntoView({ block: 'nearest' }));
}
function setView(view){
db.ui.view = view; db.ui.query = '';
ensureSelectionVisible();
renderAll(); persist();
}
function syncSearchBox(){
const si = $('#searchInput');
if (si.value !== db.ui.query) si.value = db.ui.query;
}
function firstNotebookId(){
if (!db.notebooks.length) db.notebooks.push({ id: 'nb' + uid(), name: 'Notes' });
return db.notebooks[0].id;
}
function createNote(){
const nbId = db.ui.view.type === 'nb' ? db.ui.view.id : firstNotebookId();
const t = now();
const note = { id: uid(), notebookId: nbId, title: '', html: '<div><br></div>', tags: [], pinned: false,
trashedAt: null, createdAt: t, updatedAt: t };
db.notes.unshift(note);
if (db.ui.view.type === 'trash' || db.ui.view.type === 'tag') db.ui.view = { type: 'all' };
db.ui.query = '';
db.ui.selectedNoteId = note.id;
db.ui.expanded[nbId] = true;
renderAll(); persist();
$('#edTitle').focus();
}
function trashNote(id){
const n = getNote(id); if (!n) return;
n.trashedAt = now();
if (db.ui.selectedNoteId === id) db.ui.selectedNoteId = null;
ensureSelectionVisible();
renderAll(); persist();
toast('Moved to Trash');
}
function restoreNote(id){
const n = getNote(id); if (!n) return;
let nb = getNb(n.notebookId);
if (!nb) {
nb = { id: n.notebookId || 'nb' + uid(), name: n.trashedNotebookName || 'Restored' };
n.notebookId = nb.id;
db.notebooks.push(nb);
}
n.trashedAt = null; n.trashedNotebookName = undefined;
db.ui.view = { type: 'nb', id: nb.id }; db.ui.query = ''; db.ui.expanded[nb.id] = true;
db.ui.selectedNoteId = n.id;
renderAll(); persist();
toast('Restored to ' + nb.name);
}
function purgeNote(id){
db.notes = db.notes.filter(n => n.id !== id);
if (db.ui.selectedNoteId === id) db.ui.selectedNoteId = null;
ensureSelectionVisible();
renderAll(); persist(); toast('Deleted permanently');
}
function togglePin(id){
const n = getNote(id); if (!n || n.trashedAt) return;
n.pinned = !n.pinned; n.updatedAt = n.updatedAt; // pin changes do not bump modified date
renderAll(); persist(); toast(n.pinned ? 'Pinned' : 'Unpinned');
}
function duplicateNote(id){
const n = getNote(id); if (!n) return;
const t = now();
const copy = Object.assign({}, n, { id: uid(), title: noteTitle(n) + ' copy', pinned: false, tags: [...n.tags], createdAt: t, updatedAt: t });
const idx = db.notes.indexOf(n);
db.notes.splice(idx, 0, copy);
db.ui.selectedNoteId = copy.id;
renderAll(); persist(); toast('Duplicated');
}
function moveNote(id, nbId){
const n = getNote(id); const nb = getNb(nbId); if (!n || !nb || n.notebookId === nbId) return;
n.notebookId = nbId; n.updatedAt = now();
renderAll(); persist(); toast('Moved to ' + nb.name);
}
function addTag(id, raw){
const n = getNote(id); if (!n) return;
const name = raw.replace(/^#+/, '').trim().slice(0, 32);
if (!name) return;
if (!n.tags.some(t => t.toLowerCase() === name.toLowerCase())) n.tags.push(name);
n.updatedAt = now();
renderAll(); persist();
}
function removeTag(id, tag){
const n = getNote(id); if (!n) return;
n.tags = n.tags.filter(t => t !== tag);
n.updatedAt = now();
renderAll(); persist();
}
function createNotebook(){
const nb = { id: 'nb' + uid(), name: 'Untitled notebook' };
db.notebooks.push(nb);
db.ui.expanded[nb.id] = true;
db.ui.view = { type: 'nb', id: nb.id }; db.ui.query = ''; db.ui.selectedNoteId = null;
renderAll(); persist();
startRenameNotebook(nb.id);
}
function deleteNotebook(id){
const nb = getNb(id); if (!nb) return;
db.notes.forEach(n => { if (n.notebookId === id && !n.trashedAt) { n.trashedAt = now(); n.trashedNotebookName = nb.name; } });
db.notebooks = db.notebooks.filter(x => x.id !== id);
if (db.ui.view.type === 'nb' && db.ui.view.id === id) db.ui.view = { type: 'all' };
ensureSelectionVisible();
renderAll(); persist(); toast('Notebook deleted — notes moved to Trash');
}
function startRenameNotebook(id){
const nb = getNb(id); if (!nb) return;
const span = $(`[data-rename-nb="${id}"]`); if (!span) return;
const input = document.createElement('input');
input.className = 'rename'; input.value = nb.name; input.maxLength = 48;
span.replaceWith(input);
input.focus(); input.select();
let done = false;
const finish = (commit) => {
if (done) return; done = true;
if (commit) { const v = input.value.trim(); if (v) nb.name = v; }
renderAll(); persist();
};
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
e.stopPropagation();
});
input.addEventListener('click', e => e.stopPropagation());
input.addEventListener('blur', () => finish(true));
}
/* ----- title / body sync handlers ----- */
function onTitleInput(){
const n = currentNote(); if (!n || n.trashedAt) return;
n.title = $('#edTitle').value; n.updatedAt = now();
persist(); scheduleRefresh();
}
/* =====================================================================
12. CONTEXT MENUS
===================================================================== */
let menuEl = null;
function closeMenu(){ if (menuEl) { menuEl.remove(); menuEl = null; } }
/** items: {label, icon, fn, danger, check} | {sep:true} | {head:'text'} */
function openMenu(x, y, items){
closeMenu();
const m = document.createElement('div');
m.className = 'menu'; m.setAttribute('role', 'menu');
m.innerHTML = items.map((it, i) => {
if (it.sep) return '<div class="menu-sep"></div>';
if (it.head) return `<div class="menu-head">${esc(it.head)}</div>`;
return `<button class="menu-item${it.danger ? ' danger' : ''}" data-i="${i}" role="menuitem">${it.icon ? ico(it.icon) : ''}<span>${esc(it.label)}</span>${it.check ? ico('check', 'ck') : ''}</button>`;
}).join('');
document.body.appendChild(m);
const r = m.getBoundingClientRect();
m.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
m.style.top = Math.min(y, innerHeight - r.height - 8) + 'px';
m.addEventListener('click', e => {
const b = e.target.closest('.menu-item'); if (!b) return;
const it = items[+b.dataset.i];
closeMenu();
it.fn && it.fn();
});
menuEl = m;
}
function noteMenu(id, x, y){
const n = getNote(id); if (!n) return;
const items = [
{ head: 'Move to notebook' },
...db.notebooks.map(nb => ({ label: nb.name, icon: 'folder', check: nb.id === n.notebookId, fn: () => moveNote(id, nb.id) })),
{ sep: true },
{ label: n.pinned ? 'Unpin' : 'Pin to top', icon: 'pin', fn: () => togglePin(id) },
{ label: 'Duplicate', icon: 'dup', fn: () => duplicateNote(id) },
{ sep: true },
{ label: 'Move to Trash', icon: 'trash', danger: true, fn: () => trashNote(id) },
];
openMenu(x, y, items);
}
function notebookMenu(id, x, y){
openMenu(x, y, [
{ label: 'Rename', icon: 'edit', fn: () => startRenameNotebook(id) },
{ label: 'New note here', icon: 'plus', fn: () => { db.ui.view = { type: 'nb', id }; db.ui.query = ''; createNote(); } },
{ sep: true },
{ label: 'Delete notebook', icon: 'trash', danger: true, fn: () => deleteNotebook(id) },
]);
}
/* =====================================================================
13. EDITOR — commands, markdown shortcuts, lists, paste, history
===================================================================== */
const ed = () => $('#editor');
function isEditorFocus(){ const a = document.activeElement; return a === ed() || (ed().contains(document.getSelection().anchorNode)); }
function sel(){ return window.getSelection(); }
function curRange(){
const s = sel();
if (!s.rangeCount) return null;
const r = s.getRangeAt(0);
return ed().contains(r.startContainer) ? r : null;
}
function isEmptyLine(el){
return !el.textContent.replace(/\u200b/g, '').trim() && !el.querySelector('img,hr');
}
function ensureBr(el){
if (isEmptyLine(el) && !el.querySelector('br')) el.appendChild(document.createElement('br'));
return el;
}
function placeCaret(el, atEnd = true){
const r = document.createRange();
if (atEnd) { r.selectNodeContents(el); r.collapse(false); }
else { r.setStart(el, 0); r.collapse(true); }
const s = sel(); s.removeAllRanges(); s.addRange(r);
}
/** Find the editable "line": a top-level block, or an LI, or a line inside a blockquote. */
function currentLine(){
const r = curRange(); if (!r) return null;
let el = r.startContainer.nodeType === 1 ? r.startContainer : r.startContainer.parentNode;
if (el === ed()) {
// caret directly on root: wrap loose content into a line
const d = document.createElement('div');
if (!ed().children.length) { ed().appendChild(d); ensureBr(d); placeCaret(d); return d; }
const child = ed().childNodes[Math.min(r.startOffset, ed().childNodes.length - 1)];
const line = child.nodeType === 1 && /^(DIV|P|H[1-3]|UL|OL|BLOCKQUOTE|HR)$/.test(child.tagName) ? child : null;
if (line && line.tagName !== 'UL' && line.tagName !== 'OL' && line.tagName !== 'BLOCKQUOTE') return line;
return null;
}
const li = el.closest ? el.closest('li') : null;
if (li && ed().contains(li)) return li;
let blk = el;
while (blk.parentNode && blk.parentNode !== ed() && blk.parentNode.tagName !== 'BLOCKQUOTE') blk = blk.parentNode;
if (blk.tagName === 'BLOCKQUOTE') {
// caret sits directly in blockquote: make the inline run a div
const d = document.createElement('div');
while (blk.firstChild && !(blk.firstChild.nodeType === 1 && blk.firstChild.tagName === 'DIV')) d.appendChild(blk.firstChild);
if (d.childNodes.length) blk.insertBefore(d, blk.firstChild);
return d.childNodes.length ? d : blk.firstElementChild;
}
if (blk.parentNode === ed() || (blk.parentNode && blk.parentNode.tagName === 'BLOCKQUOTE')) return blk;
return null;
}
function lineKind(line){
if (line.tagName === 'LI') {
const list = line.parentNode;
return list.classList.contains('todo') ? 'todo' : list.tagName === 'OL' ? 'ol' : 'ul';
}
if (/^H[1-3]$/.test(line.tagName)) return line.tagName.toLowerCase();
if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE') return 'quote';
return 'p';
}
/** Split a container around `child`, replacing the child slot with `repl`. */
function splitAround(container, child, repl){
const before = container.cloneNode(false), after = container.cloneNode(false);
let side = before;
for (const c of [...container.childNodes]) {
if (c === child) { side = after; continue; }
side.appendChild(c);
}
const out = [];
if (before.childNodes.length) out.push(before);
out.push(repl);
if (after.childNodes.length) out.push(after);
container.replaceWith(...out);
}
/** Take a line out of any list / blockquote, returning a plain top-level block. */
function liftLine(line){
if (line.tagName === 'LI') {
const list = line.parentNode;
const d = document.createElement('div');
d.append(...line.childNodes);
splitAround(list, line, d);
return d;
}
if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE') {
const bq = line.parentNode;
splitAround(bq, line, line);
return line;
}
return line;
}
function retag(el, tag){
const n = document.createElement(tag);
n.append(...el.childNodes);
el.replaceWith(n);
return n;
}
/** Apply a block type to the caret line. force=true skips toggle-off. */
function setBlock(type, force = false){
const line = currentLine(); if (!line) return;
const cur = lineKind(line);
if (!force && cur === type) type = 'p';
const d = liftLine(line);
let out = d;
if (type === 'p') {
out = d.tagName === 'DIV' ? d : retag(d, 'DIV');
} else if (type === 'h1' || type === 'h2' || type === 'h3') {
out = retag(d, type.toUpperCase());
} else if (type === 'quote') {
const d2 = d.tagName === 'DIV' ? d : retag(d, 'DIV');
const prev = d2.previousElementSibling;
if (prev && prev.tagName === 'BLOCKQUOTE') { prev.appendChild(d2); out = d2; }
else { const bq = document.createElement('blockquote'); d2.replaceWith(bq); bq.appendChild(d2); out = d2; }
} else if (type === 'ul' || type === 'ol' || type === 'todo') {
const listTag = type === 'ol' ? 'OL' : 'UL';
const li = document.createElement('li');
li.append(...(d.tagName === 'DIV' ? d.childNodes : [...d.childNodes]));
if (type === 'todo') li.setAttribute('data-done', 'false');
const isMatch = (l) => l && l.tagName === listTag && (type === 'todo') === l.classList.contains('todo');
const prev = d.previousElementSibling;
let list;
if (isMatch(prev)) { prev.appendChild(li); list = prev; }
else {
list = document.createElement(listTag);
if (type === 'todo') list.className = 'todo';
list.appendChild(li);
d.replaceWith(list);
}
if (d.parentNode) d.remove();
const next = list.nextElementSibling;
if (isMatch(next)) { while (next.firstChild) list.appendChild(next.firstChild); next.remove(); }
out = li;
}
ensureBr(out);
placeCaret(out, true);
if (out.tagName === 'LI' || out.tagName === 'DIV' || /^H/.test(out.tagName)) { /* ok */ }
return out;
}
function insertHR(){
const line = currentLine(); if (!line) return;
const d = liftLine(line);
const hr = document.createElement('hr');
const after = document.createElement('div'); after.appendChild(document.createElement('br'));
d.after(hr); hr.after(after);
if (isEmptyLine(d)) d.remove();
placeCaret(after, false);
}
function toggleInlineCode(){
const r = curRange(); if (!r) return;
const ancestor = (r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentNode).closest('code');
if (ancestor && ed().contains(ancestor)) {
const frag = document.createDocumentFragment();
frag.append(...ancestor.childNodes);
ancestor.replaceWith(frag);
return;
}
if (r.collapsed) return;
const code = document.createElement('code');
code.appendChild(r.extractContents());
r.insertNode(code);
const nr = document.createRange(); nr.selectNodeContents(code);
const s = sel(); s.removeAllRanges(); s.addRange(nr);
}
function inlineFormatActive(name){
const r = curRange(); if (!r) return false;
const node = r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentNode;
if (name === 'code') return !!(node && node.closest && node.closest('code'));
if (name === 'bold') return !!(node && node.closest && node.closest('b,strong'));
if (name === 'italic') return !!(node && node.closest && node.closest('i,em'));
if (name === 'underline') return !!(node && node.closest && node.closest('u'));
if (name === 'strike') return !!(node && node.closest && node.closest('s,strike'));
return false;
}
function execFmt(cmd){
H.before(true);
if (cmd === 'bold') document.execCommand('bold');
else if (cmd === 'italic') document.execCommand('italic');
else if (cmd === 'underline') document.execCommand('underline');
else if (cmd === 'strike') document.execCommand('strikeThrough');
H.after(); onEditorChanged(); updateToolbarState();
}
/** Command dispatcher for toolbar + shortcuts. */
function runCommand(act){
const n = currentNote(); if (!n || n.trashedAt) return;
ed().focus();
const blockOps = { h1: 'h1', h2: 'h2', h3: 'h3', ul: 'ul', ol: 'ol', todo: 'todo', quote: 'quote' };
if (['bold', 'italic', 'underline', 'strike'].includes(act)) return execFmt(act);
H.before(true);
if (act === 'code') toggleInlineCode();
else if (blockOps[act]) setBlock(blockOps[act]);
else if (act === 'hr') insertHR();
H.after(); onEditorChanged(); updateToolbarState();
}
/* ----- Markdown-style input shortcuts ----- */
const MD = { '#': 'h1', '##': 'h2', '###': 'h3', '-': 'ul', '*': 'ul', '1.': 'ol', '[]': 'todo', '[ ]': 'todo', '>': 'quote' };
function tryMarkdown(e){
const r = curRange(); if (!r || !r.collapsed) return false;
const line = currentLine(); if (!line) return false;
if (line.parentNode !== ed() || line.tagName === 'LI') return false; // only at top-level line start
if (!/^(DIV|P)$/.test(line.tagName)) return false;
const pre = document.createRange();
pre.setStart(line, 0);
pre.setEnd(r.startContainer, r.startOffset);
const token = pre.toString().replace(/\u00a0/g, ' ');
const type = MD[token];
if (!type) return false;
e.preventDefault();
H.before(true);
pre.deleteContents();
const s = sel(); s.removeAllRanges(); s.addRange(pre);
setBlock(type, true);
H.after(); onEditorChanged(); updateToolbarState();
return true;
}
/* ----- Enter key: list continuation / exit ----- */
function handleEnter(e){
if (e.shiftKey) return false;
const line = currentLine(); if (!line) return false;
if (line.tagName === 'LI') {
e.preventDefault();
H.before(true);
if (isEmptyLine(line)) {
const d = liftLine(line);
ensureBr(d); placeCaret(d, true);
} else {
const r = curRange();
const tail = document.createRange();
tail.setStart(r.startContainer, r.startOffset);
tail.setEnd(line, line.childNodes.length);
const frag = tail.extractContents();
const next = document.createElement('li');
next.appendChild(frag);
if (line.parentNode.classList.contains('todo')) next.setAttribute('data-done', 'false');
ensureBr(next);
line.after(next);
placeCaret(next, false);
}
H.after(); onEditorChanged(); updateToolbarState();
return true;
}
if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE' && isEmptyLine(line)) {
e.preventDefault();
H.before(true);
const d = liftLine(line); ensureBr(d); placeCaret(d, true);
H.after(); onEditorChanged(); updateToolbarState();
return true;
}
return false;
}
/* ----- Todo checkbox click ----- */
function onEditorClick(e){
const li = e.target.closest && e.target.closest('ul.todo > li');
if (!li) return;
const rect = li.getBoundingClientRect();
if (e.clientX - rect.left > 30) return;
H.before(true);
li.setAttribute('data-done', li.getAttribute('data-done') === 'true' ? 'false' : 'true');
H.after(); onEditorChanged();
}
/* ----- Paste: sanitize to supported subset ----- */
function onPaste(e){
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
H.before(true);
if (html && html.trim()) {
const clean = sanitizeHTML(html).replace(/^<div><br><\/div>$/, '');
if (clean) document.execCommand('insertHTML', false, clean);
} else if (text) {
if (text.indexOf('\n') < 0) document.execCommand('insertText', false, text);
else {
const lines = text.replace(/\r/g, '').split('\n');
document.execCommand('insertHTML', false, lines.map(l => `<div>${esc(l) || '<br>'}</div>`).join(''));
}
}
H.after(); onEditorChanged(); updateToolbarState();
}
/* ----- Toolbar state reflects caret formatting ----- */
function updateToolbarState(){
const n = currentNote();
const tb = $$('#toolbar .tb');
const off = !n || n.trashedAt || !isEditorFocus() && !curRange();
if (off) { tb.forEach(b => b.classList.remove('on')); return; }
const line = currentLine();
const kind = line ? lineKind(line) : 'p';
const map = {
bold: inlineFormatActive('bold'), italic: inlineFormatActive('italic'), underline: inlineFormatActive('underline'),
strike: inlineFormatActive('strike'), code: inlineFormatActive('code'),
h1: kind === 'h1', h2: kind === 'h2', h3: kind === 'h3', ul: kind === 'ul', ol: kind === 'ol',
todo: kind === 'todo', quote: kind === 'quote',
};
tb.forEach(b => b.classList.toggle('on', !!map[b.dataset.fmt]));
}
/* =====================================================================
14. UNDO / REDO — snapshot history (HTML + caret path)
Typing bursts coalesce; every structural action is its own step.
===================================================================== */
function caretPath(){
const r = curRange(); if (!r) return null;
const path = []; let node = r.startContainer;
while (node !== ed()) {
const p = node.parentNode; if (!p) return null;
path.unshift([...p.childNodes].indexOf(node));
node = p;
}
return { p: path, o: r.startOffset };
}
function restoreCaret(c){
const root = ed();
let node = root;
if (c) {
for (const i of c.p) { if (!node.childNodes[i]) { node = null; break; } node = node.childNodes[i]; }
} else node = null;
const r = document.createRange();
if (node) {
const max = node.nodeType === 3 ? node.length : node.childNodes.length;
r.setStart(node, Math.min(c.o, max)); r.collapse(true);
} else { r.selectNodeContents(root); r.collapse(false); }
const s = sel(); s.removeAllRanges(); s.addRange(r);
}
function snap(){ return { html: ed().innerHTML, c: caretPath() }; }
const H = {
undo: [], redo: [], cur: null, t: 0,
reset(){ this.undo = []; this.redo = []; this.cur = snap(); this.t = 0; },
/** Call before a mutation. force=false coalesces rapid typing. */
before(force){
if (!this.cur) this.cur = snap();
const idle = Date.now() - this.t > CONFIG.HISTORY_IDLE_MS;
if (force || idle) {
const top = this.undo[this.undo.length - 1];
if (!top || top.html !== this.cur.html) {
this.undo.push(this.cur);
if (this.undo.length > CONFIG.HISTORY_LIMIT) this.undo.shift();
}
}
},
/** Call after a mutation. */
after(){
const s = snap();
if (this.cur && s.html !== this.cur.html) this.redo = [];
this.cur = s; this.t = Date.now();
},
apply(s){
ed().innerHTML = s.html;
restoreCaret(s.c);
onEditorChanged(); updateToolbarState();
},
undoStep(){
if (!this.undo.length) return toast('Nothing to undo');
this.redo.push(this.cur || snap());
const prev = this.undo.pop();
this.cur = prev; this.t = 0;
this.apply(prev);
},
redoStep(){
if (!this.redo.length) return toast('Nothing to redo');
this.undo.push(this.cur || snap());
const next = this.redo.pop();
this.cur = next; this.t = 0;
this.apply(next);
},
};
/* ----- sync DOM → model (the only write path from editor) ----- */
function onEditorChanged(){
const n = currentNote();
if (!n || n.trashedAt) return;
n.html = sanitizeHTML(ed().innerHTML);
n.updatedAt = now();
updatePlaceholder();
persist(); scheduleRefresh();
}
function onEditorInput(){ onEditorChanged(); }
/* ----- editor keyboard ----- */
function onEditorKeydown(e){
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod && !e.altKey) {
if (k === 'z' && !e.shiftKey) { e.preventDefault(); H.undoStep(); return; }
if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); H.redoStep(); return; }
if (k === 'b') { e.preventDefault(); runCommand('bold'); return; }
if (k === 'i') { e.preventDefault(); runCommand('italic'); return; }
if (k === 'u') { e.preventDefault(); runCommand('underline'); return; }
if (k === 'e') { e.preventDefault(); runCommand('code'); return; }
if (e.shiftKey && k === 's') { e.preventDefault(); runCommand('strike'); return; }
if (e.shiftKey && k === '8') { e.preventDefault(); runCommand('ul'); return; }
if (e.shiftKey && k === '7') { e.preventDefault(); runCommand('ol'); return; }
if (e.shiftKey && k === '9') { e.preventDefault(); runCommand('todo'); return; }
return;
}
if (e.key === 'Enter') { if (handleEnter(e)) return; }
if (e.key === ' ' && !e.isComposing) { if (tryMarkdown(e)) return; }
if (e.key === 'Tab' && !e.shiftKey) {
const line = currentLine();
if (line && line.tagName === 'LI') {
e.preventDefault();
const prev = line.previousElementSibling;
if (prev && prev.tagName === 'LI') {
H.before(true);
let sub = prev.querySelector(':scope > ul, :scope > ol');
if (!sub) { sub = document.createElement(line.parentNode.tagName); sub.className = line.parentNode.className; prev.appendChild(sub); }
sub.appendChild(line);
H.after(); onEditorChanged();
}
}
}
}
/* =====================================================================
15. SIDEBAR + LAYOUT — collapse, resize, theme
===================================================================== */
function renderSidebarWidth(){
const app = $('#app');
app.style.setProperty('--w-side', db.ui.sideW + 'px');
app.style.setProperty('--w-list', db.ui.listW + 'px');
app.classList.toggle('side-collapsed', !!db.ui.sideCollapsed);
}
function applyTheme(){
document.documentElement.dataset.theme = db.ui.theme === 'dark' ? 'dark' : 'light';
}
function toggleSidebar(){
db.ui.sideCollapsed = !db.ui.sideCollapsed;
renderSidebarWidth(); persist();
}
function toggleTheme(){
db.ui.theme = db.ui.theme === 'dark' ? 'light' : 'dark';
applyTheme(); renderSidebar(); persist();
}
function setupResizers(){
const bind = (handle, key, min, max, sign) => {
handle.addEventListener('pointerdown', e => {
if (key === 'sideW' && db.ui.sideCollapsed) return;
e.preventDefault();
handle.setPointerCapture(e.pointerId);
handle.classList.add('drag');
const startX = e.clientX, startW = db.ui[key];
const move = ev => {
const w = Math.max(min, Math.min(max, startW + sign * (ev.clientX - startX)));
db.ui[key] = Math.round(w);
renderSidebarWidth();
};
const up = () => {
handle.classList.remove('drag');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
persist();
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
};
bind($('#hSide'), 'sideW', CONFIG.SIDE_MIN, CONFIG.SIDE_MAX, 1);
bind($('#hList'), 'listW', CONFIG.LIST_MIN, CONFIG.LIST_MAX, 1);
}
/* =====================================================================
16. EVENT WIRING — delegation for all interactive regions
===================================================================== */
function onSidebarClick(e){
const act = e.target.closest('[data-act]');
if (act) {
const a = act.dataset.act;
if (a === 'toggle-side') return toggleSidebar();
if (a === 'theme') return toggleTheme();
if (a === 'new-nb') return createNotebook();
if (a === 'expand') {
e.stopPropagation();
db.ui.expanded[act.dataset.id] = !db.ui.expanded[act.dataset.id];
renderSidebar(); persist(); return;
}
if (a === 'nb-menu') {
e.stopPropagation();
const r = act.getBoundingClientRect();
return notebookMenu(act.dataset.id, r.right - 150, r.bottom + 4);
}
}
const note = e.target.closest('[data-open-note]');
if (note) {
const n = getNote(note.dataset.openNote);
if (n) {
db.ui.view = { type: 'nb', id: n.notebookId }; db.ui.query = ''; db.ui.expanded[n.notebookId] = true;
db.ui.selectedNoteId = n.id;
renderAll(); persist();
}
return;
}
const nav = e.target.closest('[data-view]');
if (nav) {
const v = nav.dataset.view;
if (v === 'all') setView({ type: 'all' });
else if (v === 'trash') setView({ type: 'trash' });
else if (v === 'nb') {
const id = nav.dataset.id;
const expanded = { ...db.ui.expanded };
setView({ type: 'nb', id }); db.ui.expanded = expanded;
db.ui.expanded[id] = !expanded[id] || db.ui.view.id !== id ? true : db.ui.expanded[id];
renderAll(); persist();
}
else if (v === 'tag') setView({ type: 'tag', tag: nav.dataset.tag });
}
}
function onSidebarDblClick(e){
const lab = e.target.closest('[data-rename-nb]');
if (lab) startRenameNotebook(lab.dataset.renameNb);
}
function onSidebarDragOver(e){
const nb = e.target.closest('.nb');
$$('.nb.drop').forEach(x => x !== nb && x.classList.remove('drop'));
if (!nb || !dragState.id) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
nb.classList.add('drop');
}
function onSidebarDrop(e){
const nb = e.target.closest('.nb');
$$('.nb.drop').forEach(x => x.classList.remove('drop'));
if (!nb || !dragState.id) return;
e.preventDefault();
moveNote(dragState.id, nb.dataset.nb);
}
function onSidebarDragLeave(e){
const nb = e.target.closest && e.target.closest('.nb');
if (nb && !nb.contains(e.relatedTarget)) nb.classList.remove('drop');
}
const dragState = { id: null };
function onListClick(e){
const more = e.target.closest('[data-act="list-menu"]');
if (more) {
e.stopPropagation();
const r = more.getBoundingClientRect();
return noteMenu(more.dataset.note, r.right - 200, r.bottom + 4);
}
const item = e.target.closest('[data-note]');
if (item) selectNote(item.dataset.note);
}
function onListContext(e){
const item = e.target.closest('[data-note]');
if (!item || getNote(item.dataset.note)?.trashedAt) return;
e.preventDefault();
noteMenu(item.dataset.note, e.clientX, e.clientY);
}
function onListDragStart(e){
const item = e.target.closest('.note-item[data-note]');
if (!item) return;
dragState.id = item.dataset.note;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragState.id);
}
function onListDragEnd(){
dragState.id = null;
$$('.note-item.dragging').forEach(x => x.classList.remove('dragging'));
$$('.nb.drop').forEach(x => x.classList.remove('drop'));
}
function onTagsClick(e){
const x = e.target.closest('[data-act="rm-tag"]');
if (x) { e.stopPropagation(); return removeTag(db.ui.selectedNoteId, x.dataset.tag); }
const chip = e.target.closest('.tag-chip');
if (chip) setView({ type: 'tag', tag: chip.dataset.tag });
}
function onTagsKeydown(e){
if (e.target.id !== 'tagAdd') return;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const v = e.target.value; e.target.value = '';
addTag(db.ui.selectedNoteId, v.replace(/,/g, ''));
const again = $('#tagAdd'); if (again) again.focus();
} else if (e.key === 'Escape') { e.target.value = ''; e.target.blur(); }
}
function onEditorToolbarClick(e){
const b = e.target.closest('.tb'); if (!b || b.disabled) return;
runCommand(b.dataset.act);
}
function onTitleKeydown(e){
if (e.key === 'Enter') { e.preventDefault(); ed().focus(); const first = ed().firstChild; if (first) placeCaret(first, false); else placeCaret(ed(), false); }
}
/* ----- search ----- */
let searchTimer = null;
function onSearchInput(){
db.ui.query = $('#searchInput').value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
ensureSelectionVisible();
renderAll(); persist();
}, 90);
}
function clearSearch(){
$('#searchInput').value = ''; db.ui.query = '';
ensureSelectionVisible(); renderAll(); persist();
$('#searchInput').focus();
}
/* ----- header / toolbar actions ----- */
function onHeaderClick(e){
const act = e.target.closest('[data-act]'); if (!act) return;
const a = act.dataset.act;
const id = db.ui.selectedNoteId;
if (a === 'toggle-side') return toggleSidebar();
if (a === 'new-note') return createNote();
if (a === 'clear-search') return clearSearch();
if (a === 'pin') return togglePin(id);
if (a === 'dup') return duplicateNote(id);
if (a === 'trash') return trashNote(id);
if (a === 'purge') return purgeNote(id);
if (a === 'restore') return restoreNote(id);
if (a === 'note-menu') { const r = act.getBoundingClientRect(); return noteMenu(id, r.right - 200, r.bottom + 4); }
}
function onGlobalKey(e){
const mod = e.ctrlKey || e.metaKey;
if (mod && (e.key === 'n' || e.key === 'N')) { e.preventDefault(); createNote(); }
else if (mod && e.key === '\\') { e.preventDefault(); toggleSidebar(); }
else if (mod && (e.key === 'k' || e.key === 'f') && !(document.activeElement === ed())) { e.preventDefault(); $('#searchInput').focus(); $('#searchInput').select(); }
else if (e.key === 'Escape' && menuEl) closeMenu();
else if (e.key === 'Escape' && document.activeElement === $('#searchInput')) { if ($('#searchInput').value) clearSearch(); else $('#searchInput').blur(); }
}
/* ----- selection changes drive toolbar state ----- */
let selRaf = 0;
function onSelectionChange(){
if (selRaf) return;
selRaf = requestAnimationFrame(() => { selRaf = 0; updateToolbarState(); });
}
/* ----- editor input / keys (history-aware) ----- */
function onEditorInputEvent(){
H.before(false);
onEditorChanged();
H.after();
updateToolbarState();
}
/* =====================================================================
17. INIT
===================================================================== */
function init(){
db = loadDB();
if (!db.ui.view) db.ui.view = { type: 'all' };
if (db.ui.selectedNoteId && !getNote(db.ui.selectedNoteId)) db.ui.selectedNoteId = null;
paintStaticIcons();
applyTheme();
$('#sortSel').value = db.ui.sort;
renderAll();
setupResizers();
$('#sideInner').addEventListener('click', onSidebarClick);
$('#sideInner').addEventListener('dblclick', onSidebarDblClick);
$('#sideInner').addEventListener('dragover', onSidebarDragOver);
$('#sideInner').addEventListener('dragleave', onSidebarDragLeave);
$('#sideInner').addEventListener('drop', onSidebarDrop);
$('#listScroll').addEventListener('click', onListClick);
$('#listScroll').addEventListener('contextmenu', onListContext);
$('#listScroll').addEventListener('dragstart', onListDragStart);
$('#listScroll').addEventListener('dragend', onListDragEnd);
$('#edTags').addEventListener('click', onTagsClick);
$('#edTags').addEventListener('keydown', onTagsKeydown);
$('#toolbar').addEventListener('mousedown', e => { if (e.target.closest('.tb')) e.preventDefault(); });
$('#toolbar').addEventListener('click', onEditorToolbarClick);
$('#editorPane').addEventListener('click', onHeaderClick);
$('#empty').addEventListener('click', e => e.stopPropagation());
$('#listPane').addEventListener('click', onHeaderClick);
$('#searchInput').addEventListener('input', onSearchInput);
$('#searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') e.target.blur(); });
$('#sortSel').addEventListener('change', e => { db.ui.sort = e.target.value; renderAll(); persist(); });
const titleEl = $('#edTitle');
titleEl.addEventListener('input', onTitleInput);
titleEl.addEventListener('keydown', onTitleKeydown);
const edEl = ed();
edEl.addEventListener('input', onEditorInputEvent);
edEl.addEventListener('keydown', onEditorKeydown);
edEl.addEventListener('paste', onPaste);
edEl.addEventListener('click', onEditorClick);
edEl.addEventListener('keyup', updateToolbarState);
edEl.addEventListener('mouseup', updateToolbarState);
edEl.addEventListener('focus', updatePlaceholder);
edEl.addEventListener('blur', updatePlaceholder);
edEl.addEventListener('dragstart', e => e.preventDefault());
edEl.addEventListener('drop', e => e.preventDefault());
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('keydown', onGlobalKey);
document.addEventListener('mousedown', e => { if (menuEl && !menuEl.contains(e.target)) closeMenu(); }, true);
window.addEventListener('resize', closeMenu);
$('#edScroll').addEventListener('scroll', closeMenu);
window.addEventListener('beforeunload', flushSave);
document.addEventListener('visibilitychange', () => { if (document.hidden) flushSave(); });
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"9411e0cb-b0ac-4afd-aba7-7102d0958bc5","tokensIn":386724,"tokensOut":150907,"tokensTotal":537631,"cost":0.087455075,"turns":9,"toolCalls":12,"failedToolCalls":0,"timestamp":"2026-10-08T00:42:23.335Z"} -->