Notes
Deepseek v4.1 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 104,771 bytes · SHA-256 fa9539d7d5a2
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notary — Notes</title>
<style>
/* ============================================================================
1. DESIGN TOKENS
========================================================================== */
:root{
--ui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--read-font: Georgia, "Iowan Old Style", "Palatino Linotype", "Times New Roman", serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--bg: #ebe5db;
--sidebar-bg: #f6f1e9;
--panel: #fffdf9;
--panel-2: #f8f4ed;
--hover: rgba(60,45,30,.055);
--hover-strong: rgba(60,45,30,.095);
--border: #e4dccf;
--border-strong: #d2c7b5;
--text: #2d2823;
--text-soft: #565049;
--muted: #8d8478;
--accent: #c2683f;
--accent-strong: #a4532e;
--accent-soft: #f7e5da;
--mark: #ffe39b;
--code-bg: #f1ebe1;
--shadow-sm: 0 1px 2px rgba(60,45,30,.07), 0 1px 3px rgba(60,45,30,.05);
--shadow-md: 0 6px 20px rgba(60,45,30,.10), 0 2px 6px rgba(60,45,30,.06);
--shadow-lg: 0 18px 48px rgba(40,30,20,.22), 0 4px 12px rgba(40,30,20,.12);
--radius: 10px;
}
[data-theme="dark"]{
--bg:#0f0e0d;
--sidebar-bg:#171614;
--panel:#232120;
--panel-2:#1b1a18;
--hover: rgba(255,255,255,.055);
--hover-strong: rgba(255,255,255,.10);
--border:#2e2b28;
--border-strong:#3e3a34;
--text:#e9e3d9;
--text-soft:#c5beb3;
--muted:#8e867b;
--accent:#e08e64;
--accent-strong:#f2ab82;
--accent-soft:#33251d;
--mark:#5d4a1c;
--code-bg:#2a2724;
--shadow-sm:0 1px 2px rgba(0,0,0,.45);
--shadow-md:0 6px 20px rgba(0,0,0,.45);
--shadow-lg:0 18px 48px rgba(0,0,0,.62);
}
/* ============================================================================
2. BASE
========================================================================== */
*,*::before,*::after{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
font-family:var(--ui-font);font-size:13.5px;line-height:1.45;
background:var(--bg);color:var(--text);overflow:hidden;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
text-rendering:optimizeLegibility;
}
button{font:inherit;color:inherit}
input{font:inherit;color:inherit}
svg{width:16px;height:16px;display:block;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
[hidden]{display:none!important}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
::selection{background:var(--accent-soft);color:var(--accent-strong)}
[data-theme="dark"] ::selection{background:#4a3226;color:#f6d9c5}
.scroll{overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent}
.scroll::-webkit-scrollbar{width:11px;height:11px}
.scroll::-webkit-scrollbar-thumb{background:var(--border);border:3px solid transparent;background-clip:content-box;border-radius:8px}
.scroll::-webkit-scrollbar-thumb:hover{background:var(--border-strong);background-clip:content-box}
.scroll::-webkit-scrollbar-track{background:transparent}
/* ============================================================================
3. APP SHELL / PANES
========================================================================== */
.app{
--sidebar-w: 268px;
--list-w: 322px;
display:flex;height:100vh;height:100dvh;overflow:hidden;background:var(--bg);
}
.sidebar{
flex:0 0 var(--sidebar-w);width:var(--sidebar-w);overflow:hidden;
background:var(--sidebar-bg);border-right:1px solid var(--border);
transition:flex-basis .26s cubic-bezier(.22,.8,.24,1), width .26s cubic-bezier(.22,.8,.24,1);
}
.sidebar-inner{
width:var(--sidebar-w);min-width:var(--sidebar-w);height:100%;
display:flex;flex-direction:column;transition:opacity .18s ease;
}
.sidebar.collapsed{flex-basis:0;width:0;border-right-color:transparent}
.sidebar.collapsed .sidebar-inner{opacity:0}
.resizer{
flex:0 0 6px;margin:0 -3px;z-index:5;cursor:col-resize;position:relative;background:transparent;
}
.resizer::after{
content:'';position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;transition:background .15s;
}
.resizer:hover::after,.resizer.dragging::after{background:var(--accent)}
.resizer.dragging{cursor:col-resize}
body.resizing{cursor:col-resize;user-select:none}
/* ============================================================================
4. SIDEBAR
========================================================================== */
.brand{display:flex;align-items:center;gap:10px;padding:18px 16px 12px}
.brand-mark{
width:28px;height:28px;border-radius:9px;flex:0 0 auto;
background:linear-gradient(150deg,var(--accent),#8d4327);
color:#fff;font-weight:700;font-size:15px;display:grid;place-items:center;
font-family:var(--read-font);box-shadow:var(--shadow-sm);letter-spacing:-.02em;
}
.brand-text{min-width:0;flex:1}
.brand-name{font-weight:650;font-size:14.5px;letter-spacing:-.01em}
.brand-sub{font-size:11px;color:var(--muted);margin-top:1px}
.icon-btn{
width:28px;height:28px;border-radius:8px;border:none;background:transparent;color:var(--muted);
display:grid;place-items:center;cursor:pointer;transition:background .15s,color .15s;flex:0 0 auto;
}
.icon-btn:hover{background:var(--hover-strong);color:var(--text)}
.btn-new{
margin:2px 14px 12px;height:34px;border-radius:9px;border:1px solid var(--border-strong);
background:var(--panel);color:var(--text);display:flex;align-items:center;gap:8px;
padding:0 11px;cursor:pointer;font-weight:520;box-shadow:var(--shadow-sm);
transition:transform .12s,box-shadow .15s,border-color .15s,background .15s;
}
.btn-new:hover{border-color:var(--accent);color:var(--accent-strong);transform:translateY(-1px);box-shadow:var(--shadow-md)}
.btn-new:active{transform:translateY(0)}
.btn-new .kbd{margin-left:auto;font-size:10.5px;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;background:var(--panel-2)}
.nav-scroll{flex:1;overflow-y:auto;padding:0 10px 14px;scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent}
.nav-scroll::-webkit-scrollbar{width:9px}
.nav-scroll::-webkit-scrollbar-thumb{background:var(--border);border:3px solid transparent;background-clip:content-box;border-radius:8px}
.nav-group{margin-bottom:14px}
.group-head{display:flex;align-items:center;justify-content:space-between;padding:0 6px 5px}
.group-title{font-size:10.5px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.mini-btn{
width:20px;height:20px;border-radius:6px;border:none;background:transparent;color:var(--muted);
cursor:pointer;display:grid;place-items:center;transition:background .15s,color .15s;
}
.mini-btn:hover{background:var(--hover-strong);color:var(--accent)}
.nav-item{
display:flex;align-items:center;gap:9px;width:100%;padding:6px 8px;border-radius:8px;border:none;
background:transparent;cursor:pointer;text-align:left;transition:background .13s,color .13s;
color:var(--text-soft);position:relative;
}
.nav-item:hover{background:var(--hover)}
.nav-item .nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nav-item .count{
font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;
padding:1px 6px;border-radius:20px;background:var(--hover-strong);
}
.nav-item.active{background:var(--accent-soft);color:var(--accent-strong);font-weight:600}
.nav-item.active .count{background:rgba(194,104,63,.18);color:var(--accent-strong)}
.nav-item.active svg{stroke-width:2}
.nav-item.drop-target{background:var(--accent-soft);box-shadow:inset 0 0 0 1.5px var(--accent)}
.nav-item svg{flex:0 0 auto;opacity:.85}
.nav-actions{display:flex;gap:2px;opacity:0;transition:opacity .12s}
.nav-item:hover .nav-actions,.nav-item.active .nav-actions{opacity:1}
.nav-actions .mini-btn{width:22px;height:22px}
.notebook-row{display:flex;align-items:center}
.caret{
width:18px;height:18px;flex:0 0 auto;display:grid;place-items:center;border-radius:5px;border:none;
background:transparent;color:var(--muted);cursor:pointer;transition:transform .18s ease,background .15s;
}
.caret svg{width:11px;height:11px;stroke-width:2.4}
.caret.open{transform:rotate(90deg)}
.caret:hover{background:var(--hover-strong);color:var(--text)}
.sub-notes{margin:1px 0 4px 22px;padding-left:8px;border-left:1px solid var(--border);display:flex;flex-direction:column;gap:1px}
.sub-note{
display:block;width:100%;text-align:left;border:none;background:transparent;cursor:pointer;
padding:4px 7px;border-radius:7px;color:var(--muted);font-size:12.5px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:background .13s,color .13s;
}
.sub-note:hover{background:var(--hover);color:var(--text)}
.sub-note.active{background:var(--panel);color:var(--accent-strong);font-weight:550;box-shadow:var(--shadow-sm)}
.sub-empty{padding:5px 7px;font-size:12px;color:var(--muted);font-style:italic}
.tag-list{display:flex;flex-wrap:wrap;gap:5px;padding:2px 6px}
.tag-pill{
border:1px solid var(--border);background:var(--panel);border-radius:20px;padding:2.5px 9px;
font-size:11.5px;color:var(--text-soft);cursor:pointer;display:inline-flex;align-items:center;gap:5px;
transition:background .13s,border-color .13s,color .13s,transform .12s;
}
.tag-pill:hover{border-color:var(--accent);color:var(--accent-strong);transform:translateY(-1px)}
.tag-pill.active{background:var(--accent);border-color:var(--accent);color:#fff}
.tag-pill .tag-n{opacity:.6;font-size:10.5px;font-variant-numeric:tabular-nums}
.empty-hint{font-size:12px;color:var(--muted);padding:3px 7px;font-style:italic}
.sidebar-foot{padding:8px 10px 12px;border-top:1px solid var(--border);background:var(--sidebar-bg)}
/* ============================================================================
5. NOTE LIST PANE
========================================================================== */
.list-pane{
flex:0 0 var(--list-w);width:var(--list-w);background:var(--panel-2);
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;min-width:0;
}
.list-head{padding:13px 14px 9px;border-bottom:1px solid var(--border)}
.list-title-row{display:flex;align-items:center;gap:6px;margin-bottom:10px}
.list-title{font-size:14.5px;font-weight:650;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.collapse-btn{
width:26px;height:26px;border-radius:7px;border:none;background:transparent;color:var(--muted);
display:grid;place-items:center;cursor:pointer;transition:background .15s,color .15s;
}
.collapse-btn:hover{background:var(--hover-strong);color:var(--text)}
.collapse-btn.collapsed svg{transform:rotate(180deg)}
.search-wrap{position:relative;display:flex;align-items:center}
.search-wrap svg{position:absolute;left:9px;color:var(--muted);width:15px;height:15px;pointer-events:none}
#searchInput{
width:100%;height:32px;border-radius:8px;border:1px solid var(--border);background:var(--panel);
padding:0 28px 0 29px;font-size:13px;transition:border-color .15s,box-shadow .15s,background .15s;
}
#searchInput::placeholder{color:var(--muted)}
#searchInput:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);background:var(--panel)}
.search-clear{
position:absolute;right:5px;width:20px;height:20px;border-radius:6px;border:none;background:transparent;
color:var(--muted);cursor:pointer;display:grid;place-items:center;
}
.search-clear:hover{background:var(--hover-strong);color:var(--text)}
.list-sub{margin-top:8px;display:flex;align-items:center;gap:8px;min-height:20px}
.result-note{font-size:11.5px;color:var(--muted);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sort-btn{
border:1px solid transparent;background:transparent;color:var(--muted);border-radius:7px;cursor:pointer;
height:22px;padding:0 7px;font-size:11.5px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;
transition:background .15s,color .15s,border-color .15s;
}
.sort-btn:hover{background:var(--hover-strong);color:var(--text);border-color:var(--border)}
.sort-btn svg{width:12px;height:12px}
.note-list{flex:1;overflow-y:auto;padding:8px 9px 24px;scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent}
.note-list::-webkit-scrollbar{width:11px}
.note-list::-webkit-scrollbar-thumb{background:var(--border);border:3px solid transparent;background-clip:content-box;border-radius:8px}
.note-card{
position:relative;padding:10px 11px 10px 13px;border-radius:10px;cursor:pointer;
border:1px solid transparent;transition:background .14s,border-color .14s,box-shadow .14s,transform .12s;
margin-bottom:3px;
}
.note-card:hover{background:var(--hover)}
.note-card.active{background:var(--panel);border-color:var(--border);box-shadow:var(--shadow-sm)}
.note-card.active::before{
content:'';position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;background:var(--accent);
}
.note-card.dragging{opacity:.45}
.note-card-top{display:flex;align-items:center;gap:6px}
.note-card-title{
font-weight:600;font-size:13.2px;letter-spacing:-.005em;flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);
}
.note-card.active .note-card-title{color:var(--accent-strong)}
.pin-badge{color:var(--accent);flex:0 0 auto;display:grid;place-items:center}
.pin-badge svg{width:12px;height:12px;fill:currentColor;stroke-width:1.4}
.note-card-snippet{
margin-top:3px;font-size:12.3px;color:var(--muted);line-height:1.5;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word;
}
.note-card-snippet .meta-dot{margin:0 5px;opacity:.5}
.note-card-foot{display:flex;align-items:center;gap:6px;margin-top:7px}
.note-card-time{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.note-card-tags{display:flex;gap:4px;overflow:hidden;flex:1}
.mini-tag{font-size:10.5px;color:var(--muted);background:var(--hover-strong);border-radius:20px;padding:1px 6px;white-space:nowrap}
.card-menu{
position:absolute;right:6px;top:6px;width:24px;height:24px;border-radius:7px;border:none;
background:var(--panel);color:var(--muted);cursor:pointer;display:grid;place-items:center;opacity:0;
box-shadow:var(--shadow-sm);transition:opacity .14s,background .14s,color .14s;
}
.note-card:hover .card-menu,.card-menu.open{opacity:1}
.card-menu:hover{color:var(--accent)}
mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 1px;font-weight:550}
/* ============================================================================
6. EDITOR PANE
========================================================================== */
.editor-pane{flex:1 1 auto;min-width:0;background:var(--panel);display:flex;flex-direction:column;overflow:hidden;position:relative}
.editor-wrap{display:flex;flex-direction:column;height:100%;min-height:0;animation:fade .22s ease}
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.editor-empty{
position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:4px;text-align:center;padding:30px;animation:fade .3s ease;color:var(--muted);
}
.empty-art{
width:78px;height:78px;border-radius:22px;display:grid;place-items:center;margin-bottom:14px;
background:var(--accent-soft);color:var(--accent);box-shadow:var(--shadow-sm);
}
.empty-art svg{width:34px;height:34px;stroke-width:1.5}
.editor-empty h2{margin:0;font-size:17px;font-weight:640;color:var(--text);letter-spacing:-.01em;font-family:var(--read-font)}
.editor-empty p{margin:6px 0 18px;font-size:13px;max-width:290px;line-height:1.6}
.btn-primary{
height:34px;padding:0 15px;border-radius:9px;border:none;background:var(--accent);color:#fff;
font-weight:560;cursor:pointer;display:inline-flex;align-items:center;gap:7px;box-shadow:var(--shadow-sm);
transition:background .15s,transform .12s,box-shadow .15s;
}
.btn-primary:hover{background:var(--accent-strong);transform:translateY(-1px);box-shadow:var(--shadow-md)}
.btn-primary:active{transform:none}
.editor-head{padding:20px max(34px,calc(50% - 360px)) 0;border-bottom:1px solid var(--border)}
.title-input{
width:100%;border:none;background:transparent;font-family:var(--read-font);font-size:26px;font-weight:700;
letter-spacing:-.018em;padding:0 0 6px;color:var(--text);
}
.title-input::placeholder{color:var(--border-strong);font-weight:600}
.title-input:focus{outline:none}
.trash-bar{
display:flex;align-items:center;gap:8px;margin-bottom:13px;padding:6px 8px 6px 10px;
background:var(--accent-soft);border:1px solid rgba(194,104,63,.32);border-radius:9px;
font-size:12px;color:var(--accent-strong);animation:fade .2s ease;
}
.trash-bar svg{width:14px;height:14px;flex:0 0 auto}
.trash-bar button{
height:23px;padding:0 9px;border-radius:7px;border:1px solid rgba(194,104,63,.4);
background:var(--panel);color:var(--accent-strong);cursor:pointer;font-size:11.5px;font-weight:530;
transition:background .14s,border-color .14s;
}
.trash-bar button:hover{background:#fff;border-color:var(--accent)}
[data-theme="dark"] .trash-bar button:hover{background:#2c2320}
.trash-bar button.danger{color:#c0392b;border-color:rgba(192,57,43,.35)}
.trash-bar button.danger:hover{background:rgba(192,57,43,.08)}
.trash-bar .flex-spacer{flex:1}
.meta-row{display:flex;align-items:center;gap:7px;padding:2px 0 12px;flex-wrap:wrap;min-height:28px}
.meta-chip{
height:23px;padding:0 9px;border-radius:20px;border:1px solid var(--border);background:var(--panel-2);
color:var(--text-soft);font-size:11.5px;cursor:pointer;display:inline-flex;align-items:center;gap:5px;
transition:background .14s,border-color .14s,color .14s;max-width:220px;
}
.meta-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.meta-chip:hover{border-color:var(--accent);color:var(--accent-strong);background:var(--accent-soft)}
.meta-chip.ghost{border-style:dashed;color:var(--muted);background:transparent}
.meta-chip svg{width:12px;height:12px}
.tags-inline{display:flex;gap:5px;flex-wrap:wrap}
.tag-chip{
height:23px;padding:0 6px 0 9px;border-radius:20px;background:var(--accent-soft);color:var(--accent-strong);
font-size:11.5px;display:inline-flex;align-items:center;gap:4px;font-weight:520;
}
.tag-chip button{border:none;background:transparent;color:inherit;cursor:pointer;opacity:.5;width:15px;height:15px;border-radius:50%;display:grid;place-items:center;font-size:13px;line-height:1;padding:0}
.tag-chip button:hover{opacity:1;background:rgba(194,104,63,.2)}
.flex-spacer{flex:1}
.save-state{
font-size:11.5px;color:var(--muted);display:inline-flex;align-items:center;gap:5px;
transition:opacity .4s,color .2s;font-variant-numeric:tabular-nums;
}
.save-state svg{width:12px;height:12px;color:var(--accent);transition:transform .2s}
.save-state.saving svg{transform:scale(.7);opacity:.5}
.toolbar{
display:flex;align-items:center;gap:2px;padding:7px max(32px,calc(50% - 362px));border-bottom:1px solid var(--border);
background:var(--panel-2);flex-wrap:wrap;position:relative;z-index:3;
}
.tb{
height:29px;min-width:29px;padding:0 7px;border-radius:7px;border:1px solid transparent;background:transparent;
color:var(--text-soft);font-size:12.5px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;
transition:background .13s,color .13s,border-color .13s;font-weight:520;
}
.tb:hover{background:var(--hover-strong);color:var(--text)}
.tb:active{transform:scale(.94)}
.tb.active{background:var(--accent-soft);color:var(--accent-strong);border-color:rgba(194,104,63,.28)}
.tb[disabled]{opacity:.35;cursor:default}
.tb[disabled]:hover{background:transparent;color:var(--text-soft)}
.tb-sep{width:1px;height:17px;background:var(--border);margin:0 5px}
.editor-scroll{flex:1;overflow-y:auto;padding:26px 34px 0;scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent}
.editor-scroll::-webkit-scrollbar{width:12px}
.editor-scroll::-webkit-scrollbar-thumb{background:var(--border);border:4px solid transparent;background-clip:content-box;border-radius:8px}
.editor{
max-width:720px;margin:0 auto;font-family:var(--read-font);font-size:17.5px;line-height:1.72;
color:var(--text);outline:none;min-height:56vh;padding-bottom:12px;word-break:break-word;
}
.editor:empty::before{content:attr(data-placeholder);color:var(--border-strong);pointer-events:none;font-style:italic}
.editor p{margin:0 0 .62em}
.editor div{margin:0 0 .5em}
.editor h1,.editor h2,.editor h3{font-weight:700;letter-spacing:-.018em;line-height:1.25;margin:1.15em 0 .38em}
.editor h1{font-size:1.72em}
.editor h2{font-size:1.4em}
.editor h3{font-size:1.16em}
.editor h1:first-child,.editor h2:first-child,.editor h3:first-child{margin-top:.1em}
.editor h1:empty::after,.editor h2:empty::after,.editor h3:empty::after,
.editor blockquote:empty::after{content:'\200b'}
.editor li:empty::after,.editor p:empty::after{content:'\200b'}
.editor ul,.editor ol{margin:.45em 0 .8em;padding-left:1.5em}
.editor ul{list-style:disc}
.editor ol{list-style:decimal}
.editor li{margin:.2em 0}
.editor li::marker{color:var(--accent);font-weight:600}
.editor blockquote{
margin:.85em 0;padding:.15em 0 .15em 17px;border-left:3px solid var(--accent);
color:var(--text-soft);font-style:italic;
}
.editor code{
font-family:var(--mono);font-size:.85em;background:var(--code-bg);
padding:.13em .38em;border-radius:5px;border:1px solid var(--border);white-space:pre-wrap;
}
.editor hr{border:none;border-top:1.5px solid var(--border);margin:1.6em 0}
.editor ul.checklist{list-style:none;padding-left:2px;margin:.4em 0 .7em}
.editor li.chk{display:flex;align-items:flex-start;gap:9px;margin:.18em 0}
.chk-box{
width:17px;height:17px;flex:0 0 auto;margin-top:.34em;border:1.6px solid var(--border-strong);
border-radius:5px;cursor:pointer;position:relative;background:var(--panel);
transition:background .16s,border-color .16s,transform .12s;
}
.chk-box:hover{border-color:var(--accent);transform:scale(1.08)}
li.chk[data-checked="true"] .chk-box{background:var(--accent);border-color:var(--accent)}
li.chk[data-checked="true"] .chk-box::after{
content:'';position:absolute;left:4.6px;top:1.3px;width:5px;height:9px;
border:solid #fff;border-width:0 2.1px 2.1px 0;transform:rotate(45deg);
}
.chk-text{flex:1;min-width:0;transition:color .18s}
.chk-text:empty::after{content:'\200b'}
li.chk[data-checked="true"] .chk-text{color:var(--muted);text-decoration:line-through;text-decoration-thickness:1px}
.editor a{color:var(--accent-strong)}
.editor-foot{
display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:7px max(34px,calc(50% - 360px));border-top:1px solid var(--border);background:var(--panel);
font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;
}
/* ============================================================================
7. MENUS / MODALS / TOAST
========================================================================== */
.menu{
position:fixed;z-index:60;min-width:196px;background:var(--panel);border:1px solid var(--border);
border-radius:11px;box-shadow:var(--shadow-lg);padding:5px;animation:pop .13s cubic-bezier(.2,.9,.3,1.2);
transform-origin:top left;
}
@keyframes pop{from{opacity:0;transform:scale(.95) translateY(-4px)}to{opacity:1;transform:none}}
.menu-label{font-size:10px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);padding:7px 9px 4px}
.menu-item{
display:flex;align-items:center;gap:9px;width:100%;padding:7px 9px;border-radius:8px;border:none;
background:transparent;cursor:pointer;text-align:left;font-size:13px;color:var(--text);transition:background .12s,color .12s;
}
.menu-item:hover{background:var(--hover-strong)}
.menu-item.danger{color:#c0392b}
[data-theme="dark"] .menu-item.danger{color:#e88a7d}
.menu-item.danger:hover{background:rgba(192,57,43,.1)}
.menu-item .menu-check{width:15px;display:grid;place-items:center;flex:0 0 auto}
.menu-item .menu-key{margin-left:auto;font-size:11px;color:var(--muted)}
.menu-sep{height:1px;background:var(--border);margin:5px 3px}
.backdrop{
position:fixed;inset:0;background:rgba(30,22,14,.32);backdrop-filter:blur(2px);z-index:70;
display:grid;place-items:center;animation:fade .16s ease;padding:20px;
}
[data-theme="dark"] .backdrop{background:rgba(0,0,0,.55)}
.modal{
width:min(430px,100%);background:var(--panel);border:1px solid var(--border);border-radius:15px;
box-shadow:var(--shadow-lg);padding:20px 20px 16px;animation:pop .18s cubic-bezier(.2,.9,.3,1.1);
}
.modal h3{margin:0 0 6px;font-size:16px;font-weight:660;letter-spacing:-.01em}
.modal p{margin:0 0 15px;font-size:13px;color:var(--text-soft);line-height:1.55}
.modal-input{
width:100%;height:36px;border-radius:9px;border:1px solid var(--border-strong);background:var(--panel-2);
padding:0 11px;margin-bottom:15px;font-size:13.5px;
}
.modal-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.modal-actions{display:flex;justify-content:flex-end;gap:8px}
.btn{
height:33px;padding:0 14px;border-radius:9px;border:1px solid var(--border-strong);background:var(--panel-2);
cursor:pointer;font-weight:530;transition:background .14s,border-color .14s,transform .12s;
}
.btn:hover{background:var(--hover-strong)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-strong);border-color:var(--accent-strong)}
.btn.danger{background:#c0392b;border-color:#c0392b;color:#fff}
.btn.danger:hover{background:#a93226;border-color:#a93226}
.toast{
position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:90;
background:var(--text);color:var(--panel);border-radius:10px;padding:9px 15px;font-size:12.5px;
box-shadow:var(--shadow-lg);display:flex;align-items:center;gap:10px;animation:rise .22s cubic-bezier(.2,.9,.3,1.2);
}
@keyframes rise{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
.toast button{background:transparent;border:none;color:var(--accent);cursor:pointer;font-weight:620;font-size:12.5px;padding:0}
.drag-ghost{position:fixed;pointer-events:none;z-index:100}
@media (max-width:860px){
.app{--sidebar-w:230px}
.editor-head,.toolbar,.editor-scroll,.editor-foot{padding-left:18px;padding-right:18px}
}
@media (max-width:700px){
.app{--list-w:140px}
.list-pane{flex:0 0 140px}
.editor-head,.toolbar,.editor-scroll,.editor-foot{padding-left:12px;padding-right:12px}
.editor{font-size:15.5px}
.title-input{font-size:20px}
.brand-sub,.btn-new .kbd{display:none}
}
</style>
</head>
<body>
<div class="app" id="app">
<!-- ===================== SIDEBAR ===================== -->
<aside class="sidebar" id="sidebar" aria-label="Notebooks">
<div class="sidebar-inner" id="sidebarInner">
<header class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="brand-name">Notary</div>
<div class="brand-sub">Notes, nicely kept</div>
</div>
<button class="icon-btn" id="themeToggle" title="Toggle theme (dark / light)" aria-label="Toggle theme"></button>
</header>
<button class="btn-new" id="newNoteBtn">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
New note
<span class="kbd">⌘N</span>
</button>
<div class="nav-scroll" id="navScroll">
<div class="nav-group">
<button class="nav-item" data-nav="all">
<svg viewBox="0 0 24 24"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10l2 2h6.5A1.5 1.5 0 0 1 20 7.5v11A1.5 1.5 0 0 1 18.5 20h-13A1.5 1.5 0 0 1 4 18.5z"/></svg>
<span class="nav-label">All notes</span>
<span class="count" id="countAll">0</span>
</button>
</div>
<div class="nav-group">
<div class="group-head">
<span class="group-title">Notebooks</span>
<button class="mini-btn" id="newNotebookBtn" title="New notebook" aria-label="New notebook">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div id="notebookList"></div>
</div>
<div class="nav-group" id="tagsGroup">
<div class="group-head"><span class="group-title">Tags</span></div>
<div class="tag-list" id="tagList"></div>
</div>
</div>
<footer class="sidebar-foot">
<button class="nav-item" data-nav="trash">
<svg viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7M6.5 7l.8 11.4A1.6 1.6 0 0 0 8.9 20h6.2a1.6 1.6 0 0 0 1.6-1.6L17.5 7"/></svg>
<span class="nav-label">Trash</span>
<span class="count" id="countTrash">0</span>
</button>
</footer>
</div>
</aside>
<div class="resizer" id="rz1" title="Drag to resize sidebar"></div>
<!-- ===================== NOTE LIST ===================== -->
<section class="list-pane" id="listPane" aria-label="Note list">
<div class="list-head">
<div class="list-title-row">
<button class="collapse-btn" id="collapseBtn" title="Collapse sidebar" aria-label="Collapse sidebar">
<svg viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/><path d="M4 4v16"/></svg>
</button>
<div class="list-title" id="listTitle">All notes</div>
<button class="icon-btn" id="listNewBtn" title="New note">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="search-wrap">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4-4"/></svg>
<input id="searchInput" type="text" placeholder="Search all notes…" autocomplete="off" spellcheck="false">
<button class="search-clear" id="searchClear" title="Clear search" hidden>
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="list-sub">
<span class="result-note" id="resultNote"></span>
<button class="sort-btn" id="sortBtn" title="Change sort order"></button>
</div>
</div>
<div class="note-list" id="noteList"></div>
</section>
<div class="resizer" id="rz2" title="Drag to resize note list"></div>
<!-- ===================== EDITOR ===================== -->
<section class="editor-pane" id="editorPane" aria-label="Editor">
<div class="editor-empty" id="editorEmpty">
<div class="empty-art">
<svg viewBox="0 0 24 24"><path d="M6 4.5h7.5L18 9v10.5H6z"/><path d="M13.5 4.5V9H18"/><path d="M9 13h6M9 16.2h3.6"/></svg>
</div>
<h2>Select or create a note</h2>
<p>Choose a note from the list, or start something new. Everything saves as you type.</p>
<button class="btn-primary" id="emptyNewBtn">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
New note
</button>
</div>
<div class="editor-wrap" id="editorWrap" hidden>
<header class="editor-head">
<div class="trash-bar" id="trashBar" hidden>
<svg viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7M6.5 7l.8 11.4A1.6 1.6 0 0 0 8.9 20h6.2a1.6 1.6 0 0 0 1.6-1.6L17.5 7"/></svg>
<span>This note is in the Trash.</span>
<span class="flex-spacer"></span>
<button id="restoreBtn">Restore</button>
<button id="purgeBtn" class="danger">Delete forever</button>
</div>
<input id="titleInput" class="title-input" placeholder="Untitled note" autocomplete="off" spellcheck="false">
<div class="meta-row">
<button class="meta-chip" id="notebookChip" title="Move to notebook"></button>
<span id="tagChips" class="tags-inline"></span>
<button class="meta-chip ghost" id="addTagBtn">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
<span>Tag</span>
</button>
<div class="flex-spacer"></div>
<span class="save-state" id="saveState">
<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
<span id="saveStateText">Saved</span>
</span>
<button class="icon-btn" id="noteMenuBtn" title="Note options">
<svg viewBox="0 0 24 24"><circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none"/></svg>
</button>
</div>
</header>
<div class="toolbar" id="toolbar">
<button class="tb" data-cmd="undo" title="Undo (⌘Z)"><svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h9a6 6 0 0 1 0 12h-3"/></svg></button>
<button class="tb" data-cmd="redo" title="Redo (⌘⇧Z)"><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9h-9a6 6 0 0 0 0 12h3"/></svg></button>
<span class="tb-sep"></span>
<button class="tb" data-cmd="bold" title="Bold (⌘B)"><b>B</b></button>
<button class="tb" data-cmd="italic" title="Italic (⌘I)"><i>I</i></button>
<button class="tb" data-cmd="underline" title="Underline (⌘U)"><u>U</u></button>
<button class="tb" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button>
<button class="tb" data-cmd="code" title="Inline code (⌘E)"><span style="font-family:var(--mono);font-size:11.5px"></></span></button>
<span class="tb-sep"></span>
<button class="tb" data-cmd="h1" title="Heading 1">H1</button>
<button class="tb" data-cmd="h2" title="Heading 2">H2</button>
<button class="tb" data-cmd="h3" title="Heading 3">H3</button>
<span class="tb-sep"></span>
<button class="tb" data-cmd="ul" title="Bulleted list"><svg viewBox="0 0 24 24"><path d="M9 6.5h11M9 12h11M9 17.5h11"/><circle cx="4.6" cy="6.5" r="1.4" fill="currentColor" stroke="none"/><circle cx="4.6" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="4.6" cy="17.5" r="1.4" fill="currentColor" stroke="none"/></svg></button>
<button class="tb" data-cmd="ol" title="Numbered list"><svg viewBox="0 0 24 24"><path d="M9.5 6.5h11M9.5 12h11M9.5 17.5h11"/><path d="M3.6 4.8h1.3v3.1M3.4 11.2c.9-.9 2.1-.5 2.1.4 0 .9-2 1.3-2 2.2h2.2M3.5 16.4c1.4 0 2 .7 2 1.4 0 .9-.8 1.5-1.6 1.4M4 19.2c1 0 1.7.5 1.7 1.3"/></svg></button>
<button class="tb" data-cmd="checklist" title="Checklist item"><svg viewBox="0 0 24 24"><rect x="3.4" y="3.6" width="7" height="7" rx="2"/><path d="M5.2 7.1l1.4 1.4 2.3-2.7"/><path d="M13.5 7h7M13.5 12h7M13.5 17h7"/><path d="M5.4 14.6h4.2M7.5 12.5v4.2"/></svg></button>
<span class="tb-sep"></span>
<button class="tb" data-cmd="blockquote" title="Blockquote"><svg viewBox="0 0 24 24"><path d="M5 5v14"/><path d="M10.5 8h8M10.5 12h8M10.5 16h5"/></svg></button>
<button class="tb" data-cmd="hr" title="Divider"><svg viewBox="0 0 24 24"><path d="M4 12h16"/></svg></button>
</div>
<div class="editor-scroll" id="editorScroll">
<div class="editor" id="editor" contenteditable="true" spellcheck="true" data-placeholder="Start writing…"></div>
</div>
<footer class="editor-foot">
<span id="wordCount">0 words</span>
<span id="footDate"></span>
</footer>
</div>
</section>
</div>
<script>
"use strict";
/* ============================================================================
1. TUNABLE CONSTANTS
========================================================================== */
const CFG = {
STORAGE_KEY: 'notary.notes.v1',
AUTOSAVE_MS: 450, // debounce before the data model is written to localStorage
HISTORY_COMMIT_MS:450, // typing burst window for undo grouping
HISTORY_LIMIT: 150,
SNIPPET_LEN: 150, // characters of body text shown in the note list
MIN_SIDEBAR_W: 186,
MAX_SIDEBAR_W: 440,
MIN_LIST_W: 232,
MAX_LIST_W: 560,
MIN_EDITOR_W: 400,
COLLAPSE_AT: 720, // viewport width under which the sidebar auto-collapses
SORT: {
modified: { key: 'modified', label: 'Last modified' },
created: { key: 'created', label: 'Date created' },
alpha: { key: 'alpha', label: 'Alphabetical' }
},
DEFAULT_NOTEBOOK: 'My Notebook'
};
/* ============================================================================
2. ICONS (inline SVG, feather-ish)
========================================================================== */
const ICONS = {
notebook: '<svg viewBox="0 0 24 24"><path d="M6 4.5A1.5 1.5 0 0 1 7.5 3H17a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7.5A1.5 1.5 0 0 1 6 19.5z"/><path d="M6 17.5h12M9.5 3v18"/></svg>',
chevron: '<svg viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>',
pin: '<svg viewBox="0 0 24 24"><path d="M9 4h6l-.7 4.2 2.7 2.6H7l2.7-2.6z"/><path d="M12 10.8V20"/></svg>',
more: '<svg viewBox="0 0 24 24"><circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
pencil: '<svg viewBox="0 0 24 24"><path d="M16.5 4.6l2.9 2.9L8.4 18.4l-3.4.5.5-3.4z"/></svg>',
copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 6.5V5.8A1.8 1.8 0 0 0 13.2 4H5.8A1.8 1.8 0 0 0 4 5.8v7.4A1.8 1.8 0 0 0 5.8 15h.7"/></svg>',
trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7M6.5 7l.8 11.4A1.6 1.6 0 0 0 8.9 20h6.2a1.6 1.6 0 0 0 1.6-1.6L17.5 7"/></svg>',
restore: '<svg viewBox="0 0 24 24"><path d="M4 10a8 8 0 1 1 2.3 5.6"/><path d="M4 4.5V10h5.5"/></svg>',
folder: '<svg viewBox="0 0 24 24"><path d="M4 6.5A1.5 1.5 0 0 1 5.5 5H10l2 2h6.5A1.5 1.5 0 0 1 20 8.5v9A1.5 1.5 0 0 1 18.5 19h-13A1.5 1.5 0 0 1 4 17.5z"/></svg>',
arrowR: '<svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
check: '<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>',
undo: '<svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h9a6 6 0 0 1 0 12h-3"/></svg>',
sun: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.2M12 19.2v2.2M4.2 12H2M22 12h-2.2M5.8 5.8L4.2 4.2M19.8 19.8l-1.6-1.6M18.2 5.8l1.6-1.6M4.2 19.8l1.6-1.6"/></svg>',
moon: '<svg viewBox="0 0 24 24"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>',
divider: '<svg viewBox="0 0 24 24"><path d="M4 12h16"/></svg>',
sortAsc: '<svg viewBox="0 0 24 24"><path d="M7 20V5M7 5l-3 3M7 5l3 3M13 7h7M13 12h5M13 17h3"/></svg>'
};
/* ============================================================================
3. UTILITIES
========================================================================== */
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
function uid(prefix) {
return (prefix || 'id') + '_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function debounce(fn, ms) {
let t = null;
const wrapped = function () {
const args = arguments;
clearTimeout(t);
t = setTimeout(() => { t = null; fn.apply(null, args); }, ms);
};
wrapped.flush = function () { if (t) { clearTimeout(t); t = null; fn.apply(null, arguments); } };
wrapped.cancel = function () { clearTimeout(t); t = null; };
return wrapped;
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
}
function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/* Human-friendly timestamps --------------------------------------------------*/
function formatStamp(ts) {
if (!ts) return '';
const d = new Date(ts), now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (d.toDateString() === yest.toDateString()) return 'Yesterday';
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
function formatFull(ts) {
if (!ts) return '';
return new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
/* HTML -> plain text (single source of truth for search + snippets) ----------*/
const TEXT_BLOCKS = 'p,div,h1,h2,h3,li,blockquote,tr';
function htmlToText(html, opts) {
const o = opts || {};
const doc = new DOMParser().parseFromString(String(html == null ? '' : html), 'text/html');
doc.querySelectorAll('br').forEach(br => br.replaceWith(doc.createTextNode('\n')));
doc.querySelectorAll('hr').forEach(hr => hr.replaceWith(doc.createTextNode(' ')));
doc.querySelectorAll(TEXT_BLOCKS).forEach(el => {
if (el.lastChild && el.lastChild.nodeType === 1 && el.lastChild.tagName === 'BR') el.lastChild.remove();
el.appendChild(doc.createTextNode('\n'));
});
let text = (doc.body.textContent || '').replace(/\u00a0/g, ' ').replace(/\u200b/g, '');
text = text.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/\n{3,}/g, '\n\n');
return o.keepNewlines ? text.trim() : text.replace(/\s+/g, ' ').trim();
}
/* HTML sanitizer: keeps only the supported formatting subset ----------------*/
const ALLOWED_TAGS = {
B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, DEL: 1, CODE: 1,
P: 1, DIV: 1, BR: 1, H1: 1, H2: 1, H3: 1,
UL: 1, OL: 1, LI: 1, BLOCKQUOTE: 1, HR: 1, SPAN: 1, MARK: 1, A: 1
};
const FORBIDDEN_TAGS = { SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, LINK: 1, META: 1, TITLE: 1, NOSCRIPT: 1, TEMPLATE: 1, FORM: 1, INPUT: 1, SVG: 1, IMG: 1, VIDEO: 1, AUDIO: 1, HEAD: 1 };
const TAG_REMAP = { STRONG: 'b', EM: 'i', STRIKE: 's', DEL: 's', MARK: 'span', B: 'b', I: 'i', U: 'u', S: 's' };
function sanitizeNode(node, doc) {
if (node.nodeType === 3) return doc.createTextNode(node.nodeValue);
if (node.nodeType !== 1) return doc.createDocumentFragment();
const tag = node.tagName;
if (FORBIDDEN_TAGS[tag]) return doc.createDocumentFragment();
const frag = doc.createDocumentFragment();
Array.prototype.forEach.call(node.childNodes, child => frag.appendChild(sanitizeNode(child, doc)));
if (!ALLOWED_TAGS[tag]) return frag; // unwrap unknown wrappers
const out = doc.createElement(TAG_REMAP[tag] || tag.toLowerCase());
/* checklist spans are the only permitted structural classes */
const cls = node.getAttribute('class') || '';
if (tag === 'SPAN' && /(^|\s)chk-(box|text)(\s|$)/.test(cls)) {
const isBox = /(^|\s)chk-box(\s|$)/.test(cls);
out.setAttribute('class', isBox ? 'chk-box' : 'chk-text');
if (isBox) out.setAttribute('contenteditable', 'false');
}
if (tag === 'A') {
const href = (node.getAttribute('href') || '').trim();
if (/^https?:|^mailto:/i.test(href)) out.setAttribute('href', href);
}
out.appendChild(frag);
return out;
}
function sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(String(html == null ? '' : html), 'text/html');
const holder = doc.createElement('div');
Array.prototype.forEach.call(doc.body.childNodes, n => holder.appendChild(sanitizeNode(n, doc)));
return holder.innerHTML;
}
function textToHtml(text) {
return escapeHtml(text).split(/\n{2,}/).map(block =>
'<p>' + block.split('\n').map(l => escapeHtml(l)).join('<br>') + '</p>'
).join('');
}
/* ============================================================================
4. DATA MODEL (single source of truth — the DOM is only a projection)
========================================================================== */
/* notebook: { id, name, createdAt }
note: { id, notebookId, title, html, tags[], pinned, trashed, trashedFrom,
createdAt, updatedAt } */
let state = {
notebooks: [],
notes: [],
ui: {
view: 'all', // 'all' | 'trash' | <notebookId>
tagFilter: null,
selectedNoteId: null,
search: '',
sort: 'modified',
theme: 'light',
sidebarW: CFG.MIN_SIDEBAR_W + 82,
listW: 322,
sidebarCollapsed: false,
expanded: {}
}
};
/* --- persistence -----------------------------------------------------------*/
const persist = debounce(() => {
try {
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
v: 1, notebooks: state.notebooks, notes: state.notes, ui: state.ui
}));
setSaveState('saved');
} catch (e) {
console.warn('Save failed', e);
setSaveState('error');
}
}, CFG.AUTOSAVE_MS);
function saveNow() {
persist.cancel();
try {
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
v: 1, notebooks: state.notebooks, notes: state.notes, ui: state.ui
}));
setSaveState('saved');
} catch (e) { /* ignore */ }
}
function loadState() {
let raw = null;
try { raw = localStorage.getItem(CFG.STORAGE_KEY); } catch (e) { raw = null; }
if (raw) {
try {
const data = JSON.parse(raw);
if (data && Array.isArray(data.notebooks) && Array.isArray(data.notes)) {
state.notebooks = data.notebooks.map(nb => ({ id: nb.id, name: nb.name, createdAt: nb.createdAt || Date.now() }));
state.notes = data.notes.map(n => ({
id: n.id || uid('note'),
notebookId: n.notebookId,
title: typeof n.title === 'string' ? n.title : '',
html: typeof n.html === 'string' ? n.html : '',
tags: Array.isArray(n.tags) ? n.tags.filter(t => typeof t === 'string') : [],
pinned: !!n.pinned, trashed: !!n.trashed,
trashedFrom: n.trashedFrom || null,
createdAt: n.createdAt || Date.now(),
updatedAt: n.updatedAt || n.createdAt || Date.now()
}));
Object.assign(state.ui, data.ui || {});
/* repair notes orphaned by a deleted/unknown notebook */
const ids = {};
state.notebooks.forEach(nb => { ids[nb.id] = true; });
state.notes.forEach(n => { if (!ids[n.notebookId]) n.notebookId = state.notebooks[0].id; });
return true;
}
} catch (e) { console.warn('Corrupt save, starting fresh', e); }
}
seed();
return false;
}
/* --- seed content ----------------------------------------------------------*/
function seed() {
const nbId = uid('nb');
state.notebooks = [
{ id: nbId, name: CFG.DEFAULT_NOTEBOOK, createdAt: Date.now() }
];
const ideasId = uid('nb');
state.notebooks.push({ id: ideasId, name: 'Ideas', createdAt: Date.now() });
const now = Date.now();
state.notes = [
{
id: uid('note'), notebookId: nbId, title: 'Welcome to Notary',
html: '<p>Notary is a quiet place to keep your notes. Everything you type is <b>saved as you go</b> — no save button, no ceremony.</p>' +
'<h2>Try a few things</h2>' +
'<ul class="checklist">' +
'<li class="chk" data-checked="true"><span class="chk-box" contenteditable="false"></span><span class="chk-text">Click a checkbox to toggle it</span></li>' +
'<li class="chk" data-checked="false"><span class="chk-box" contenteditable="false"></span><span class="chk-text">Type <code># </code> for a heading, <code>- </code> for a list, <code>[] </code> for a task</span></li>' +
'<li class="chk" data-checked="false"><span class="chk-box" contenteditable="false"></span><span class="chk-text">Drag a note onto a notebook to move it</span></li>' +
'</ul>' +
'<blockquote>Search finds words inside your formatted notes — even the <i>italic</i> ones.</blockquote>' +
'<p>Use <code>⌘B</code>, <code>⌘I</code> and <code>⌘U</code> for quick formatting, or reach for the toolbar above.</p>',
tags: ['getting started'], pinned: true, trashed: false, trashedFrom: null,
createdAt: now, updatedAt: now
},
{
id: uid('note'), notebookId: ideasId, title: 'Reading list',
html: '<p>Books worth the shelf space:</p><ul><li>The Shape of Design — Frank Chimero</li>' +
'<li><i>Thinking in Systems</i> — Donella Meadows</li><li>Several Short Sentences About Writing</li></ul>',
tags: ['reading'], pinned: false, trashed: false, trashedFrom: null,
createdAt: now - 86400000, updatedAt: now - 43200000
}
];
state.ui.selectedNoteId = state.notes[0].id;
}
</script>
<script>
"use strict";
/* ============================================================================
5. MODEL SELECTORS (derived, read-only views of `state`)
========================================================================== */
function getNote(id) { return state.notes.find(n => n.id === id) || null; }
function getNotebook(id) { return state.notebooks.find(nb => nb.id === id) || null; }
function notebookName(id) { const nb = getNotebook(id); return nb ? nb.name : 'Notebook'; }
function allTags() {
const map = new Map();
state.notes.forEach(n => {
if (n.trashed) return;
n.tags.forEach(t => map.set(t, (map.get(t) || 0) + 1));
});
return Array.from(map.entries()).map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
function displayTitle(note) {
if (!note) return 'Untitled';
const t = (note.title || '').trim();
if (t) return t;
const first = htmlToText(note.html).split('\n')[0].trim();
return first ? first.slice(0, 90) : 'Untitled';
}
function noteText(note) { return htmlToText(note.html); }
/* The list currently in view: honours view, tag filter, search and sort. ----*/
function visibleNotes() {
const ui = state.ui;
const q = ui.search.trim().toLowerCase();
let list = state.notes.filter(n => (ui.view === 'trash' ? n.trashed : !n.trashed));
if (ui.view !== 'all' && ui.view !== 'trash') list = list.filter(n => n.notebookId === ui.view);
if (ui.tagFilter) list = list.filter(n => n.tags.indexOf(ui.tagFilter) !== -1);
if (q) {
list = list.filter(n => (displayTitle(n) + '\n' + noteText(n)).toLowerCase().indexOf(q) !== -1);
}
list.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (ui.sort === 'alpha') return displayTitle(a).localeCompare(displayTitle(b), undefined, { sensitivity: 'base' });
if (ui.sort === 'created') return b.createdAt - a.createdAt;
return b.updatedAt - a.updatedAt;
});
return list;
}
/* Snippet + match highlighting (operates on extracted TEXT, never on tags) --*/
function buildSnippet(note, query) {
const text = noteText(note);
if (!text) return '';
const q = (query || '').trim();
let start = 0;
if (q) {
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx > -1) start = Math.max(0, idx - Math.floor(CFG.SNIPPET_LEN / 3));
}
let out = text.slice(start, start + CFG.SNIPPET_LEN);
if (start > 0) out = '…' + out;
if (start + CFG.SNIPPET_LEN < text.length) out += '…';
let html = escapeHtml(out);
if (q) html = html.replace(new RegExp('(' + escapeRegExp(escapeHtml(q)) + ')', 'ig'), '<mark>$1</mark>');
return html;
}
function highlightTitle(title, query) {
const q = (query || '').trim();
if (!q) return escapeHtml(title);
return escapeHtml(title).replace(new RegExp('(' + escapeRegExp(q) + ')', 'ig'), '<mark>$1</mark>');
}
/* ============================================================================
6. SMALL UI PRIMITIVES — menus, modal, toast, save state
========================================================================== */
let openMenuEl = null;
function closeMenu() {
if (openMenuEl) { openMenuEl.remove(); openMenuEl = null; document.removeEventListener('mousedown', onDocDownForMenu, true); document.removeEventListener('keydown', onKeyForMenu, true); }
}
function onDocDownForMenu(e) { if (openMenuEl && !openMenuEl.contains(e.target)) closeMenu(); }
function onKeyForMenu(e) { if (e.key === 'Escape') { e.stopPropagation(); closeMenu(); } }
function showMenu(anchor, items) {
closeMenu();
const menu = document.createElement('div');
menu.className = 'menu';
items.forEach(item => {
if (item.separator) { const s = document.createElement('div'); s.className = 'menu-sep'; menu.appendChild(s); return; }
if (item.label) { const l = document.createElement('div'); l.className = 'menu-label'; l.textContent = item.label; menu.appendChild(l); return; }
const btn = document.createElement('button');
btn.className = 'menu-item' + (item.danger ? ' danger' : '');
btn.innerHTML = '<span class="menu-check">' + (item.checked ? ICONS.check : '') + '</span>' +
'<span class="menu-icon" style="display:grid;place-items:center;color:inherit;opacity:.75">' + (item.icon || '') + '</span>' +
'<span>' + escapeHtml(item.text) + '</span>' +
(item.key ? '<span class="menu-key">' + escapeHtml(item.key) + '</span>' : '');
btn.addEventListener('click', ev => { ev.stopPropagation(); closeMenu(); item.onClick && item.onClick(); });
menu.appendChild(btn);
});
document.body.appendChild(menu);
const r = anchor.getBoundingClientRect();
const mw = menu.offsetWidth, mh = menu.offsetHeight;
let x = r.left, y = r.bottom + 6;
if (x + mw > window.innerWidth - 10) x = Math.max(10, r.right - mw);
if (y + mh > window.innerHeight - 10) y = Math.max(10, r.top - mh - 6);
if (items.some(i => i.alignRight) || false) x = Math.max(10, r.right - mw);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
openMenuEl = menu;
setTimeout(() => {
document.addEventListener('mousedown', onDocDownForMenu, true);
document.addEventListener('keydown', onKeyForMenu, true);
}, 0);
return menu;
}
function askDialog(opts) {
return new Promise(resolve => {
const back = document.createElement('div');
back.className = 'backdrop';
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML =
'<h3>' + escapeHtml(opts.title || '') + '</h3>' +
(opts.message ? '<p>' + escapeHtml(opts.message) + '</p>' : '') +
(opts.input ? '<input class="modal-input" type="text" spellcheck="false" />' : '') +
'<div class="modal-actions">' +
'<button class="btn" data-act="cancel">' + escapeHtml(opts.cancelText || 'Cancel') + '</button>' +
'<button class="btn ' + (opts.danger ? 'danger' : 'primary') + '" data-act="ok">' + escapeHtml(opts.confirmText || 'Create') + '</button>' +
'</div>';
back.appendChild(modal);
document.body.appendChild(back);
const input = $('.modal-input', modal);
if (input) input.value = opts.value || '';
setTimeout(() => { if (input) { input.focus(); input.select(); } else { $('[data-act="ok"]', modal).focus(); } }, 30);
function done(val) { back.remove(); document.removeEventListener('keydown', onKey, true); resolve(val); }
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); done(null); }
else if (e.key === 'Enter' && (opts.input || true)) { e.preventDefault(); done(input ? input.value : true); }
}
$('[data-act="cancel"]', modal).addEventListener('click', () => done(null));
$('[data-act="ok"]', modal).addEventListener('click', () => done(input ? input.value : true));
back.addEventListener('mousedown', e => { if (e.target === back) done(null); });
document.addEventListener('keydown', onKey, true);
});
}
function confirmDialog(title, message, confirmText) {
return askDialog({ title, message, confirmText: confirmText || 'Delete', danger: true }).then(v => !!v);
}
let toastTimer = null;
function toast(message, actionLabel, action) {
$$('.toast').forEach(t => t.remove());
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = '<span>' + escapeHtml(message) + '</span>';
if (actionLabel) {
const b = document.createElement('button');
b.textContent = actionLabel;
b.addEventListener('click', () => { el.remove(); action && action(); });
el.appendChild(b);
}
document.body.appendChild(el);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.remove(), actionLabel ? 6000 : 2400);
}
const setSaveState = (() => {
const el = () => $('#saveStateText');
const icon = () => $('#saveState');
return function (mode) {
const t = el(); if (!t) return;
const wrap = icon();
if (mode === 'saving') { t.textContent = 'Saving…'; wrap && wrap.classList.add('saving'); }
else if (mode === 'saved') { t.textContent = 'Saved'; wrap && wrap.classList.remove('saving'); }
else { t.textContent = 'Not saved'; wrap && wrap.classList.remove('saving'); }
};
})();
/* ============================================================================
7. RENDER — SIDEBAR
========================================================================== */
function renderSidebar() {
const ui = state.ui;
/* All notes + trash counts */
const live = state.notes.filter(n => !n.trashed);
$('#countAll').textContent = live.length;
$('#countTrash').textContent = state.notes.filter(n => n.trashed).length;
$$('.nav-item[data-nav]').forEach(el => {
el.classList.toggle('active', !ui.tagFilter && ui.view === el.getAttribute('data-nav') && !ui.search);
});
/* Notebooks */
const list = $('#notebookList');
list.innerHTML = '';
state.notebooks.forEach(nb => {
const count = live.filter(n => n.notebookId === nb.id).length;
const isOpen = !!ui.expanded[nb.id];
const row = document.createElement('div');
row.className = 'notebook-row';
const caret = document.createElement('button');
caret.className = 'caret' + (isOpen ? ' open' : '');
caret.innerHTML = ICONS.chevron;
caret.title = isOpen ? 'Collapse' : 'Expand';
caret.addEventListener('click', e => {
e.stopPropagation();
ui.expanded[nb.id] = !ui.expanded[nb.id];
renderSidebar(); persist();
});
row.appendChild(caret);
const item = document.createElement('button');
item.className = 'nav-item';
item.setAttribute('data-notebook', nb.id);
item.classList.toggle('active', ui.view === nb.id && !ui.tagFilter && !ui.search);
item.innerHTML = ICONS.notebook + '<span class="nav-label"></span><span class="count">' + count + '</span>' +
'<span class="nav-actions"><span class="mini-btn" data-act="menu" title="Notebook options">' + ICONS.more + '</span></span>';
$('.nav-label', item).textContent = nb.name;
item.addEventListener('click', e => {
if (e.target.closest('[data-act="menu"]')) { notebookMenu(e.target.closest('[data-act="menu"]'), nb); return; }
selectNotebook(nb.id);
});
item.addEventListener('dblclick', () => renameNotebook(nb.id));
/* drag & drop target */
item.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drop-target'); });
item.addEventListener('dragleave', () => item.classList.remove('drop-target'));
item.addEventListener('drop', e => {
e.preventDefault(); item.classList.remove('drop-target');
const id = e.dataTransfer.getData('text/plain');
if (id && getNote(id)) { moveNote(id, nb.id); toast('Moved to “' + nb.name + '”'); }
});
row.appendChild(item);
list.appendChild(row);
if (isOpen) {
const sub = document.createElement('div');
sub.className = 'sub-notes';
const notes = live.filter(n => n.notebookId === nb.id).sort((a, b) => (b.pinned - a.pinned) || (b.updatedAt - a.updatedAt));
if (!notes.length) {
const e = document.createElement('div');
e.className = 'sub-empty'; e.textContent = 'No notes yet';
sub.appendChild(e);
} else {
notes.forEach(n => {
const b = document.createElement('button');
b.className = 'sub-note' + (ui.selectedNoteId === n.id ? ' active' : '');
b.textContent = displayTitle(n);
b.title = displayTitle(n);
b.addEventListener('click', () => { ui.view = nb.id; ui.tagFilter = null; openNote(n.id); });
sub.appendChild(b);
});
}
list.appendChild(sub);
}
});
/* Tags */
const tagList = $('#tagList');
const tags = allTags();
tagList.innerHTML = '';
if (!tags.length) {
const e = document.createElement('div');
e.className = 'empty-hint'; e.textContent = 'No tags yet';
tagList.appendChild(e);
}
tags.forEach(t => {
const b = document.createElement('button');
b.className = 'tag-pill' + (ui.tagFilter === t.name ? ' active' : '');
b.innerHTML = '<span>#' + escapeHtml(t.name) + '</span><span class="tag-n">' + t.count + '</span>';
b.addEventListener('click', () => {
ui.tagFilter = ui.tagFilter === t.name ? null : t.name;
if (ui.view === 'trash') ui.view = 'all';
renderSidebar(); renderList();
});
tagList.appendChild(b);
});
}
function notebookMenu(anchor, nb) {
showMenu(anchor, [
{ text: 'Rename notebook', icon: ICONS.pencil, onClick: () => renameNotebook(nb.id) },
{ text: 'New note here', icon: ICONS.plus, onClick: () => newNote(nb.id) },
{ separator: true },
{
text: 'Delete notebook', icon: ICONS.trash, danger: true, onClick: async () => {
const ok = await confirmDialog('Delete “' + nb.name + '”?',
'Notes in this notebook are moved to Trash. You can restore them later.', 'Delete notebook');
if (ok) deleteNotebook(nb.id);
}
}
]);
}
/* ============================================================================
8. RENDER — NOTE LIST
========================================================================== */
function currentListTitle() {
const ui = state.ui;
if (ui.search.trim()) return 'Search';
if (ui.tagFilter) return '#' + ui.tagFilter;
if (ui.view === 'all') return 'All notes';
if (ui.view === 'trash') return 'Trash';
return notebookName(ui.view);
}
function renderList() {
const ui = state.ui;
const q = ui.search.trim();
const notes = visibleNotes();
$('#listTitle').textContent = currentListTitle();
$('#searchInput').value = ui.search;
$('#searchClear').hidden = !ui.search;
$('#collapseBtn').classList.toggle('collapsed', ui.sidebarCollapsed);
$('#collapseBtn').title = ui.sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar';
const sortLabels = { modified: 'Last modified', created: 'Date created', alpha: 'Alphabetical' };
$('#sortBtn').innerHTML = ICONS.sortAsc + '<span>' + sortLabels[ui.sort] + '</span>';
const noteEl = $('#resultNote');
if (ui.search.trim()) noteEl.textContent = notes.length + (notes.length === 1 ? ' result' : ' results') + ' for “' + q + '”';
else if (ui.view === 'trash') noteEl.textContent = notes.length ? notes.length + ' deleted' : 'Trash is empty';
else noteEl.textContent = notes.length + (notes.length === 1 ? ' note' : ' notes');
const wrap = $('#noteList');
wrap.innerHTML = '';
if (!notes.length) {
const empty = document.createElement('div');
empty.style.cssText = 'padding:34px 16px;text-align:center;color:var(--muted);font-size:12.5px;line-height:1.6';
empty.innerHTML = ui.search.trim()
? '<div style="font-size:22px;margin-bottom:8px;opacity:.5">🔍</div>No notes match “' + escapeHtml(q) + '”'
: (ui.view === 'trash'
? '<div style="font-size:22px;margin-bottom:8px;opacity:.5">🗑️</div>Nothing in the trash'
: '<div style="font-size:22px;margin-bottom:8px;opacity:.5">📝</div>No notes here yet<br><span style="opacity:.75">Press ⌘N to start one</span>');
wrap.appendChild(empty);
return;
}
notes.forEach(note => {
const card = document.createElement('div');
card.className = 'note-card' + (ui.selectedNoteId === note.id ? ' active' : '');
card.setAttribute('data-note', note.id);
card.draggable = true;
const snippet = buildSnippet(note, q);
const tagsHtml = note.tags.slice(0, 3).map(t => '<span class="mini-tag">#' + escapeHtml(t) + '</span>').join('');
card.innerHTML =
'<div class="note-card-top">' +
(note.pinned ? '<span class="pin-badge" title="Pinned">' + ICONS.pin + '</span>' : '') +
'<div class="note-card-title">' + highlightTitle(displayTitle(note), q) + '</div>' +
'</div>' +
'<div class="note-card-snippet">' +
(snippet || '<span style="opacity:.6">Empty note</span>') +
'</div>' +
'<div class="note-card-foot">' +
'<span class="note-card-time">' +
(ui.view === 'trash' ? 'Deleted ' + formatStamp(note.updatedAt) : formatStamp(note.updatedAt)) +
'</span>' +
'<span class="note-card-tags">' + tagsHtml + '</span>' +
'</div>' +
'<button class="card-menu" title="Options">' + ICONS.more + '</button>';
card.addEventListener('click', e => {
if (e.target.closest('.card-menu')) { noteCardMenu(e.target.closest('.card-menu'), note); return; }
openNote(note.id);
});
card.addEventListener('dragstart', e => {
card.classList.add('dragging');
e.dataTransfer.setData('text/plain', note.id);
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
wrap.appendChild(card);
});
}
function noteCardMenu(anchor, note) {
const items = [];
if (note.trashed) {
items.push({ text: 'Restore note', icon: ICONS.restore, onClick: () => restoreNote(note.id) });
items.push({ separator: true });
items.push({
text: 'Delete forever', icon: ICONS.trash, danger: true, onClick: async () => {
const ok = await confirmDialog('Delete forever?', '“' + displayTitle(note) + '” will be permanently removed. This cannot be undone.', 'Delete forever');
if (ok) deleteForever(note.id);
}
});
} else {
items.push({ text: note.pinned ? 'Unpin' : 'Pin to top', icon: ICONS.pin, onClick: () => togglePin(note.id) });
items.push({ text: 'Duplicate', icon: ICONS.copy, onClick: () => duplicateNote(note.id) });
items.push({ text: 'Rename', icon: ICONS.pencil, onClick: () => renameNote(note.id) });
items.push({ separator: true });
items.push({ label: 'Move to' });
state.notebooks.forEach(nb => items.push({
text: nb.name, checked: nb.id === note.notebookId,
onClick: () => { moveNote(note.id, nb.id); toast('Moved to “' + nb.name + '”'); }
}));
items.push({ separator: true });
items.push({ text: 'Move to trash', icon: ICONS.trash, danger: true, onClick: () => trashNote(note.id) });
}
showMenu(anchor, items);
}
/* ============================================================================
9. RENDER — EDITOR CHROME
========================================================================== */
let renderedNoteId = null;
function renderEditor() {
const note = getNote(state.ui.selectedNoteId);
const wrap = $('#editorWrap');
const empty = $('#editorEmpty');
if (!note) {
commitEditorToModel();
wrap.hidden = true;
empty.hidden = false;
renderedNoteId = null;
return;
}
wrap.hidden = false;
empty.hidden = true;
if (renderedNoteId !== note.id) {
commitEditorToModel(); /* outgoing note keeps the caret's work */
renderedNoteId = note.id;
$('#titleInput').value = note.title;
$('#editor').innerHTML = note.html || '';
const h = hist.get(note.id);
if (h) h.last = note.html; else hist.set(note.id, { undo: [], redo: [], last: note.html || '', timer: null });
}
renderEditorMeta();
updateWordCount();
refreshToolbar();
}
function renderEditorMeta() {
const note = getNote(state.ui.selectedNoteId);
if (!note) return;
$('#notebookChip').innerHTML = ICONS.folder + '<span>' + escapeHtml(notebookName(note.notebookId)) + '</span>';
const chips = $('#tagChips');
chips.innerHTML = '';
note.tags.forEach(t => {
const c = document.createElement('span');
c.className = 'tag-chip';
c.innerHTML = '<span>#' + escapeHtml(t) + '</span><button title="Remove tag" aria-label="Remove tag">×</button>';
$('button', c).addEventListener('click', e => { e.stopPropagation(); removeTag(note.id, t); });
chips.appendChild(c);
});
$('#footDate').textContent = 'Edited ' + formatFull(note.updatedAt);
$('#trashBar').hidden = !note.trashed;
$('#noteMenuBtn').title = note.pinned ? 'Pinned — note options' : 'Note options';
}
function updateWordCount() {
const text = htmlToText($('#editor').innerHTML);
const words = text ? text.split(/\s+/).filter(Boolean).length : 0;
const chars = text.length;
$('#wordCount').textContent = words + (words === 1 ? ' word' : ' words') + ' · ' + chars + ' characters';
}
</script>
<script>
"use strict";
/* ============================================================================
10. MODEL MUTATIONS
========================================================================== */
function createNotebook(name) {
const nb = { id: uid('nb'), name: (name || 'Notebook').trim() || 'Notebook', createdAt: Date.now() };
state.notebooks.push(nb);
state.ui.expanded[nb.id] = true;
persist();
return nb;
}
async function renameNotebook(id) {
const nb = getNotebook(id); if (!nb) return;
const name = await askDialog({ title: 'Rename notebook', input: true, value: nb.name, confirmText: 'Rename' });
if (name == null) return;
const clean = String(name).trim();
if (!clean) return;
nb.name = clean;
persist(); renderSidebar(); renderList(); renderEditorMeta();
}
async function deleteNotebook(id) {
const nb = getNotebook(id); if (!nb) return;
const notes = state.notes.filter(n => n.notebookId === id && !n.trashed);
notes.forEach(n => { n.trashed = true; n.trashedFrom = n.notebookId; n.updatedAt = Date.now(); });
state.notebooks = state.notebooks.filter(n => n.id !== id);
if (!state.notebooks.length) state.notebooks.push({ id: uid('nb'), name: CFG.DEFAULT_NOTEBOOK, createdAt: Date.now() });
if (state.ui.view === id) state.ui.view = 'all';
ensureSelection();
persist(); renderAll();
if (notes.length) toast(notes.length + ' note' + (notes.length > 1 ? 's' : '') + ' moved to Trash');
}
function newNote(notebookId) {
let nbId = notebookId;
if (!nbId && state.ui.view !== 'all' && state.ui.view !== 'trash') nbId = state.ui.view;
if (!nbId) nbId = (state.notebooks[0] || createNotebook(CFG.DEFAULT_NOTEBOOK)).id;
const now = Date.now();
const note = {
id: uid('note'), notebookId: nbId, title: '', html: '<p><br></p>', tags: [],
pinned: false, trashed: false, trashedFrom: null, createdAt: now, updatedAt: now
};
state.notes.unshift(note);
if (state.ui.view !== 'all') state.ui.view = nbId;
state.ui.tagFilter = null;
state.ui.search = '';
state.ui.selectedNoteId = note.id;
getHist(note.id).last = note.html;
saveNow(); renderAll();
setTimeout(() => { const t = $('#titleInput'); if (t) { t.focus(); t.select(); } }, 40);
return note;
}
function openNote(id) {
const note = getNote(id); if (!note) return;
if (renderedNoteId && renderedNoteId !== id) flushHistory(renderedNoteId);
if (note.trashed !== (state.ui.view === 'trash') && !state.ui.search.trim()) {
if (note.trashed) state.ui.view = 'trash'; else if (state.ui.view === 'trash') state.ui.view = 'all';
}
state.ui.selectedNoteId = id;
commitEditorToModel();
renderAll();
}
function selectNotebook(id) {
state.ui.view = id;
state.ui.tagFilter = null;
state.ui.search = '';
ensureSelection();
renderAll();
}
function trashNote(id) {
const note = getNote(id); if (!note || note.trashed) return;
flushHistory(id);
note.trashed = true;
note.trashedFrom = note.notebookId;
note.updatedAt = Date.now();
if (state.ui.selectedNoteId === id) state.ui.selectedNoteId = null;
ensureSelection();
persist(); renderAll();
toast('Note moved to Trash', 'Undo', () => restoreNote(id));
}
function restoreNote(id) {
const note = getNote(id); if (!note) return;
note.trashed = false;
if (!getNotebook(note.notebookId)) {
note.notebookId = (note.trashedFrom && getNotebook(note.trashedFrom)) ? note.trashedFrom
: (state.notebooks[0] && state.notebooks[0].id);
}
note.updatedAt = Date.now();
if (state.ui.view === 'trash') { state.ui.selectedNoteId = null; ensureSelection(); }
persist(); renderAll();
toast('Restored to “' + notebookName(note.notebookId) + '”');
}
async function deleteForever(id) {
const note = getNote(id); if (!note) return;
state.notes = state.notes.filter(n => n.id !== id);
hist.delete(id);
if (state.ui.selectedNoteId === id) { state.ui.selectedNoteId = null; ensureSelection(); }
persist(); renderAll();
}
function togglePin(id) {
const note = getNote(id); if (!note) return;
note.pinned = !note.pinned;
persist(); renderList(); renderSidebar(); renderEditorMeta();
toast(note.pinned ? 'Pinned to top' : 'Unpinned');
}
function duplicateNote(id) {
const note = getNote(id); if (!note) return;
const copy = JSON.parse(JSON.stringify(note));
copy.id = uid('note');
copy.title = displayTitle(note) + ' copy';
copy.pinned = false;
copy.trashed = false;
copy.trashedFrom = null;
copy.createdAt = copy.updatedAt = Date.now();
state.notes.unshift(copy);
state.ui.selectedNoteId = copy.id;
state.ui.search = '';
if (state.ui.view === 'trash') state.ui.view = copy.notebookId;
persist(); renderAll();
toast('Note duplicated');
}
function moveNote(id, notebookId) {
const note = getNote(id); if (!note || !getNotebook(notebookId)) return;
note.notebookId = notebookId;
note.updatedAt = Date.now();
if (state.ui.view !== 'all' && state.ui.view !== 'trash' && state.ui.view !== notebookId) {
state.ui.selectedNoteId = null; ensureSelection();
}
persist(); renderAll();
}
async function renameNote(id) {
const note = getNote(id); if (!note) return;
const name = await askDialog({ title: 'Rename note', input: true, value: note.title || displayTitle(note), confirmText: 'Rename' });
if (name == null) return;
note.title = String(name).trim();
note.updatedAt = Date.now();
if (state.ui.selectedNoteId === id) { $('#titleInput').value = note.title; }
persist(); renderAll();
}
function addTag(id, raw) {
const note = getNote(id); if (!note) return;
const tag = String(raw || '').trim().replace(/^#+/, '').replace(/\s+/g, '-').toLowerCase().slice(0, 32);
if (!tag || note.tags.indexOf(tag) !== -1) { renderEditorMeta(); return; }
note.tags.push(tag);
note.updatedAt = Date.now();
persist(); renderEditorMeta(); renderSidebar(); renderList();
}
function removeTag(id, tag) {
const note = getNote(id); if (!note) return;
note.tags = note.tags.filter(t => t !== tag);
note.updatedAt = Date.now();
if (state.ui.tagFilter === tag && !state.notes.some(n => !n.trashed && n.tags.indexOf(tag) !== -1)) state.ui.tagFilter = null;
persist(); renderEditorMeta(); renderSidebar(); renderList();
}
function commitEditorToModel() {
const id = renderedNoteId;
const note = getNote(id);
const e = ed();
if (!note || !e || $('#editorWrap').hidden) return;
if (note.html !== e.innerHTML) {
note.html = e.innerHTML;
note.updatedAt = Date.now();
persist();
}
}
/* ============================================================================
11. UNDO / REDO — snapshot history per note
========================================================================== */
const hist = new Map();
function getHist(id) {
if (!hist.has(id)) {
const note = getNote(id);
hist.set(id, { undo: [], redo: [], last: note ? note.html : '', timer: null });
}
return hist.get(id);
}
function commitHistory(id) {
const h = hist.get(id); if (!h) return;
clearTimeout(h.timer); h.timer = null;
const note = getNote(id); if (!note) return;
if (note.html !== h.last) {
h.undo.push(h.last);
if (h.undo.length > CFG.HISTORY_LIMIT) h.undo.shift();
h.redo.length = 0;
h.last = note.html;
}
}
function recordHistory(id) {
const h = getHist(id);
clearTimeout(h.timer);
h.timer = setTimeout(() => commitHistory(id), CFG.HISTORY_COMMIT_MS);
}
function flushHistory(id) { const h = hist.get(id); if (h && h.timer) commitHistory(id); }
function undo() {
const id = state.ui.selectedNoteId; if (!getNote(id)) return;
commitHistory(id);
const h = getHist(id);
if (!h.undo.length) { toast('Nothing to undo'); return; }
h.redo.push(h.last);
h.last = h.undo.pop();
applyHtml(h.last);
}
function redo() {
const id = state.ui.selectedNoteId; if (!getNote(id)) return;
const h = getHist(id);
if (!h.redo.length) return;
h.undo.push(h.last);
h.last = h.redo.pop();
applyHtml(h.last);
}
function applyHtml(html) {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
const ed = $('#editor');
ed.innerHTML = html;
note.html = html;
note.updatedAt = Date.now();
caretToEnd(ed);
ed.focus();
setSaveState('saving');
persist();
updateWordCount(); renderEditorMeta(); refreshToolbar(); scheduleListRefresh();
}
/* ============================================================================
12. EDITOR — DOM / CARET HELPERS
========================================================================== */
function ed() { return $('#editor'); }
function inEditor(node) { const e = ed(); return !!(node && (node === e || e.contains(node))); }
function closestTag(node, tagName) {
const e = ed();
if (!inEditor(node)) return null;
let n = node && node.nodeType === 3 ? node.parentNode : node;
while (n && n !== e) {
if (n.nodeType === 1 && n.tagName === tagName) return n;
n = n.parentNode;
}
return null;
}
function closestIn(node, test) {
const e = ed();
if (!inEditor(node)) return null;
let n = node && node.nodeType === 3 ? node.parentNode : node;
while (n && n !== e) {
if (n.nodeType === 1 && test(n)) return n;
n = n.parentNode;
}
return null;
}
const BLOCK_RE = /^(P|DIV|H1|H2|H3|LI|BLOCKQUOTE)$/;
function lineBlockFor(node) {
const e = ed();
if (!inEditor(node)) return null;
let n = node && node.nodeType === 3 ? node.parentNode : node;
if (n === e) return null;
while (n && n !== e) {
if (n.nodeType === 1 && BLOCK_RE.test(n.tagName)) return n;
n = n.parentNode;
}
return null;
}
function currentBlockEl() {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
return lineBlockFor(sel.anchorNode);
}
function caretToStart(el) {
const r = document.createRange();
r.setStart(el, 0); r.collapse(true);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
}
function caretToEnd(el) {
const r = document.createRange();
r.selectNodeContents(el); r.collapse(false);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
}
function linePrefix(node, offset) {
if (!node || node.nodeType !== 3) return { text: '', block: null };
const block = lineBlockFor(node);
if (block) {
let out = '';
const w = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let t;
while ((t = w.nextNode())) {
if (t === node) { out += t.data.slice(0, offset); return { text: out, block: block }; }
out += t.data;
}
return { text: out, block: block };
}
return { text: node.data.slice(0, offset), block: null };
}
/* Paste: sanitised content is spliced at the caret. Block-level pastes split the
current paragraph instead of nesting blocks inside it. */
function insertPastedHtml(html) {
const sel = window.getSelection();
const e = ed();
if (!sel || !sel.rangeCount || !inEditor(sel.anchorNode)) { e.insertAdjacentHTML('beforeend', html); return; }
const range = sel.getRangeAt(0);
const block = lineBlockFor(range.startContainer);
const temp = document.createElement('div');
temp.innerHTML = html;
const nodes = Array.prototype.slice.call(temp.childNodes);
const hasBlocks = nodes.some(n => n.nodeType === 1 && /^(P|DIV|H1|H2|H3|UL|OL|LI|BLOCKQUOTE|HR)$/.test(n.tagName));
if (!block || !hasBlocks) {
range.deleteContents();
const frag = document.createDocumentFragment();
nodes.forEach(n => frag.appendChild(n));
if (!frag.childNodes.length) return;
const last = frag.lastChild;
range.insertNode(frag);
const r = document.createRange();
r.setStartAfter(last); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
return;
}
/* keep whatever sat after the caret and push it below the pasted blocks */
const tail = range.cloneRange();
tail.setEnd(block, block.childNodes.length);
const tailFrag = tail.extractContents();
let ref = block;
nodes.forEach(n => { ref.after(n); ref = n; });
const tailText = (tailFrag.textContent || '').replace(/[\s\u200b]/g, '');
if (tailText || tailFrag.querySelector('*')) {
const holder = document.createElement('p');
holder.appendChild(tailFrag);
ref.after(holder);
caretToStart(holder);
} else {
const r = document.createRange();
r.setStartAfter(ref); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
}
}
function insertHtmlAtCaret(html) {
const sel = window.getSelection();
const e = ed();
if (!sel || !sel.rangeCount || !inEditor(sel.anchorNode)) {
e.insertAdjacentHTML('beforeend', html);
return;
}
const range = sel.getRangeAt(0);
range.deleteContents();
const temp = document.createElement('div');
temp.innerHTML = html;
const frag = document.createDocumentFragment();
while (temp.firstChild) frag.appendChild(temp.firstChild);
if (!frag.childNodes.length) return;
const last = frag.lastChild;
range.insertNode(frag);
const r = document.createRange();
r.setStartAfter(last); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
}
/* ============================================================================
13. EDITOR — INLINE / BLOCK FORMATTING
========================================================================== */
function pushEditorChange() {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
note.html = ed().innerHTML;
note.updatedAt = Date.now();
recordHistory(note.id);
setSaveState('saving');
persist();
updateWordCount();
renderEditorMeta();
refreshToolbar();
scheduleListRefresh();
}
const scheduleListRefresh = debounce(() => { renderList(); renderSidebar(); }, 250);
function toggleInline(cmd) {
try {
document.execCommand('styleWithCSS', false, false);
document.execCommand(cmd, false, null);
} catch (err) { /* no-op */ }
pushEditorChange();
}
function toggleInlineCode() {
const sel = window.getSelection();
if (!sel || !sel.rangeCount || !inEditor(sel.anchorNode)) return;
const existing = closestTag(sel.anchorNode, 'CODE');
if (existing) {
const parent = existing.parentNode;
while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
existing.remove();
} else {
const range = sel.getRangeAt(0);
if (range.collapsed) return;
const code = document.createElement('code');
try { range.surroundContents(code); }
catch (err) { code.appendChild(range.extractContents()); range.insertNode(code); }
caretToEnd(code);
}
pushEditorChange();
}
function setBlock(tag) {
const cur = currentBlockEl();
if (cur && cur.tagName === tag.toUpperCase()) {
document.execCommand('formatBlock', false, 'P');
} else {
document.execCommand('formatBlock', false, '<' + tag.toUpperCase() + '>');
}
pushEditorChange();
}
function toggleListCmd(kind) {
const cmd = kind === 'ul' ? 'insertUnorderedList' : 'insertOrderedList';
try { document.execCommand(cmd, false, null); } catch (e) { /* no-op */ }
pushEditorChange();
}
function makeChkItem(text) {
const li = document.createElement('li');
li.className = 'chk';
li.setAttribute('data-checked', 'false');
const box = document.createElement('span');
box.className = 'chk-box';
box.setAttribute('contenteditable', 'false');
const span = document.createElement('span');
span.className = 'chk-text';
span.textContent = '\u200b';
if (text) span.textContent = text;
li.appendChild(box);
li.appendChild(span);
return li;
}
function chkTextOf(li) { return $('.chk-text', li) || li; }
function moveBlockIntoChk(li, block) {
const span = chkTextOf(li);
let moved = false;
while (block.firstChild) {
const c = block.firstChild;
if (c.nodeType === 3 && c.nodeValue === '') { c.remove(); continue; }
span.appendChild(c);
moved = true;
}
/* Chrome purges inline elements that hold nothing but empty text nodes, so the
wrapper must always carry at least one (invisible) character. */
if (!moved) span.textContent = '\u200b';
return span;
}
function convertBlockToList(block, tag) {
const li = document.createElement('li');
while (block.firstChild) li.appendChild(block.firstChild);
if (!li.firstChild) li.appendChild(document.createElement('br'));
const list = document.createElement(tag);
list.appendChild(li);
block.replaceWith(list);
caretToEnd(li);
}
function convertBlockToChecklist(block) {
const li = makeChkItem('');
moveBlockIntoChk(li, block);
const ul = document.createElement('ul');
ul.className = 'checklist';
ul.appendChild(li);
block.replaceWith(ul);
caretToEnd(chkTextOf(li));
}
function unwrapChkItem(li) {
const list = li.parentElement;
const p = document.createElement('div');
while (li.firstChild) {
const c = li.firstChild;
if (c.nodeType === 1 && c.classList && c.classList.contains('chk-box')) { c.remove(); continue; }
if (c.nodeType === 1 && c.classList && c.classList.contains('chk-text')) {
while (c.firstChild) p.appendChild(c.firstChild);
c.remove();
continue;
}
p.appendChild(c);
}
li.remove();
if (!list.querySelector('li')) list.replaceWith(p); else list.after(p);
caretToEnd(p);
}
function toggleChecklist() {
const sel = window.getSelection();
if (!sel || !sel.rangeCount || !inEditor(sel.anchorNode)) return;
const li = closestTag(sel.anchorNode, 'LI');
if (li && li.classList.contains('chk')) { unwrapChkItem(li); pushEditorChange(); return; }
const block = currentBlockEl();
if (!block) return;
if (block.tagName === 'LI') {
const ul = document.createElement('ul');
ul.className = 'checklist';
const nli = makeChkItem('');
moveBlockIntoChk(nli, block);
ul.appendChild(nli);
block.replaceWith(ul);
caretToEnd(chkTextOf(nli));
} else {
convertBlockToChecklist(block);
}
pushEditorChange();
}
function insertDivider() {
const before = currentBlockEl();
try { document.execCommand('insertHorizontalRule', false, null); } catch (e) { /* no-op */ }
const e = ed();
const hrs = $$('hr', e);
const hr = hrs[hrs.length - 1];
if (hr && !hr.nextElementSibling) {
const p = document.createElement('p');
p.innerHTML = '<br>';
hr.after(p);
caretToStart(p);
}
pushEditorChange();
}
/* Markdown-style input shortcuts --------------------------------------------*/
const MD_PATTERNS = [
[/^###$/, 'h3'], [/^##$/, 'h2'], [/^#$/, 'h1'],
[/^[-*+]$/, 'ul'], [/^\[\]$/, 'checklist'], [/^\[ ?\]$/, 'checklist'],
[/^>$/, 'blockquote']
];
function detectShortcut() {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
const r = sel.getRangeAt(0);
if (!r.collapsed) return null;
const ctx = linePrefix(r.startContainer, r.startOffset);
if (ctx.text == null) return null;
for (let i = 0; i < MD_PATTERNS.length; i++) {
if (MD_PATTERNS[i][0].test(ctx.text)) {
return { kind: MD_PATTERNS[i][1], block: ctx.block, node: r.startContainer, offset: r.startOffset };
}
}
return null;
}
function applyShortcut(sc) {
const del = document.createRange();
del.setStart(sc.node, 0);
del.setEnd(sc.node, sc.offset);
del.deleteContents();
let target = sc.block;
if (!target) {
const p = document.createElement('div');
sc.node.parentNode.insertBefore(p, sc.node);
p.appendChild(sc.node);
target = p;
}
if (target.tagName === 'LI') {
if (sc.kind === 'ul' || sc.kind === 'checklist') { pushEditorChange(); return; }
const li = target;
const wrap = document.createElement('div');
while (li.firstChild) wrap.appendChild(li.firstChild);
li.replaceWith(wrap);
target = wrap;
}
if (sc.kind === 'ul') convertBlockToList(target, 'ul');
else if (sc.kind === 'checklist') convertBlockToChecklist(target);
else if (sc.kind === 'blockquote') setBlockTag(target, 'blockquote');
else setBlockTag(target, sc.kind);
pushEditorChange();
}
function setBlockTag(block, tag) {
if (block.tagName === tag.toUpperCase()) return;
const el = document.createElement(tag);
while (block.firstChild) el.appendChild(block.firstChild);
block.replaceWith(el);
caretToEnd(el);
}
/* Enter / list continuation --------------------------------------------------*/
function handleEnter(e) {
if (e.shiftKey) return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount || !inEditor(sel.anchorNode)) return;
const li = closestTag(sel.anchorNode, 'LI');
if (!li) return;
const isChk = li.classList.contains('chk');
const text = li.textContent.replace(/\u200b/g, '').trim();
const list = li.parentElement;
if (!isChk) {
if (text) return; // let the browser continue the plain list
}
e.preventDefault();
if (text) {
const nli = makeChkItem('');
li.after(nli);
caretToEnd(chkTextOf(nli));
pushEditorChange();
return;
}
/* empty item -> leave the list and start a paragraph */
const p = document.createElement('div');
p.innerHTML = '<br>';
const siblings = Array.prototype.slice.call(list.children).filter(c => c !== li);
const after = [];
let n = li.nextElementSibling;
while (n) { after.push(n); n = n.nextElementSibling; }
if (!siblings.length) {
list.replaceWith(p);
} else if (!after.length) {
list.after(p);
li.remove();
} else {
const newList = document.createElement(list.tagName.toLowerCase());
if (list.className) newList.className = list.className;
after.forEach(a => newList.appendChild(a));
list.after(p);
p.after(newList);
li.remove();
}
caretToStart(p);
pushEditorChange();
}
/* Toolbar state --------------------------------------------------------------*/
let tbRaf = null;
function refreshToolbar() {
if (tbRaf) return;
tbRaf = requestAnimationFrame(() => {
tbRaf = null;
const sel = window.getSelection();
const anchor = sel && sel.rangeCount ? sel.anchorNode : null;
const qs = cmd => { try { return document.queryCommandState(cmd); } catch (e) { return false; } };
const block = currentBlockEl();
const li = closestTag(anchor, 'LI');
$$('#toolbar .tb').forEach(btn => {
const cmd = btn.getAttribute('data-cmd');
let active = false;
if (cmd === 'bold' || cmd === 'italic' || cmd === 'underline' || cmd === 'strikeThrough') active = qs(cmd);
else if (cmd === 'code') active = !!closestTag(anchor, 'CODE');
else if (cmd === 'ul') active = qs('insertUnorderedList') || !!(li && li.parentElement.tagName === 'UL' && !li.classList.contains('chk'));
else if (cmd === 'ol') active = qs('insertOrderedList') || !!(li && li.parentElement.tagName === 'OL');
else if (cmd === 'checklist') active = !!(li && li.classList.contains('chk'));
else if (cmd === 'h1' || cmd === 'h2' || cmd === 'h3') active = !!(block && block.tagName === cmd.toUpperCase());
else if (cmd === 'blockquote') active = !!(block && block.tagName === 'BLOCKQUOTE');
btn.classList.toggle('active', active);
});
const h = hist.get(state.ui.selectedNoteId);
const u = $('#toolbar .tb[data-cmd="undo"]'), r = $('#toolbar .tb[data-cmd="redo"]');
if (u) u.disabled = !(h && h.undo.length);
if (r) r.disabled = !(h && h.redo.length);
});
}
/* ============================================================================
14. EVENTS
========================================================================== */
function ensureSelection() {
const sel = getNote(state.ui.selectedNoteId);
const wantTrash = state.ui.view === 'trash';
if (sel && (!!sel.trashed === wantTrash)) return;
const list = visibleNotes();
state.ui.selectedNoteId = list.length ? list[0].id : null;
}
/* At boot we keep an explicit "nothing selected" so the empty state can show. */
function validateSelection() {
const sel = getNote(state.ui.selectedNoteId);
const wantTrash = state.ui.view === 'trash';
if (!sel || (!!sel.trashed !== wantTrash)) state.ui.selectedNoteId = null;
}
function renderAll() { renderSidebar(); renderList(); renderEditor(); }
function bindEvents() {
const tool = $('#toolbar');
tool.addEventListener('mousedown', e => { if (e.target.closest('.tb')) e.preventDefault(); });
tool.addEventListener('click', e => {
const btn = e.target.closest('.tb'); if (!btn) return;
const cmd = btn.getAttribute('data-cmd');
ed().focus();
if (cmd === 'bold' || cmd === 'italic' || cmd === 'underline' || cmd === 'strikeThrough') toggleInline(cmd);
else if (cmd === 'code') toggleInlineCode();
else if (cmd === 'h1' || cmd === 'h2' || cmd === 'h3') setBlock(cmd);
else if (cmd === 'ul' || cmd === 'ol') toggleListCmd(cmd);
else if (cmd === 'checklist') toggleChecklist();
else if (cmd === 'blockquote') setBlock('blockquote');
else if (cmd === 'hr') insertDivider();
else if (cmd === 'undo') undo();
else if (cmd === 'redo') redo();
});
const e = ed();
e.addEventListener('input', pushEditorChange);
e.addEventListener('keydown', e => {
const mod = e.metaKey || e.ctrlKey;
if (mod && !e.altKey) {
const k = (e.key || '').toLowerCase();
if (k === 'b' || k === 'i' || k === 'u') { e.preventDefault(); toggleInline(k === 'b' ? 'bold' : k === 'i' ? 'italic' : 'underline'); return; }
if (k === 'e') { e.preventDefault(); toggleInlineCode(); return; }
if (k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (k === 'y') { e.preventDefault(); redo(); return; }
}
if (e.key === ' ' && !e.shiftKey) {
const sc = detectShortcut();
if (sc) { e.preventDefault(); applyShortcut(sc); return; }
}
if (e.key === 'Enter') { handleEnter(e); return; }
if (e.key === 'Tab') { e.preventDefault(); try { document.execCommand(e.shiftKey ? 'outdent' : 'indent', false, null); } catch (err) { } pushEditorChange(); return; }
});
e.addEventListener('keyup', refreshToolbar);
e.addEventListener('mouseup', refreshToolbar);
e.addEventListener('mousedown', e => {
const box = e.target.closest ? e.target.closest('.chk-box') : null;
if (!box) return;
e.preventDefault();
const li = box.closest('li.chk');
if (!li) return;
li.setAttribute('data-checked', li.getAttribute('data-checked') === 'true' ? 'false' : 'true');
if (!inEditor(document.activeElement)) ed().focus();
pushEditorChange();
});
e.addEventListener('paste', ev => {
ev.preventDefault();
let html = '', text = '';
try {
const cd = ev.clipboardData || window.clipboardData;
if (cd) { html = cd.getData('text/html') || ''; text = cd.getData('text/plain') || ''; }
} catch (err) { /* ignore */ }
const clean = html ? sanitizeHtml(html) : textToHtml(text);
insertPastedHtml(clean);
pushEditorChange();
});
e.addEventListener('drop', ev => { /* let the browser handle in-editor drops */ });
document.addEventListener('selectionchange', () => { if (inEditor(document.activeElement) || (window.getSelection() && inEditor(window.getSelection().anchorNode))) refreshToolbar(); });
$('#editorScroll').addEventListener('mousedown', ev => {
if (ev.target !== $('#editorScroll')) return;
ev.preventDefault();
ed().focus();
caretToEnd(ed());
});
/* title */
const title = $('#titleInput');
title.addEventListener('input', () => {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
note.title = title.value;
note.updatedAt = Date.now();
setSaveState('saving'); persist();
scheduleListRefresh();
});
title.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); ed().focus(); caretToEnd(ed()); }
else if (ev.key === 'Escape') title.blur();
});
/* search + sort */
const search = $('#searchInput');
search.addEventListener('input', () => {
state.ui.search = search.value;
state.ui.tagFilter = null;
if (search.value.trim() && state.ui.view === 'trash') state.ui.view = 'all';
persist(); renderList(); renderSidebar();
});
search.addEventListener('keydown', ev => { if (ev.key === 'Escape') { search.value = ''; state.ui.search = ''; search.blur(); renderList(); renderSidebar(); } });
$('#searchClear').addEventListener('click', () => { search.value = ''; state.ui.search = ''; search.focus(); persist(); renderList(); renderSidebar(); });
$('#sortBtn').addEventListener('click', ev => {
const items = Object.keys(CFG.SORT).map(k => ({
text: CFG.SORT[k].label, checked: state.ui.sort === k,
onClick: () => { state.ui.sort = k; persist(); renderList(); }
}));
showMenu(ev.currentTarget, items);
});
/* sidebar nav */
$$('.nav-item[data-nav]').forEach(el => el.addEventListener('click', () => {
state.ui.view = el.getAttribute('data-nav');
state.ui.tagFilter = null;
state.ui.search = '';
ensureSelection(); persist(); renderAll();
}));
$('#newNoteBtn').addEventListener('click', () => newNote());
$('#listNewBtn').addEventListener('click', () => newNote());
$('#emptyNewBtn').addEventListener('click', () => newNote());
$('#newNotebookBtn').addEventListener('click', async () => {
const name = await askDialog({ title: 'New notebook', input: true, value: '', confirmText: 'Create' });
if (name == null) return;
createNotebook(name);
renderSidebar();
});
$('#collapseBtn').addEventListener('click', () => {
state.ui.sidebarCollapsed = !state.ui.sidebarCollapsed;
$('#sidebar').classList.toggle('collapsed', state.ui.sidebarCollapsed);
$('#collapseBtn').classList.toggle('collapsed', state.ui.sidebarCollapsed);
$('#collapseBtn').title = state.ui.sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar';
persist();
});
$('#themeToggle').addEventListener('click', () => {
state.ui.theme = state.ui.theme === 'dark' ? 'light' : 'dark';
applyTheme(); persist();
});
/* editor meta */
$('#notebookChip').addEventListener('click', ev => {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
showMenu(ev.currentTarget, state.notebooks.map(nb => ({
text: nb.name, checked: nb.id === note.notebookId,
onClick: () => { moveNote(note.id, nb.id); toast('Moved to “' + nb.name + '”'); }
})));
});
$('#addTagBtn').addEventListener('click', async () => {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
const tag = await askDialog({ title: 'Add a tag', input: true, value: '', confirmText: 'Add tag' });
if (tag == null) return;
addTag(note.id, tag);
});
$('#noteMenuBtn').addEventListener('click', ev => {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
noteCardMenu(ev.currentTarget, note);
});
$('#restoreBtn').addEventListener('click', () => {
const note = getNote(state.ui.selectedNoteId); if (note) restoreNote(note.id);
});
$('#purgeBtn').addEventListener('click', async () => {
const note = getNote(state.ui.selectedNoteId); if (!note) return;
const ok = await confirmDialog('Delete forever?', '“' + displayTitle(note) + '” will be permanently removed. This cannot be undone.', 'Delete forever');
if (ok) deleteForever(note.id);
});
/* resizers */
setupResizer($('#rz1'), 'sidebar');
setupResizer($('#rz2'), 'list');
/* global keys */
document.addEventListener('keydown', ev => {
const mod = ev.metaKey || ev.ctrlKey;
if (!mod) return;
const k = (ev.key || '').toLowerCase();
if (k === 'n') { ev.preventDefault(); newNote(); }
else if (k === 'f') { ev.preventDefault(); $('#searchInput').focus(); $('#searchInput').select(); }
else if (k === 's') { ev.preventDefault(); saveNow(); toast('Saved'); }
});
window.addEventListener('resize', () => {
const app = $('#app');
const maxList = window.innerWidth - state.ui.sidebarW - CFG.MIN_EDITOR_W;
if (state.ui.listW > maxList) { state.ui.listW = Math.max(CFG.MIN_LIST_W, maxList); app.style.setProperty('--list-w', state.ui.listW + 'px'); }
});
window.addEventListener('beforeunload', () => { commitEditorToModel(); saveNow(); });
}
function setupResizer(el, which) {
el.addEventListener('mousedown', ev => {
ev.preventDefault();
const startX = ev.clientX;
const startW = which === 'sidebar' ? state.ui.sidebarW : state.ui.listW;
const app = $('#app');
el.classList.add('dragging');
document.body.classList.add('resizing');
function move(e2) {
const d = e2.clientX - startX;
if (which === 'sidebar') {
const w = Math.max(CFG.MIN_SIDEBAR_W, Math.min(CFG.MAX_SIDEBAR_W, startW + d));
setSidebarWidth(w);
} else {
const maxByEditor = window.innerWidth - state.ui.sidebarW - CFG.MIN_EDITOR_W;
const w = Math.max(CFG.MIN_LIST_W, Math.min(CFG.MAX_LIST_W, Math.min(startW + d, Math.max(CFG.MIN_LIST_W, maxByEditor))));
state.ui.listW = w;
app.style.setProperty('--list-w', w + 'px');
}
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
el.classList.remove('dragging');
document.body.classList.remove('resizing');
persist();
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
}
function setSidebarWidth(w) {
state.ui.sidebarW = w;
$('#app').style.setProperty('--sidebar-w', w + 'px');
$('#sidebarInner').style.minWidth = w + 'px';
}
function applyTheme() {
document.documentElement.setAttribute('data-theme', state.ui.theme === 'dark' ? 'dark' : 'light');
$('#themeToggle').innerHTML = state.ui.theme === 'dark' ? ICONS.sun : ICONS.moon;
$('#themeToggle').title = state.ui.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
}
/* ============================================================================
15. INIT
========================================================================== */
function init() {
loadState();
applyTheme();
applyLayout();
if (window.innerWidth < CFG.COLLAPSE_AT) state.ui.sidebarCollapsed = true;
$('#sidebar').classList.toggle('collapsed', state.ui.sidebarCollapsed);
$('#collapseBtn').classList.toggle('collapsed', state.ui.sidebarCollapsed);
validateSelection();
bindEvents();
renderAll();
/* keep the save indicator honest on first paint */
setTimeout(() => setSaveState('saved'), 260);
}
function applyLayout() {
setSidebarWidth(state.ui.sidebarW || 268);
$('#app').style.setProperty('--list-w', (state.ui.listW || 322) + 'px');
}
/* expose a small surface for testing / debugging */
window.__app = {
get state() { return state; },
CFG, ICONS,
htmlToText, sanitizeHtml, textToHtml, displayTitle, visibleNotes, noteText, buildSnippet,
getNote, getNotebook, notebookName, allTags,
newNote, openNote, selectNotebook, trashNote, restoreNote, deleteForever, togglePin,
duplicateNote, moveNote, renameNote, createNotebook, addTag, removeTag,
saveNow, renderAll, renderList, renderSidebar, renderEditor, undo, redo, applyShortcut, detectShortcut, commitHistory, flushHistory,
editor: () => $('#editor'),
caretToEnd, caretToStart, pushEditorChange, getHist,
setEditorHtml(html) { const e = ed(); e.innerHTML = html; pushEditorChange(); }
};
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"f90f5bc5-3fc6-4287-aebf-74f5f4d832ea","tokensIn":13336385,"tokensOut":117714,"tokensTotal":13454099,"cost":0.13422355799999997,"turns":76,"toolCalls":75,"failedToolCalls":0,"timestamp":"2026-09-10T17:36:03.531Z"} -->