Notes
Qwen 3.8 · 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 86,777 bytes · SHA-256 1a066a4db98e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quill — Notes</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Crect%20width='24'%20height='24'%20rx='6'%20fill='%23b45309'/%3E%3Cpath%20d='M17.5%206.5a4.24%204.24%200%200%200-6-6L5%207v8h8z'%20fill='none'%20stroke='%23fff'%20stroke-width='1.7'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3Cpath%20d='M13%208%204%2017'%20stroke='%23fff'%20stroke-width='1.7'%20stroke-linecap='round'/%3E%3C/svg%3E">
<style>
/* ============================================================
1. THEME VARIABLES & RESET
============================================================ */
:root{
--bg:#f2ede5;
--panel:#f8f5f0;
--panel-hi:#fcfbf8;
--panel-2:#eae3d8;
--text:#2c2620;
--text-2:#6e6558;
--text-3:#9b9184;
--border:#e4ddd1;
--border-2:#d5ccbd;
--accent:#b45309;
--accent-ink:#ffffff;
--accent-soft:rgba(180,83,9,.10);
--accent-soft-2:rgba(180,83,9,.20);
--mark:#f5d688;
--hover:rgba(60,45,20,.05);
--hover-2:rgba(60,45,20,.10);
--danger:#c73e33;
--danger-soft:rgba(199,62,51,.09);
--check-ink:#ffffff;
--shadow:0 6px 24px rgba(70,55,30,.14),0 1px 3px rgba(70,55,30,.08);
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-editor:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
--font-mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
}
[data-theme="dark"]{
--bg:#161310;
--panel:#1d1a15;
--panel-hi:#242019;
--panel-2:#2d2820;
--text:#ece4d6;
--text-2:#a89d8b;
--text-3:#776c5c;
--border:#322c23;
--border-2:#443b2e;
--accent:#e2a44f;
--accent-ink:#241a0a;
--accent-soft:rgba(226,164,79,.13);
--accent-soft-2:rgba(226,164,79,.26);
--mark:rgba(226,164,79,.42);
--hover:rgba(255,245,225,.055);
--hover-2:rgba(255,245,225,.11);
--danger:#e0685c;
--danger-soft:rgba(224,104,92,.13);
--check-ink:#241a0a;
--shadow:0 6px 24px rgba(0,0,0,.5),0 1px 3px rgba(0,0,0,.4);
color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:var(--font-ui);
font-size:14px;
background:var(--bg);
color:var(--text);
overflow:hidden;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
button{font-family:inherit}
.hidden{display:none!important}
/* ============================================================
2. APP LAYOUT
============================================================ */
#app{display:flex;flex-direction:column;height:100vh;--sbw:264px;--listw:320px}
#panes{display:flex;flex:1;min-height:0}
.handle{width:6px;flex-shrink:0;cursor:col-resize;position:relative;z-index:5}
.handle::after{content:'';position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--border);transition:background .15s}
.handle:hover::after,.handle.active::after{background:var(--accent)}
body.resizing{cursor:col-resize;user-select:none}
body.resizing #sidebar,body.resizing #listpane{transition:none}
#app.sb-closed #sidebar{width:0!important;border-right-width:0}
#app.sb-closed #handle-sb{display:none}
/* ============================================================
3. TOP BAR
============================================================ */
#topbar{
height:46px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;
padding:0 10px 0 6px;background:var(--panel);border-bottom:1px solid var(--border);gap:10px;
}
.tb-left,.tb-right{display:flex;align-items:center;gap:8px}
.brand{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700;letter-spacing:.01em;padding-left:4px;user-select:none}
.brand-mark{width:24px;height:24px;border-radius:7px;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center}
.save-ind{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--text-3);opacity:0;transition:opacity .35s;padding:4px 8px}
.save-ind.show{opacity:1}
.save-ind svg{color:var(--accent)}
/* ============================================================
4. SIDEBAR
============================================================ */
#sidebar{
background:var(--bg);border-right:1px solid var(--border);display:flex;flex-direction:column;
width:var(--sbw);min-width:0;overflow:hidden;transition:width .18s ease;
}
.sb-head{display:flex;align-items:center;justify-content:space-between;padding:12px 10px 8px 14px;flex-shrink:0}
.sb-title{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-3)}
.sb-head-actions{display:flex;gap:2px}
#sb-body{flex:1;overflow-y:auto;overflow-x:hidden;padding:0 0 14px}
.sb-item{
display:flex;align-items:center;gap:8px;padding:6px 8px;margin:1px 8px;border-radius:8px;
cursor:pointer;color:var(--text-2);font-size:13px;user-select:none;position:relative;
transition:background .12s;
}
.sb-item:hover{background:var(--hover);color:var(--text)}
.sb-item.selected{background:var(--accent-soft);color:var(--text);font-weight:600}
.sb-item.selected .sb-ic{color:var(--accent)}
.sb-ic{display:grid;place-items:center;color:var(--text-3);flex-shrink:0}
.sb-label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.sb-count{font-size:11px;color:var(--text-3);flex-shrink:0}
.sb-chev{background:none;border:0;cursor:pointer;color:var(--text-3);display:grid;place-items:center;width:18px;height:18px;border-radius:4px;padding:0;flex-shrink:0}
.sb-chev:hover{background:var(--hover-2);color:var(--text)}
.sb-chev svg{transition:transform .15s}
.nb.collapsed .sb-chev svg{transform:rotate(-90deg)}
.sb-kebab{opacity:0;background:none;border:0;cursor:pointer;color:var(--text-3);width:20px;height:20px;border-radius:5px;display:grid;place-items:center;padding:0;flex-shrink:0;transition:opacity .12s}
.sb-item:hover .sb-kebab{opacity:1}
.sb-kebab:hover{background:var(--hover-2);color:var(--text)}
.sb-sep{height:1px;background:var(--border);margin:8px 14px}
.sb-notes{margin:2px 8px 6px 24px;border-left:1px solid var(--border);padding-left:4px}
.sb-note{padding:4px 10px;font-size:12.5px;color:var(--text-2);border-radius:6px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .12s}
.sb-note:hover{background:var(--hover);color:var(--text)}
.sb-note.selected{background:var(--accent-soft);color:var(--accent);font-weight:600}
.sb-note-add{display:flex;align-items:center;gap:6px;color:var(--text-3);margin-top:2px}
.sb-note-add:hover{color:var(--accent)}
.drop-nb.drop-here{background:var(--accent-soft);outline:1.5px dashed var(--accent);outline-offset:-1.5px}
.inline-rename{flex:1;font-size:13px;font-family:inherit;padding:3px 7px;border-radius:5px;border:1px solid var(--accent);background:var(--panel-hi);color:var(--text);outline:none;min-width:0}
/* ============================================================
5. NOTE LIST PANE
============================================================ */
#listpane{background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;width:var(--listw);min-width:0}
.lp-head{padding:12px 12px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.lp-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
#lp-title{font-size:14.5px;font-weight:650;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.lp-actions{display:flex;gap:6px;flex-shrink:0}
.chip-btn{
display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;border-radius:7px;
border:1px solid var(--border-2);background:var(--panel-hi);color:var(--text-2);
font-size:12px;font-weight:550;cursor:pointer;transition:all .12s;white-space:nowrap;
}
.chip-btn:hover{border-color:var(--text-3);color:var(--text)}
.chip-btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
.chip-btn.primary:hover{filter:brightness(1.07);color:var(--accent-ink)}
.lp-search-row{display:flex;gap:6px;align-items:center}
.search-box{
flex:1;display:flex;align-items:center;gap:7px;height:30px;padding:0 10px;border-radius:8px;
background:var(--panel-2);border:1px solid transparent;color:var(--text-3);transition:all .15s;min-width:0;
}
.search-box:focus-within{background:var(--panel-hi);border-color:var(--accent);color:var(--accent)}
.search-box input{flex:1;border:0;background:none;outline:none;font-size:13px;color:var(--text);font-family:inherit;min-width:0}
.search-box input::placeholder{color:var(--text-3)}
.tag-chip{
display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:999px;
background:var(--accent-soft);color:var(--accent);font-size:12px;font-weight:600;border:0;cursor:pointer;
}
.tag-chip:hover{background:var(--accent-soft-2)}
#note-list{flex:1;overflow-y:auto;padding:8px 8px 16px}
.note-card{
padding:10px 12px 9px;border-radius:10px;cursor:pointer;border:1px solid transparent;
transition:background .12s,border-color .12s;user-select:none;
}
.note-card + .note-card{margin-top:3px}
.note-card:hover{background:var(--hover)}
.note-card.selected{background:var(--accent-soft);border-color:var(--accent-soft-2);box-shadow:inset 3px 0 0 var(--accent)}
.note-card.dragging{opacity:.45}
.nc-line1{display:flex;align-items:center;gap:6px}
.nc-title{flex:1;font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.005em;min-width:0}
.nc-pin{color:var(--accent);display:grid;place-items:center;flex-shrink:0}
.nc-kebab{opacity:0;background:none;border:0;color:var(--text-3);width:20px;height:20px;border-radius:5px;display:grid;place-items:center;cursor:pointer;padding:0;flex-shrink:0;transition:opacity .12s}
.note-card:hover .nc-kebab,.note-card.selected .nc-kebab{opacity:1}
.nc-kebab:hover{background:var(--hover-2);color:var(--text)}
.nc-snippet{font-size:12.5px;color:var(--text-2);line-height:1.45;margin:3px 0 6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.nc-snippet .dim{color:var(--text-3);font-style:italic}
.nc-line2{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--text-3)}
.nc-nb{background:var(--panel-2);padding:1px 6px;border-radius:4px;font-size:10.5px;white-space:nowrap}
.nc-tags{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap;justify-content:flex-end}
.nc-tag{border:0;background:var(--accent-soft);color:var(--accent);font-size:10.5px;font-weight:600;padding:1.5px 7px;border-radius:999px;cursor:pointer;transition:background .12s}
.nc-tag:hover{background:var(--accent-soft-2)}
mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 1px}
.list-empty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:48px 20px;text-align:center;color:var(--text-3)}
.le-ic{opacity:.5;margin-bottom:4px}
.le-t{font-size:13.5px;font-weight:600;color:var(--text-2)}
.le-s{font-size:12px}
.list-empty .btn-primary{margin-top:10px}
/* ============================================================
6. EDITOR PANE — CHROME
============================================================ */
#editorpane{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--panel-hi)}
#editor-head{flex-shrink:0}
.ed-head-inner{max-width:780px;margin:0 auto;padding:18px 44px 0}
.eh-title-row{display:flex;align-items:center;gap:10px}
#note-title{
flex:1;border:0;background:none;outline:none;font-family:var(--font-editor);
font-size:27px;font-weight:600;color:var(--text);letter-spacing:-.015em;padding:2px 0;min-width:0;
}
#note-title::placeholder{color:var(--text-3);font-weight:500}
.eh-actions{display:flex;gap:4px;flex-shrink:0}
.eh-meta{display:flex;align-items:center;gap:10px;margin:4px 0 10px;font-size:12px;color:var(--text-3);flex-wrap:wrap}
#note-updated{white-space:nowrap}
#tag-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;flex:1;min-width:0}
.tag-pill{display:inline-flex;align-items:center;gap:3px;background:var(--accent-soft);color:var(--accent);font-size:12px;font-weight:600;padding:2px 4px 2px 9px;border-radius:999px}
.tag-x{border:0;background:none;color:inherit;opacity:.55;cursor:pointer;font-size:13px;line-height:1;padding:2px 5px;border-radius:999px}
.tag-x:hover{opacity:1;background:var(--accent-soft-2)}
.tag-add{border:0;background:none;outline:none;font-size:12px;color:var(--text-2);width:64px;padding:2px 0}
.tag-add::placeholder{color:var(--text-3)}
/* toolbar */
#toolbar{display:flex;align-items:center;gap:1px;flex-wrap:wrap;padding:8px 0 12px;border-bottom:1px solid var(--border)}
.tb-btn{
display:grid;place-items:center;min-width:28px;height:28px;padding:0 5px;border-radius:6px;
border:0;background:none;color:var(--text-2);cursor:pointer;transition:background .1s,color .1s;
}
.tb-btn:hover{background:var(--hover);color:var(--text)}
.tb-btn.on{background:var(--accent-soft);color:var(--accent)}
.tb-label{font-size:11.5px;font-weight:700;letter-spacing:.02em;font-family:var(--font-ui)}
.tb-sep{width:1px;height:18px;background:var(--border);margin:0 5px}
/* buttons */
.icon-btn{
display:grid;place-items:center;width:28px;height:28px;border-radius:7px;border:0;background:none;
color:var(--text-2);cursor:pointer;transition:background .12s,color .12s;padding:0;
}
.icon-btn:hover{background:var(--hover);color:var(--text)}
.icon-btn.on{background:var(--accent-soft);color:var(--accent)}
.icon-btn.sm{width:24px;height:24px;border-radius:6px}
.btn-primary{
background:var(--accent);color:var(--accent-ink);border:0;padding:8px 16px;border-radius:9px;
font-size:13px;font-weight:650;cursor:pointer;transition:filter .12s;box-shadow:0 1px 2px rgba(0,0,0,.08);
}
.btn-primary:hover{filter:brightness(1.07)}
/* ============================================================
7. EDITOR PANE — CONTENT TYPOGRAPHY
============================================================ */
#editor-scroll{flex:1;overflow-y:auto}
#editor{
max-width:780px;margin:0 auto;padding:18px 44px 140px;outline:none;min-height:50vh;
font-family:var(--font-editor);font-size:16.5px;line-height:1.75;color:var(--text);caret-color:var(--accent);
}
#editor ::selection{background:var(--accent-soft-2)}
#editor > :first-child{margin-top:0}
#editor h1,#editor h2,#editor h3{font-weight:700;letter-spacing:-.015em;line-height:1.3}
#editor h1{font-size:1.8em;margin:.55em 0 .4em}
#editor h2{font-size:1.42em;margin:.55em 0 .35em}
#editor h3{font-size:1.16em;margin:.5em 0 .3em}
#editor p{margin:.45em 0}
#editor ul,#editor ol{margin:.45em 0;padding-left:1.55em}
#editor li{margin:.14em 0}
#editor li::marker{color:var(--text-3)}
#editor blockquote{margin:.7em 0;padding:.15em 0 .15em 1.05em;border-left:3px solid var(--accent);color:var(--text-2);font-style:italic}
#editor blockquote p{margin:.2em 0}
#editor hr{border:0;height:1px;background:var(--border-2);margin:1.5em 0}
#editor code{
font-family:var(--font-mono);font-size:.84em;background:var(--panel-2);
border:1px solid var(--border);border-radius:5px;padding:.12em .4em;
}
#editor b,#editor strong{font-weight:700}
#editor a{color:var(--accent)}
/* checklists */
#editor ul.ck{list-style:none;padding-left:.15em;margin:.35em 0}
#editor ul.ck li{position:relative;padding-left:30px;margin:.18em 0}
.ck-box{
position:absolute;left:2px;top:6px;width:16px;height:16px;border:1.5px solid var(--border-2);
border-radius:5px;cursor:pointer;transition:background .12s,border-color .12s;
}
.ck-box:hover{border-color:var(--accent)}
#editor ul.ck li[data-checked="true"] .ck-box{background:var(--accent);border-color:var(--accent)}
#editor ul.ck li[data-checked="true"] .ck-box::after{
content:'';position:absolute;left:4.5px;top:1px;width:4px;height:8.5px;
border:solid var(--check-ink);border-width:0 2px 2px 0;transform:rotate(45deg);
}
#editor ul.ck li[data-checked="true"]{color:var(--text-3)}
/* ============================================================
8. MENUS
============================================================ */
.menu{
position:fixed;z-index:200;min-width:190px;max-width:270px;background:var(--panel-hi);
border:1px solid var(--border-2);border-radius:11px;box-shadow:var(--shadow);padding:5px;
animation:menuIn .13s ease;
}
@keyframes menuIn{from{opacity:0;transform:scale(.97) translateY(-2px)}to{opacity:1;transform:none}}
.menu-item{
display:flex;align-items:center;gap:9px;width:100%;padding:6.5px 9px;border:0;background:none;
border-radius:7px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;
}
.menu-item:hover{background:var(--hover)}
.menu-item svg{color:var(--text-3);flex-shrink:0}
.menu-item.danger{color:var(--danger)}
.menu-item.danger svg{color:var(--danger)}
.menu-item.danger:hover{background:var(--danger-soft)}
.menu-sep{height:1px;background:var(--border);margin:5px 8px}
/* ============================================================
9. EMPTY STATE
============================================================ */
#empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;text-align:center;padding:40px}
.es-icon{width:60px;height:60px;border-radius:18px;background:var(--panel-2);color:var(--text-3);display:grid;place-items:center;margin-bottom:6px}
.es-title{font-family:var(--font-editor);font-size:20px;font-weight:600;color:var(--text-2)}
.es-sub{font-size:13px;color:var(--text-3);margin-bottom:10px}
/* ============================================================
10. TOAST
============================================================ */
#toast{
position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(12px);
display:flex;align-items:center;gap:12px;background:var(--panel-hi);color:var(--text);
border:1px solid var(--border-2);border-radius:11px;box-shadow:var(--shadow);
padding:9px 10px 9px 14px;font-size:13px;z-index:300;opacity:0;pointer-events:none;
transition:opacity .18s,transform .18s;max-width:min(440px,80vw);
}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0);pointer-events:auto}
#toast .toast-msg{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#toast .toast-action{
border:0;background:none;color:var(--accent);font-weight:700;font-size:13px;cursor:pointer;
padding:4px 10px;border-radius:7px;
}
#toast .toast-action:hover{background:var(--accent-soft)}
/* ============================================================
11. SCROLLBARS, FOCUS, MISC
============================================================ */
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border-2);border-radius:8px;border:3px solid transparent;background-clip:content-box}
::-webkit-scrollbar-thumb:hover{background:var(--text-3);border:3px solid transparent;background-clip:content-box}
:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
button:focus:not(:focus-visible){outline:none}
.note-card:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<div class="tb-left">
<button id="btn-sidebar" class="icon-btn" title="Toggle sidebar"></button>
<div class="brand"><span class="brand-mark" id="brand-mark"></span>Quill</div>
</div>
<div class="tb-right">
<div id="save-ind" class="save-ind"><span id="save-ind-ic"></span><span id="save-ind-text">Saved</span></div>
<button id="btn-theme" class="icon-btn" title="Toggle dark mode"></button>
</div>
</header>
<div id="panes">
<aside id="sidebar">
<div class="sb-head">
<span class="sb-title">Notebooks</span>
<div class="sb-head-actions">
<button id="btn-add-notebook" class="icon-btn sm" title="New notebook"></button>
</div>
</div>
<div id="sb-body"></div>
</aside>
<div id="handle-sb" class="handle"></div>
<section id="listpane">
<div class="lp-head">
<div class="lp-title-row">
<h1 id="lp-title">All Notes</h1>
<div class="lp-actions">
<button id="btn-sort" class="chip-btn" title="Sort order"></button>
<button id="btn-new-note" class="chip-btn primary" title="Create a new note"></button>
</div>
</div>
<div class="lp-search-row">
<div class="search-box"><span id="search-ic"></span><input id="search" type="text" placeholder="Search notes… ( / )" spellcheck="false" autocomplete="off"></div>
<button id="tag-chip" class="tag-chip hidden" title="Clear tag filter"></button>
</div>
</div>
<div id="note-list"></div>
</section>
<div id="handle-list" class="handle"></div>
<main id="editorpane">
<div id="editor-head">
<div class="ed-head-inner">
<div class="eh-title-row">
<input id="note-title" placeholder="Untitled note" spellcheck="false" autocomplete="off">
<div class="eh-actions">
<button id="btn-pin" class="icon-btn" title="Pin note to top"></button>
<button id="btn-note-menu" class="icon-btn" title="Note options"></button>
</div>
</div>
<div class="eh-meta">
<span id="note-updated"></span>
<div id="tag-row"></div>
</div>
<div id="toolbar"></div>
</div>
</div>
<div id="editor-scroll">
<div id="editor" contenteditable="true" spellcheck="true"></div>
</div>
<div id="empty-state">
<div class="es-icon" id="es-icon"></div>
<div class="es-title">Select or create a note</div>
<div class="es-sub">Choose a note from the list, or start a new one.</div>
<button id="btn-empty-new" class="btn-primary">New note</button>
</div>
</main>
</div>
</div>
<script>
'use strict';
/* ============================================================
1. TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
STORAGE_KEY: 'quill.notes.v1',
AUTOSAVE_MS: 500, // debounce for localStorage writes
SNIPPET_LEN: 150, // approx. chars in list snippets
UNDO_LIMIT: 200, // max undo steps kept per note
LIST_REFRESH_MS: 350, // debounce for re-rendering list while typing
MIN_SIDEBAR: 200, MAX_SIDEBAR: 420,
MIN_LIST: 250, MAX_LIST: 520,
};
/* ============================================================
2. ICONS (inline SVG, stroke-based)
============================================================ */
const ICONS = {
plus: '<path d="M12 5v14M5 12h14"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
x: '<path d="M18 6 6 18M6 6l12 12"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
restore: '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
kebab: '<circle cx="12" cy="5" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>',
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
bold: '<path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/>',
italic: '<path d="M19 4h-9M14 20H5M15 4 9 20"/>',
underline: '<path d="M6 4v6a6 6 0 0 0 12 0V4"/><path d="M4 20h16"/>',
strike: '<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><path d="M4 12h16"/>',
code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
list: '<path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="1.3" fill="currentColor" stroke="none"/><circle cx="3.5" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="3.5" cy="18" r="1.3" fill="currentColor" stroke="none"/>',
listol: '<path d="M10 6h11M10 12h11M10 18h11"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
checklist: '<rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.5 1.5 2.5-2.5"/><path d="M14 8h7"/><rect x="3" y="13" width="7" height="7" rx="1.5"/><path d="M14 16h7"/>',
quote: '<path d="M10 11H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v6c0 2-1 3.5-3 4"/><path d="M19 11h-4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v6c0 2-1 3.5-3 4"/>',
hr: '<path d="M4 12h16"/><path d="M8 8h8" opacity=".4"/><path d="M8 16h8" opacity=".4"/>',
note: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h4"/>',
folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
tag: '<path d="M20.59 13.41 12 22l-9-9V4a1 1 0 0 1 1-1h9z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor" stroke="none"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
sort: '<path d="M3 6h13M3 12h9M3 18h5"/><path d="m17 14 3 3 3-3M20 17V4"/>',
sidebar: '<rect x="3" y="3" width="18" height="18" rx="2.5"/><path d="M9 3v18"/>',
edit: '<path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/>',
feather: '<path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><path d="M16 8 2 22"/><path d="M17.5 15H9"/>',
corner: '<path d="M15 14 20 9l-5-5"/><path d="M4 20v-7a4 4 0 0 1 4-4h12"/>',
};
function icon(name, size){
size = size || 16;
return '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'+(ICONS[name]||'')+'</svg>';
}
/* ============================================================
3. SMALL UTILITIES
============================================================ */
function $(sel){ return document.querySelector(sel); }
function div(cls){ const d = document.createElement('div'); if (cls) d.className = cls; return d; }
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, function(c){
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
});
}
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
function uid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
function fmtTime(ts){
const d = new Date(ts), now = new Date();
const hm = String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
if (d.toDateString() === now.toDateString()) return hm;
const mon = d.toLocaleString('en-US', { month:'short', day:'numeric' });
return d.getFullYear() === now.getFullYear() ? mon : mon + ', ' + d.getFullYear();
}
/* ============================================================
4. DATA MODEL & PERSISTENCE
Single source of truth: `state`. The DOM is a projection.
============================================================ */
let state = null;
function defaultUi(){
return {
scope: 'all', // 'all' | 'trash' | notebookId
noteId: null,
sort: 'modified', // 'modified' | 'created' | 'alpha'
theme: 'light',
sidebarW: 264,
listW: 320,
sidebarOpen: true,
tagFilter: null,
search: '',
};
}
function seedState(){
const now = Date.now(), H = 3600e3, D = 24*H;
const nbGeneral = { id: uid(), name: 'General', collapsed: false };
const nbWork = { id: uid(), name: 'Work', collapsed: false };
const nbReading = { id: uid(), name: 'Reading', collapsed: true };
const welcome = {
id: uid(), notebookId: nbGeneral.id, title: 'Welcome to Quill',
html: '<h2>Welcome to Quill</h2><p>Quill is a quiet place for your notes. Everything you type is <b>saved automatically</b> in this browser — reload the page and it is all still here.</p><h3>Things to try</h3><ul class="ck"><li data-checked="true"><span class="ck-box"></span>Open a note and start typing</li><li data-checked="false"><span class="ck-box"></span>Type <code># </code> at the start of a line to make a heading</li><li data-checked="false"><span class="ck-box"></span>Drag this note onto another notebook in the sidebar</li><li data-checked="false"><span class="ck-box"></span>Press <code>Ctrl + Z</code> — undo understands your formatting</li></ul><blockquote>Tip: the search box finds anything, even words hidden inside <b>bold</b> or <i>italic</i> text.</blockquote>',
tags: ['getting-started'], pinned: true, createdAt: now - 2*D, updatedAt: now - 2*H, deletedAt: null,
};
const notes = [
welcome,
{
id: uid(), notebookId: nbGeneral.id, title: 'Formatting cheat sheet',
html: '<p>Shortcuts and gestures worth knowing:</p><ul><li><b>Bold</b> — <code>Ctrl/⌘ B</code></li><li><i>Italic</i> — <code>Ctrl/⌘ I</code></li><li><u>Underline</u> — <code>Ctrl/⌘ U</code></li><li><code>Inline code</code> — toolbar</li><li>Bullets — type <code>- </code> at line start</li><li>Checklist — type <code>[] </code> at line start</li><li>Quote — type <code>> </code> at line start</li></ul><hr><p>Headings, numbered lists and horizontal rules live in the toolbar above.</p>',
tags: ['reference'], pinned: false, createdAt: now - 2*D, updatedAt: now - 5*H, deletedAt: null,
},
{
id: uid(), notebookId: nbWork.id, title: 'Project kickoff — draft',
html: '<p>Goals for the next sprint. <b>Decision needed:</b> firm up the shipping date.</p><ul class="ck"><li data-checked="false"><span class="ck-box"></span>Outline the onboarding flow</li><li data-checked="false"><span class="ck-box"></span>Interview three beta users</li><li data-checked="true"><span class="ck-box"></span>Book the demo room</li></ul>',
tags: ['work','q3'], pinned: false, createdAt: now - D, updatedAt: now - 30*60e3, deletedAt: null,
},
{
id: uid(), notebookId: nbReading.id, title: 'To read',
html: '<p>Books and papers queued for the next month.</p><ul><li>Deep Work — Cal Newport</li><li>The Design of Everyday Things</li></ul>',
tags: [], pinned: false, createdAt: now - 6*D, updatedAt: now - 2*D, deletedAt: null,
},
];
const ui = defaultUi();
ui.noteId = welcome.id;
return { notebooks: [nbGeneral, nbWork, nbReading], notes: notes, ui: ui };
}
function loadState(){
let raw = null;
try { raw = localStorage.getItem(CONFIG.STORAGE_KEY); } catch(e){}
if (raw){
try {
const s = JSON.parse(raw);
if (s && Array.isArray(s.notebooks) && Array.isArray(s.notes) && s.ui){
s.ui = Object.assign(defaultUi(), s.ui);
state = s;
return;
}
} catch(e){ /* corrupted — reseed */ }
}
state = seedState();
persist();
}
function persist(){
try { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(state)); } catch(e){}
}
/* saved indicator */
let saveTimer = null, fadeTimer = null;
let saveInd, saveIndText, saveIndIc;
function setSaveInd(text, show){
saveIndText.textContent = text;
saveIndIc.innerHTML = icon('check', 13);
saveInd.classList.add('show');
clearTimeout(fadeTimer);
if (text === 'Saved') fadeTimer = setTimeout(function(){ saveInd.classList.remove('show'); }, 1500);
}
function scheduleSave(){
setSaveInd('Saving…', true);
clearTimeout(saveTimer);
saveTimer = setTimeout(function(){ persist(); setSaveInd('Saved', true); }, CONFIG.AUTOSAVE_MS);
}
function flushSave(){
clearTimeout(saveTimer);
persist();
}
function getNote(id){ return state.notes.find(function(n){ return n.id === id; }) || null; }
function getNotebook(id){ return state.notebooks.find(function(n){ return n.id === id; }) || null; }
function firstNotebookId(){ return state.notebooks.length ? state.notebooks[0].id : null; }
/* ============================================================
5. TEXT EXTRACTION & SNIPPETS
Pure functions over stored HTML — feed search and previews.
Never matches markup: tags are stripped before matching.
============================================================ */
function htmlToText(html){
const host = document.createElement('div');
host.innerHTML = html || '';
host.querySelectorAll('script,style,iframe,object,embed').forEach(function(n){ n.remove(); });
const lines = [];
const norm = function(s){ return String(s).replace(/\s+/g, ' ').trim(); };
const visit = function(node){
if (node.nodeType === 3){ lines.push(node.nodeValue); return; }
if (node.nodeType !== 1) return;
const tag = node.tagName;
if (tag === 'BR'){ lines.push('\n'); return; }
if (tag === 'LI'){
const t = norm(node.textContent);
if (t) lines.push(t + '\n');
return;
}
if (/^(H1|H2|H3|P|BLOCKQUOTE|UL|OL|DIV|PRE|TABLE)$/.test(tag)){
for (const c of node.childNodes) visit(c);
lines.push('\n');
return;
}
for (const c of node.childNodes) visit(c);
};
for (const c of host.childNodes) visit(c);
return lines.join('').split('\n').map(norm).filter(Boolean).join('\n');
}
function makeSnippet(text, q){
if (!q) {
const t = text.slice(0, CONFIG.SNIPPET_LEN);
return escapeHtml(t) + (text.length > CONFIG.SNIPPET_LEN ? '…' : '');
}
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx < 0) return escapeHtml(text.slice(0, CONFIG.SNIPPET_LEN));
const start = Math.max(0, idx - 40);
const end = Math.min(text.length, idx + q.length + 110);
const pre = text.slice(start, idx);
const match = text.slice(idx, idx + q.length);
const post = text.slice(idx + q.length, end);
return (start > 0 ? '…' : '') + escapeHtml(pre) + '<mark>' + escapeHtml(match) + '</mark>' + escapeHtml(post) + (end < text.length ? '…' : '');
}
function highlightText(text, q){
const i = text.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return escapeHtml(text);
return escapeHtml(text.slice(0, i)) + '<mark>' + escapeHtml(text.slice(i, i + q.length)) + '</mark>' + escapeHtml(text.slice(i + q.length));
}
/* ============================================================
6. HTML SANITIZATION & CONTENT NORMALIZATION
Whitelist-based: only supported markup survives paste/load.
============================================================ */
const editor = $('#editor');
const ALLOWED_TAGS = new Set(['P','BR','B','I','U','S','STRONG','EM','SUB','SUP','CODE','PRE','H1','H2','H3','H4','UL','OL','LI','BLOCKQUOTE','HR','A','SPAN','DIV']);
const STRIP_TAGS = new Set(['SCRIPT','STYLE','IFRAME','OBJECT','EMBED','LINK','META','BASE','FORM','INPUT','TEXTAREA','BUTTON','VIDEO','AUDIO','CANVAS','SVG','TEMPLATE','NOSCRIPT','IMG','FRAME','FRAMESET']);
function safeHref(v){ return /^(https?:|mailto:|#|\/)/i.test(String(v).trim()); }
function sanitizeHtml(html){
const host = document.createElement('div');
host.innerHTML = html;
cleanNode(host);
normalizeContent(host);
return host.innerHTML;
}
function cleanNode(node){
for (const child of Array.from(node.childNodes)){
if (child.nodeType !== 1){
if (child.nodeType !== 3) child.remove();
continue;
}
const tag = child.tagName;
if (STRIP_TAGS.has(tag)){ child.remove(); continue; }
if (!ALLOWED_TAGS.has(tag)){
while (child.firstChild) node.insertBefore(child.firstChild, child);
child.remove();
cleanNode(node);
continue;
}
for (const attr of Array.from(child.attributes)){
const name = attr.name, value = attr.value;
if (name === 'href' && tag === 'A' && safeHref(value)) continue;
if (name === 'data-checked' && tag === 'LI' && (value === 'true' || value === 'false')) continue;
if (name === 'class' && tag === 'UL' && value === 'ck') continue;
child.removeAttribute(name);
}
cleanNode(child);
}
}
/* Keeps document structure consistent: checklist boxes, block wrapping,
trailing editable block, no stray empty paragraphs. */
function normalizeContent(host){
host.querySelectorAll('script,style,iframe,object,embed').forEach(function(n){ n.remove(); });
host.querySelectorAll('li').forEach(function(li){
const parent = li.parentElement;
const isCk = parent && parent.tagName === 'UL' && parent.classList.contains('ck');
const box = li.querySelector(':scope > .ck-box');
if (isCk && !box){
const b = document.createElement('span');
b.className = 'ck-box';
li.insertBefore(b, li.firstChild);
} else if (!isCk && box){
box.remove();
}
if (isCk && li.hasAttribute('data-checked') && li.getAttribute('data-checked') !== 'true'){
li.setAttribute('data-checked', 'false');
}
});
host.querySelectorAll('div').forEach(function(d){
const hasBlock = Array.from(d.children).some(function(c){
return /^(P|H1|H2|H3|H4|UL|OL|BLOCKQUOTE|HR|PRE|DIV)$/.test(c.tagName);
});
if (hasBlock){
while (d.firstChild) d.parentNode.insertBefore(d.firstChild, d);
d.remove();
} else {
const p = document.createElement('p');
while (d.firstChild) p.appendChild(d.firstChild);
d.replaceWith(p);
}
});
// unwrap nested blocks (e.g. <p><p>x</p></p> from pasting)
host.querySelectorAll('p, h1, h2, h3, h4').forEach(function(b){
const blockChild = Array.from(b.children).some(function(c){
return /^(P|H1|H2|H3|H4|UL|OL|BLOCKQUOTE|HR|PRE)$/.test(c.tagName);
});
if (blockChild){
while (b.firstChild) b.parentNode.insertBefore(b.firstChild, b);
b.remove();
}
});
// wrap leading inline content in a paragraph
let node = host.firstChild;
const inline = [];
while (node && (node.nodeType === 3 || (node.nodeType === 1 && !/^(P|H1|H2|H3|H4|UL|OL|BLOCKQUOTE|HR|PRE)$/.test(node.tagName)))){
inline.push(node);
node = node.nextSibling;
}
if (inline.length){
const p = document.createElement('p');
inline.forEach(function(n){ p.appendChild(n); });
if (node) node.parentNode.insertBefore(p, node);
else host.appendChild(p);
}
// drop empty paragraphs, keep the last one
host.querySelectorAll('p').forEach(function(p){
if (!p.textContent.trim() && !p.querySelector('br') && p !== host.lastChild) p.remove();
});
if (!host.childNodes.length){ host.innerHTML = '<p><br></p>'; return; }
const last = host.lastChild;
if (last.nodeType === 3 || (last.nodeType === 1 && !/^(P|H1|H2|H3|H4|UL|OL|BLOCKQUOTE|PRE)$/.test(last.tagName))){
const p = document.createElement('p');
p.innerHTML = '<br>';
host.appendChild(p);
}
}
/* ============================================================
7. SELECTION & UNDO / REDO
Snapshot-based: every change records pre-state + caret offsets.
Typing bursts coalesce; structural ops always snapshot.
============================================================ */
const undoStack = [], redoStack = [];
function editorSel(){
const s = window.getSelection();
if (!s.rangeCount) return null;
const r = s.getRangeAt(0);
return editor.contains(r.commonAncestorContainer) ? r : null;
}
function caretOffsets(range){
const r = document.createRange();
r.selectNodeContents(editor);
r.setEnd(range.startContainer, range.startOffset);
const d = document.createElement('div');
d.appendChild(r.cloneContents());
const start = d.textContent.length;
d.innerHTML = '';
r.setEnd(range.endContainer, range.endOffset);
d.appendChild(r.cloneContents());
return { start: start, end: d.textContent.length };
}
function rangeFromOffsets(start, end){
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
let rem = start, node, range = null;
while ((node = walker.nextNode())){
const len = node.textContent.length;
if (rem <= len){ range = document.createRange(); range.setStart(node, rem); break; }
rem -= len;
}
if (!range){
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
return range;
}
let rem2 = end, node2, endSet = false;
const w2 = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
while ((node2 = w2.nextNode())){
const len = node2.textContent.length;
if (rem2 <= len){ range.setEnd(node2, rem2); endSet = true; break; }
rem2 -= len;
}
if (!endSet){ range.setEnd(range.startContainer, range.startOffset); }
return range;
}
function setSelectionRange(range){
const s = window.getSelection();
s.removeAllRanges();
s.addRange(range);
}
function placeCaret(node, collapseToStart){
const r = document.createRange();
r.selectNodeContents(node);
r.collapse(!!collapseToStart);
setSelectionRange(r);
}
function placeCaretEnd(node){
const r = document.createRange();
r.selectNodeContents(node);
r.collapse(false);
setSelectionRange(r);
}
function focusEditorEnd(){
const r = document.createRange();
r.selectNodeContents(editor);
r.collapse(false);
setSelectionRange(r);
editor.focus();
}
function pushSnapshot(kind){
const range = editorSel();
const off = range ? caretOffsets(range) : { start: 0, end: 0 };
const snap = { html: editor.innerHTML, start: off.start, end: off.end, kind: kind, ts: Date.now() };
const top = undoStack[undoStack.length - 1];
if (kind === 'typing' && top && top.kind === 'typing' && (snap.ts - top.ts) < 900){
top.ts = snap.ts; // coalesce the burst; keep the pre-burst html
} else {
undoStack.push(snap);
if (undoStack.length > CONFIG.UNDO_LIMIT) undoStack.shift();
}
redoStack.length = 0;
}
function pushOp(){ pushSnapshot('op'); }
function currentSnap(){
const range = editorSel();
const off = range ? caretOffsets(range) : { start: 0, end: 0 };
return { html: editor.innerHTML, start: off.start, end: off.end, kind: 'op', ts: Date.now() };
}
function applyHtmlWithCaret(snap){
editor.innerHTML = snap.html;
normalizeContent(editor);
setSelectionRange(rangeFromOffsets(snap.start, snap.end));
}
function undo(){
const snap = undoStack.pop();
if (!snap || !state.ui.noteId) return;
redoStack.push(currentSnap());
applyHtmlWithCaret(snap);
afterEditorChange();
}
function redo(){
const snap = redoStack.pop();
if (!snap) return;
undoStack.push(currentSnap());
applyHtmlWithCaret(snap);
afterEditorChange();
}
/* ============================================================
8. EDITOR EVENTS — markdown shortcuts, lists, paste
============================================================ */
function currentBlock(){
const range = editorSel();
if (!range) return null;
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentElement;
if (!n || !editor.contains(n)) return null;
return n.closest('p, li, h1, h2, h3, blockquote');
}
/* `# ` `## ` `### ` `- ` `[] ` `> ` `1. ` at line start → block conversion */
function handleMarkdownShortcut(e){
if (e.key !== ' ' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
const range = editorSel();
if (!range) return;
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentElement;
const block = n && n.closest('li, p');
if (!block || !editor.contains(block)) return;
const r = document.createRange();
r.selectNodeContents(block);
r.setEnd(range.startContainer, range.startOffset);
const before = r.toString();
const m = before.match(/^(#{1,3}|\[ ?\]|[-*]|\d+\.|>)$/);
if (!m) return;
e.preventDefault();
pushOp();
const marker = m[1];
r.deleteContents(); // strip the marker text
const inner = block.innerHTML;
let newBlock;
if (/^#/.test(marker)){
newBlock = document.createElement('h' + marker.length);
newBlock.innerHTML = inner;
} else if (marker === '-' || marker === '*'){
newBlock = document.createElement('ul');
const li = document.createElement('li');
li.innerHTML = inner;
newBlock.appendChild(li);
} else if (marker === '[]' || marker === '[ ]'){
newBlock = document.createElement('ul');
newBlock.classList.add('ck');
const li = document.createElement('li');
li.setAttribute('data-checked', 'false');
li.innerHTML = inner;
newBlock.appendChild(li);
} else if (marker === '>'){
newBlock = document.createElement('blockquote');
const p = document.createElement('p');
p.innerHTML = inner;
newBlock.appendChild(p);
} else {
newBlock = document.createElement('ol');
const li = document.createElement('li');
li.innerHTML = inner;
newBlock.appendChild(li);
}
block.replaceWith(newBlock);
normalizeContent(editor);
placeCaret(newBlock, true);
afterEditorChange();
}
/* Enter: continue list, exit empty item */
function handleEnter(e){
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return;
const range = editorSel();
if (!range) return;
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentElement;
const li = n && n.closest('li');
if (!li || !editor.contains(li)) return;
e.preventDefault();
pushOp();
if (!li.textContent.trim()){
const list = li.parentElement;
const prevLi = li.previousElementSibling;
li.remove();
if (!list.children.length){
const p = document.createElement('p');
p.innerHTML = '<br>';
list.replaceWith(p);
placeCaret(p, true);
} else if (prevLi){
placeCaretEnd(prevLi);
} else {
placeCaret(list, true);
}
} else {
const r = document.createRange();
r.setStart(range.startContainer, range.startOffset);
r.setEnd(range.endContainer, range.endOffset);
const frag = r.extractContents();
const newLi = document.createElement('li');
if (li.parentElement && li.parentElement.classList.contains('ck')) newLi.setAttribute('data-checked', 'false');
newLi.appendChild(frag);
if (!newLi.textContent.trim() && !newLi.querySelector('br,img')) newLi.innerHTML = '<br>';
li.after(newLi);
placeCaret(newLi, true);
}
normalizeContent(editor);
afterEditorChange();
}
/* Tab / Shift+Tab: indent & outdent list items */
function handleTab(e){
if (e.key !== 'Tab') return;
const range = editorSel();
if (!range) return;
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentElement;
const li = n && n.closest('li');
if (!li || !editor.contains(li)) return;
e.preventDefault();
pushOp();
if (e.shiftKey) outdentLi(li); else indentLi(li);
normalizeContent(editor);
placeCaretEnd(li);
afterEditorChange();
}
function indentLi(li){
const prev = li.previousElementSibling;
if (!prev) return;
const isCk = li.parentElement.classList.contains('ck');
let sub = prev.querySelector(':scope > ul, :scope > ol');
if (!sub){
sub = document.createElement(isCk ? 'ul' : 'ul');
if (isCk) sub.classList.add('ck');
prev.appendChild(sub);
}
sub.appendChild(li);
}
function outdentLi(li){
const list = li.parentElement;
if (list && list.parentElement && list.parentElement.tagName === 'LI'){
const parentLi = list.parentElement;
parentLi.after(li);
if (!list.children.length) list.remove();
}
}
/* Backspace at the very start of a list item: merge / de-list */
function handleBackspace(e){
if (e.key !== 'Backspace') return;
const range = editorSel();
if (!range || !range.collapsed) return;
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentElement;
const li = n && n.closest('li');
if (!li || !editor.contains(li)) return;
const r = document.createRange();
r.selectNodeContents(li);
r.setEnd(range.startContainer, range.startOffset);
if (r.toString().length > 0) return;
e.preventDefault();
pushOp();
const list = li.parentElement;
const prevLi = li.previousElementSibling;
if (prevLi){
const frag = document.createRange().createContextualFragment(li.innerHTML);
frag.querySelectorAll('.ck-box').forEach(function(b){ b.remove(); });
const first = frag.firstChild;
prevLi.appendChild(frag);
li.remove();
const r2 = document.createRange();
if (first){
if (first.nodeType === 3) r2.setStart(first, 0);
else r2.setStartBefore(first);
r2.collapse(true);
} else {
r2.selectNodeContents(prevLi);
r2.collapse(false);
}
setSelectionRange(r2);
} else {
const p = document.createElement('p');
p.innerHTML = li.innerHTML;
li.remove();
if (list.previousElementSibling){
list.insertBefore(p, list);
if (!list.children.length) list.remove();
} else {
list.replaceWith(p);
}
placeCaret(p, true);
}
normalizeContent(editor);
afterEditorChange();
}
/* Paste: sanitize to the supported subset */
function handlePaste(e){
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
let range = editorSel();
if (!range) return;
pushOp();
const host = document.createElement('div');
if (html){
host.innerHTML = sanitizeHtml(html);
} else if (text){
const lines = text.replace(/\r/g, '').split('\n');
if (lines.length === 1){
host.appendChild(document.createTextNode(lines[0]));
} else {
lines.forEach(function(line){
const p = document.createElement('p');
if (line) p.textContent = line;
else p.innerHTML = '<br>';
host.appendChild(p);
});
}
} else return;
range.deleteContents();
range.collapse(true);
const nodes = Array.from(host.childNodes);
let last = null;
nodes.forEach(function(n){
range.insertNode(n);
const r2 = document.createRange();
r2.setStartAfter(n);
r2.collapse(true);
setSelectionRange(r2);
range = r2;
last = n;
});
if (nodes.length) placeCaretEnd(last);
normalizeContent(editor);
afterEditorChange();
}
/* ============================================================
9. FORMATTING OPERATIONS (toolbar actions)
============================================================ */
function isRangeInside(range, el){
const r = document.createRange();
r.selectNodeContents(el);
return range.compareBoundaryPoints(Range.END_TO_START, r) >= 0 &&
range.compareBoundaryPoints(Range.START_TO_END, r) <= 0;
}
function unwrapEl(el){
const parent = el.parentNode;
if (!parent) return;
const frag = document.createRange().createContextualFragment(el.innerHTML);
const idx = Array.from(parent.childNodes).indexOf(el);
parent.replaceChild(frag, el);
const r = document.createRange();
const ref = parent.childNodes[Math.min(idx, parent.childNodes.length - 1)];
r.setStartBefore(ref);
r.collapse(true);
setSelectionRange(r);
}
function wrapSelection(tag){
const range = editorSel();
if (!range) return;
if (range.collapsed){
const el = document.createElement(tag);
el.innerHTML = ' ';
range.insertNode(el);
placeCaret(el, true);
return;
}
const el = document.createElement(tag);
try {
range.surroundContents(el);
} catch(err){
const frag = range.extractContents();
el.appendChild(frag);
range.insertNode(el);
}
placeCaret(el, true);
}
function applyInline(tag){
const range = editorSel();
if (!range) return;
const anc = range.commonAncestorContainer;
const ancEl = anc.nodeType === 1 ? anc : (anc.parentElement || null);
const holder = ancEl ? ancEl.closest(tag) : null;
if (holder && isRangeInside(range, holder)){
pushOp();
unwrapEl(holder);
afterEditorChange();
return;
}
pushOp();
if (tag === 'code'){
wrapSelection('code');
} else {
const cmd = { b: 'bold', i: 'italic', u: 'underline', s: 'strikeThrough' }[tag];
let ok = false;
try { ok = document.execCommand(cmd); } catch(err){ ok = false; }
if (!ok) wrapSelection(tag);
}
afterEditorChange();
}
function applyBlock(tag){
const block = currentBlock();
if (!block) return;
pushOp();
const nextTag = block.tagName === tag ? 'P' : tag;
const el = document.createElement(nextTag);
el.innerHTML = block.innerHTML;
block.replaceWith(el);
normalizeContent(editor);
placeCaret(el, true);
afterEditorChange();
}
function toggleList(type){
const block = currentBlock();
if (!block) return;
pushOp();
if (block.tagName === 'LI'){
const list = block.parentElement;
const isCk = list.classList.contains('ck');
const isOl = list.tagName === 'OL';
const same = (type === 'ul' && !isCk && !isOl) || (type === 'ol' && !isCk && isOl) || (type === 'ck' && isCk);
if (same){
const p = document.createElement('p');
p.innerHTML = block.innerHTML;
block.replaceWith(p);
if (!list.children.length) list.remove();
normalizeContent(editor);
placeCaret(p, true);
afterEditorChange();
return;
}
const newList = document.createElement(type === 'ol' ? 'ol' : 'ul');
if (type === 'ck') newList.classList.add('ck');
while (list.firstChild) newList.appendChild(list.firstChild);
list.replaceWith(newList);
if (type !== 'ck') newList.querySelectorAll(':scope > li > .ck-box').forEach(function(b){ b.remove(); });
normalizeContent(editor);
placeCaret(block, true);
afterEditorChange();
return;
}
if (block.tagName === 'P'){
const list = document.createElement(type === 'ol' ? 'ol' : 'ul');
if (type === 'ck') list.classList.add('ck');
const li = document.createElement('li');
if (type === 'ck') li.setAttribute('data-checked', 'false');
li.innerHTML = block.innerHTML;
list.appendChild(li);
block.replaceWith(list);
normalizeContent(editor);
placeCaret(li, true);
afterEditorChange();
}
}
function toggleQuote(){
const block = currentBlock();
if (!block) return;
pushOp();
const bq = block.closest('blockquote');
if (bq){
while (bq.firstChild) bq.parentNode.insertBefore(bq.firstChild, bq);
bq.remove();
normalizeContent(editor);
placeCaret(block, true);
} else {
const bq2 = document.createElement('blockquote');
const p = document.createElement('p');
p.innerHTML = block.innerHTML;
bq2.appendChild(p);
block.replaceWith(bq2);
normalizeContent(editor);
placeCaret(p, true);
}
afterEditorChange();
}
function insertHr(){
const block = currentBlock();
if (!block) return;
pushOp();
const hr = document.createElement('hr');
const p = document.createElement('p');
p.innerHTML = '<br>';
block.after(hr);
hr.after(p);
normalizeContent(editor);
placeCaret(p, true);
afterEditorChange();
}
/* toolbar state at caret */
const toolbarBtns = {};
function updateToolbarState(){
const range = editorSel();
const anc = range ? range.commonAncestorContainer : null;
const ancEl = anc ? (anc.nodeType === 1 ? anc : anc.parentElement) : null;
const setOn = function(key, on){
const b = toolbarBtns[key];
if (b) b.classList.toggle('on', !!on);
};
if (!ancEl || !editor.contains(ancEl)){
Object.keys(toolbarBtns).forEach(function(k){ setOn(k, false); });
return;
}
setOn('b', !!ancEl.closest('b, strong'));
setOn('i', !!ancEl.closest('i, em'));
setOn('u', !!ancEl.closest('u'));
setOn('s', !!ancEl.closest('s, strike'));
setOn('code', !!ancEl.closest('code'));
const block = ancEl.closest('p, li, h1, h2, h3, blockquote');
setOn('h1', !!block && block.tagName === 'H1');
setOn('h2', !!block && block.tagName === 'H2');
setOn('h3', !!block && block.tagName === 'H3');
const list = block && block.tagName === 'LI' ? block.parentElement : (block ? block.closest('ul, ol') : null);
const isCk = !!(list && list.classList && list.classList.contains('ck'));
setOn('ul', !!list && list.tagName === 'UL' && !isCk);
setOn('ol', !!list && list.tagName === 'OL');
setOn('ck', isCk);
setOn('quote', !!ancEl.closest('blockquote'));
}
function buildToolbar(){
const tb = $('#toolbar');
const defs = [
{ key: 'b', icon: 'bold', title: 'Bold (Ctrl+B)', fn: function(){ applyInline('b'); } },
{ key: 'i', icon: 'italic', title: 'Italic (Ctrl+I)', fn: function(){ applyInline('i'); } },
{ key: 'u', icon: 'underline', title: 'Underline (Ctrl+U)', fn: function(){ applyInline('u'); } },
{ key: 's', icon: 'strike', title: 'Strikethrough', fn: function(){ applyInline('s'); } },
{ key: 'code', icon: 'code', title: 'Inline code', fn: function(){ applyInline('code'); } },
{ sep: true },
{ key: 'h1', label: 'H1', title: 'Heading 1', fn: function(){ applyBlock('H1'); } },
{ key: 'h2', label: 'H2', title: 'Heading 2', fn: function(){ applyBlock('H2'); } },
{ key: 'h3', label: 'H3', title: 'Heading 3', fn: function(){ applyBlock('H3'); } },
{ sep: true },
{ key: 'ul', icon: 'list', title: 'Bulleted list', fn: function(){ toggleList('ul'); } },
{ key: 'ol', icon: 'listol', title: 'Numbered list', fn: function(){ toggleList('ol'); } },
{ key: 'ck', icon: 'checklist', title: 'Checklist', fn: function(){ toggleList('ck'); } },
{ key: 'quote', icon: 'quote', title: 'Blockquote', fn: toggleQuote },
{ key: 'hr', icon: 'hr', title: 'Horizontal rule', fn: insertHr },
];
defs.forEach(function(d){
if (d.sep){ tb.appendChild(div('tb-sep')); return; }
const b = document.createElement('button');
b.type = 'button';
b.className = 'tb-btn';
b.title = d.title;
b.innerHTML = d.label ? '<span class="tb-label">' + d.label + '</span>' : icon(d.icon, 16);
b.addEventListener('mousedown', function(e){ e.preventDefault(); }); // keep editor selection
b.addEventListener('click', function(){
d.fn();
editor.focus();
updateToolbarState();
});
toolbarBtns[d.key] = b;
tb.appendChild(b);
});
}
/* ============================================================
10. MODEL MUTATIONS (notes & notebooks)
============================================================ */
function createNote(notebookId){
let nbId = notebookId || (state.ui.scope !== 'all' && state.ui.scope !== 'trash' ? state.ui.scope : null) || firstNotebookId();
if (!nbId){
const nb = { id: uid(), name: 'General', collapsed: false };
state.notebooks.push(nb);
nbId = nb.id;
}
const now = Date.now();
const note = {
id: uid(), notebookId: nbId, title: '', html: '<p><br></p>', tags: [],
pinned: false, createdAt: now, updatedAt: now, deletedAt: null,
};
state.notes.push(note);
state.ui.noteId = note.id;
scheduleSave();
renderAll();
const ti = $('#note-title');
ti.focus();
}
function selectNote(id, opts){
state.ui.noteId = id;
scheduleSave();
renderList();
renderSidebar();
renderEditor();
if (opts && opts.focus){
editor.focus();
focusEditorEnd();
}
}
function moveNote(id, nbId){
const note = getNote(id);
if (!note) return;
note.notebookId = nbId;
note.deletedAt = null;
scheduleSave();
renderAll();
}
function togglePin(id){
const note = getNote(id);
if (!note) return;
note.pinned = !note.pinned;
scheduleSave();
renderList();
renderEditor();
}
function duplicateNote(id){
const note = getNote(id);
if (!note) return;
const now = Date.now();
const idx = state.notes.findIndex(function(n){ return n.id === id; });
const copy = {
id: uid(), notebookId: note.notebookId,
title: (note.title || 'Untitled') + ' (copy)',
html: note.html, tags: (note.tags || []).slice(),
pinned: false, createdAt: now, updatedAt: now, deletedAt: null,
};
state.notes.splice(idx + 1, 0, copy);
state.ui.noteId = copy.id;
scheduleSave();
renderAll();
}
function deleteNote(id){
const note = getNote(id);
if (!note) return;
const title = note.title || 'Untitled note';
note.deletedAt = Date.now();
if (state.ui.noteId === id) state.ui.noteId = null;
scheduleSave();
renderAll();
showToast('“' + title + '” moved to Trash', 'Undo', function(){ restoreNote(id); });
}
function restoreNote(id){
const note = getNote(id);
if (!note) return;
note.deletedAt = null;
scheduleSave();
renderAll();
}
function destroyNote(id){
const idx = state.notes.findIndex(function(n){ return n.id === id; });
if (idx < 0) return;
state.notes.splice(idx, 1);
if (state.ui.noteId === id) state.ui.noteId = null;
scheduleSave();
renderAll();
}
function addTag(noteId, tag){
const note = getNote(noteId);
if (!note) return;
tag = tag.trim().replace(/^#+/, '');
if (!tag) return;
note.tags = note.tags || [];
if (note.tags.some(function(t){ return t.toLowerCase() === tag.toLowerCase(); })) return;
note.tags.push(tag);
scheduleSave();
renderTags(note);
renderList();
}
function removeTag(noteId, tag){
const note = getNote(noteId);
if (!note) return;
note.tags = (note.tags || []).filter(function(t){ return t !== tag; });
scheduleSave();
renderTags(note);
renderList();
}
function setScope(scope){
state.ui.scope = scope;
state.ui.tagFilter = null;
scheduleSave();
renderSidebar();
renderList();
}
function createNotebook(){
const nb = { id: uid(), name: 'New Notebook', collapsed: false };
state.notebooks.push(nb);
scheduleSave();
renderSidebar();
const item = document.querySelector('#sb-body .sb-item[data-nb="' + nb.id + '"]');
if (item) startRenameNotebook(nb, item);
}
function deleteNotebook(nb){
let target = state.notebooks.find(function(n){ return n.id !== nb.id; }) || null;
if (!target){
target = { id: uid(), name: 'General', collapsed: false };
state.notebooks.push(target);
}
state.notes.forEach(function(n){
if (n.notebookId === nb.id) n.notebookId = target.id;
});
state.notebooks = state.notebooks.filter(function(n){ return n.id !== nb.id; });
if (state.ui.scope === nb.id) state.ui.scope = 'all';
scheduleSave();
renderAll();
}
function startRenameNotebook(nb, item){
const lab = item.querySelector('.sb-label');
if (!lab || lab.querySelector('input')) return;
const input = document.createElement('input');
input.className = 'inline-rename';
input.value = nb.name;
lab.textContent = '';
lab.appendChild(input);
input.focus();
input.select();
let done = false;
const commit = function(ok){
if (done) return;
done = true;
const v = input.value.trim();
if (ok && v) nb.name = v;
scheduleSave();
renderSidebar();
};
input.addEventListener('keydown', function(e){
e.stopPropagation();
if (e.key === 'Enter') commit(true);
if (e.key === 'Escape') commit(false);
});
input.addEventListener('blur', function(){ commit(true); });
}
/* ============================================================
11. RENDERING — sidebar, note list, editor, tags
The DOM is a projection of `state`.
============================================================ */
let dragNoteId = null;
let listTimer = null;
function countFor(scope){
if (scope === 'trash') return state.notes.filter(function(n){ return n.deletedAt; }).length;
if (scope === 'all') return state.notes.filter(function(n){ return !n.deletedAt; }).length;
return state.notes.filter(function(n){ return !n.deletedAt && n.notebookId === scope; }).length;
}
function sbItem(iconName, label, count, selected, onClick, opts){
opts = opts || {};
const item = div('sb-item' + (selected ? ' selected' : ''));
if (opts.nb) item.dataset.nb = opts.nb.id;
const ic = div('sb-ic');
ic.innerHTML = icon(iconName, 15);
item.appendChild(ic);
const lab = div('sb-label');
lab.textContent = label;
item.appendChild(lab);
const c = div('sb-count');
c.textContent = count;
item.appendChild(c);
if (opts.nb){
const chev = document.createElement('button');
chev.className = 'sb-chev';
chev.title = opts.nb.collapsed ? 'Expand' : 'Collapse';
chev.innerHTML = icon('chevron', 13);
chev.addEventListener('click', function(e){
e.stopPropagation();
opts.nb.collapsed = !opts.nb.collapsed;
scheduleSave();
renderSidebar();
});
item.appendChild(chev);
const kebab = document.createElement('button');
kebab.className = 'sb-kebab';
kebab.title = 'Notebook options';
kebab.innerHTML = icon('kebab', 14);
kebab.addEventListener('click', function(e){
e.stopPropagation();
openNotebookMenu(kebab, opts.nb);
});
item.appendChild(kebab);
item.addEventListener('dblclick', function(){ startRenameNotebook(opts.nb, item); });
item.addEventListener('dragover', function(e){
if (dragNoteId){ e.preventDefault(); item.classList.add('drop-here'); }
});
item.addEventListener('dragleave', function(){ item.classList.remove('drop-here'); });
item.addEventListener('drop', function(e){
if (!dragNoteId) return;
e.preventDefault();
item.classList.remove('drop-here');
moveNote(dragNoteId, opts.nb.id);
});
}
if (opts.trash){
item.addEventListener('dragover', function(e){
if (dragNoteId){ e.preventDefault(); item.classList.add('drop-here'); }
});
item.addEventListener('dragleave', function(){ item.classList.remove('drop-here'); });
item.addEventListener('drop', function(e){
if (!dragNoteId) return;
e.preventDefault();
item.classList.remove('drop-here');
deleteNote(dragNoteId);
});
}
item.addEventListener('click', onClick);
return item;
}
function renderSidebar(){
const body = $('#sb-body');
body.innerHTML = '';
body.appendChild(sbItem('inbox', 'All Notes', countFor('all'), state.ui.scope === 'all', function(){ setScope('all'); }));
state.notebooks.forEach(function(nb){
body.appendChild(sbItem('folder', nb.name, countFor(nb.id), state.ui.scope === nb.id, function(){ setScope(nb.id); }, { nb: nb }));
const notes = state.notes.filter(function(n){ return !n.deletedAt && n.notebookId === nb.id; });
if (!nb.collapsed && notes.length){
const wrap = div('sb-notes');
notes.forEach(function(n){
const row = div('sb-note' + (state.ui.noteId === n.id ? ' selected' : ''));
row.textContent = n.title || 'Untitled note';
row.title = n.title || 'Untitled note';
row.addEventListener('click', function(){ selectNote(n.id, { focus: false }); });
wrap.appendChild(row);
});
const add = div('sb-note sb-note-add');
add.innerHTML = icon('plus', 13) + '<span>New note</span>';
add.addEventListener('click', function(){ createNote(nb.id); });
wrap.appendChild(add);
body.appendChild(wrap);
}
});
body.appendChild(div('sb-sep'));
const trashed = state.notes.filter(function(n){ return n.deletedAt; });
body.appendChild(sbItem('trash', 'Trash', trashed.length, state.ui.scope === 'trash', function(){ setScope('trash'); }, { trash: true }));
if (state.ui.scope === 'trash' && trashed.length){
const wrap = div('sb-notes');
trashed.forEach(function(n){
const row = div('sb-note' + (state.ui.noteId === n.id ? ' selected' : ''));
row.textContent = n.title || 'Untitled note';
row.addEventListener('click', function(){ selectNote(n.id, { focus: false }); });
wrap.appendChild(row);
});
body.appendChild(wrap);
}
}
const SORT_LABELS = { modified: 'Modified', created: 'Created', alpha: 'A–Z' };
const SORT_ORDER = ['modified', 'created', 'alpha'];
function visibleNotes(){
const q = state.ui.search.trim().toLowerCase();
let notes = state.notes.slice();
if (state.ui.scope === 'trash') notes = notes.filter(function(n){ return n.deletedAt; });
else notes = notes.filter(function(n){
return !n.deletedAt && (state.ui.scope === 'all' || n.notebookId === state.ui.scope);
});
if (state.ui.tagFilter) notes = notes.filter(function(n){ return (n.tags || []).indexOf(state.ui.tagFilter) >= 0; });
if (q) notes = notes.filter(function(n){
return (n.title || '').toLowerCase().indexOf(q) >= 0 || htmlToText(n.html).toLowerCase().indexOf(q) >= 0;
});
const sortFn = {
modified: function(a, b){ return b.updatedAt - a.updatedAt; },
created: function(a, b){ return b.createdAt - a.createdAt; },
alpha: function(a, b){ return (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' }); },
}[state.ui.sort] || function(a, b){ return b.updatedAt - a.updatedAt; };
notes.sort(sortFn);
if (state.ui.scope !== 'trash'){
notes.sort(function(a, b){ return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0); });
}
return notes;
}
function noteCard(n){
const q = state.ui.search.trim();
const card = div('note-card' + (state.ui.noteId === n.id ? ' selected' : ''));
card.draggable = true;
const l1 = div('nc-line1');
const t = div('nc-title');
t.innerHTML = n.title ? highlightText(n.title, q) : '<span class="dim">Untitled note</span>';
l1.appendChild(t);
if (n.pinned && state.ui.scope !== 'trash'){
const pin = div('nc-pin');
pin.innerHTML = icon('pin', 12);
pin.title = 'Pinned';
l1.appendChild(pin);
}
const kebab = document.createElement('button');
kebab.className = 'nc-kebab';
kebab.title = 'Note options';
kebab.innerHTML = icon('kebab', 14);
kebab.addEventListener('click', function(e){
e.stopPropagation();
openNoteMenu(kebab, n);
});
l1.appendChild(kebab);
card.appendChild(l1);
const snip = div('nc-snippet');
const text = htmlToText(n.html);
snip.innerHTML = text ? makeSnippet(text, q) : '<span class="dim">No additional text</span>';
card.appendChild(snip);
const l2 = div('nc-line2');
const time = document.createElement('span');
time.textContent = fmtTime(n.updatedAt);
l2.appendChild(time);
if (state.ui.scope === 'all' && !n.deletedAt){
const nb = getNotebook(n.notebookId);
if (nb){
const el = div('nc-nb');
el.textContent = nb.name;
l2.appendChild(el);
}
}
if ((n.tags || []).length){
const tags = div('nc-tags');
n.tags.forEach(function(tag){
const b = document.createElement('button');
b.className = 'nc-tag';
b.textContent = tag;
b.title = 'Filter by #' + tag;
b.addEventListener('click', function(e){
e.stopPropagation();
state.ui.tagFilter = tag;
scheduleSave();
renderList();
});
tags.appendChild(b);
});
l2.appendChild(tags);
}
card.appendChild(l2);
card.addEventListener('click', function(){ selectNote(n.id, { focus: false }); });
card.addEventListener('dblclick', function(){
selectNote(n.id, { focus: false });
const ti = $('#note-title');
ti.focus();
ti.select();
});
card.addEventListener('dragstart', function(e){
dragNoteId = n.id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', n.id);
card.classList.add('dragging');
});
card.addEventListener('dragend', function(){
card.classList.remove('dragging');
dragNoteId = null;
});
return card;
}
function renderList(){
const titleEl = $('#lp-title');
titleEl.textContent = state.ui.scope === 'all' ? 'All Notes' : state.ui.scope === 'trash' ? 'Trash' : (getNotebook(state.ui.scope) || { name: '' }).name;
$('#btn-sort').innerHTML = icon('sort', 13) + SORT_LABELS[state.ui.sort];
const chip = $('#tag-chip');
if (state.ui.tagFilter){
chip.classList.remove('hidden');
chip.innerHTML = icon('tag', 12) + escapeHtml(state.ui.tagFilter) + '<span>×</span>';
} else {
chip.classList.add('hidden');
}
const list = $('#note-list');
list.innerHTML = '';
const notes = visibleNotes();
if (!notes.length){
const empty = div('list-empty');
if (state.ui.search.trim()){
empty.innerHTML = icon('search', 26) + '<div class="le-t">No results</div><div class="le-s">Nothing matches “' + escapeHtml(state.ui.search.trim()) + '”.</div>';
} else if (state.ui.scope === 'trash'){
empty.innerHTML = icon('trash', 26) + '<div class="le-t">Trash is empty</div><div class="le-s">Deleted notes land here.</div>';
} else {
empty.innerHTML = icon('note', 26) + '<div class="le-t">No notes here yet</div><div class="le-s">Capture your first thought.</div>';
const b = document.createElement('button');
b.className = 'btn-primary';
b.textContent = 'New note';
b.addEventListener('click', function(){ createNote(); });
empty.appendChild(b);
}
list.appendChild(empty);
return;
}
notes.forEach(function(n){ list.appendChild(noteCard(n)); });
}
let lastRenderedNoteId = null;
function renderEditor(){
const note = getNote(state.ui.noteId);
const head = $('#editor-head');
const scroll = $('#editor-scroll');
const empty = $('#empty-state');
if (!note){
head.classList.add('hidden');
scroll.classList.add('hidden');
empty.classList.remove('hidden');
lastRenderedNoteId = null;
return;
}
head.classList.remove('hidden');
scroll.classList.remove('hidden');
empty.classList.add('hidden');
if (note.id !== lastRenderedNoteId){
undoStack.length = 0;
redoStack.length = 0;
lastRenderedNoteId = note.id;
editor.innerHTML = note.html || '<p><br></p>';
normalizeContent(editor);
}
const ti = $('#note-title');
if (document.activeElement !== ti) ti.value = note.title || '';
$('#note-updated').textContent = 'Edited ' + fmtTime(note.updatedAt);
$('#btn-pin').classList.toggle('on', !!note.pinned);
renderTags(note);
updateToolbarState();
}
function renderTags(note){
const row = $('#tag-row');
row.innerHTML = '';
(note.tags || []).forEach(function(tag){
const pill = div('tag-pill');
pill.innerHTML = escapeHtml(tag);
const x = document.createElement('button');
x.className = 'tag-x';
x.innerHTML = '×';
x.title = 'Remove tag';
x.addEventListener('click', function(){ removeTag(note.id, tag); });
pill.appendChild(x);
row.appendChild(pill);
});
const input = document.createElement('input');
input.className = 'tag-add';
input.placeholder = 'Add tag…';
input.spellcheck = false;
input.addEventListener('keydown', function(e){
if (e.key === 'Enter'){
const v = input.value.trim();
if (v) addTag(note.id, v);
input.value = '';
} else if (e.key === 'Backspace' && !input.value){
const tags = note.tags || [];
if (tags.length) removeTag(note.id, tags[tags.length - 1]);
}
});
row.appendChild(input);
}
function updateMeta(note){
$('#note-updated').textContent = 'Edited ' + fmtTime(note.updatedAt);
}
function renderAll(){
renderSidebar();
renderList();
renderEditor();
}
/* content → model sync (called after every editor change) */
function afterEditorChange(){
const note = getNote(state.ui.noteId);
if (!note) return;
note.html = editor.innerHTML;
note.updatedAt = Date.now();
scheduleSave();
clearTimeout(listTimer);
listTimer = setTimeout(function(){
renderList();
updateMeta(note);
}, CONFIG.LIST_REFRESH_MS);
updateToolbarState();
}
/* ============================================================
12. TOAST
============================================================ */
let toastTimer = null;
function showToast(message, actionLabel, actionFn){
let toast = document.getElementById('toast');
if (!toast){
toast = document.createElement('div');
toast.id = 'toast';
document.body.appendChild(toast);
}
toast.innerHTML = '';
const msg = document.createElement('span');
msg.className = 'toast-msg';
msg.textContent = message;
toast.appendChild(msg);
if (actionLabel){
const b = document.createElement('button');
b.className = 'toast-action';
b.textContent = actionLabel;
b.addEventListener('click', function(){
hideToast();
actionFn();
});
toast.appendChild(b);
}
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(hideToast, 4500);
}
function hideToast(){
const toast = document.getElementById('toast');
if (toast) toast.classList.remove('show');
}
/* ============================================================
13. MENUS
============================================================ */
let activeMenu = null;
function closeMenu(){
if (activeMenu){
activeMenu.remove();
activeMenu = null;
}
document.removeEventListener('mousedown', menuOutsideHandler);
}
function menuOutsideHandler(e){
if (activeMenu && !activeMenu.contains(e.target)) closeMenu();
}
function openMenu(anchor, items){
closeMenu();
const menu = div('menu');
items.forEach(function(it){
if (it === 'sep'){ menu.appendChild(div('menu-sep')); return; }
const b = document.createElement('button');
b.className = 'menu-item' + (it.danger ? ' danger' : '');
b.innerHTML = (it.icon ? icon(it.icon, 14) : '') + '<span>' + escapeHtml(it.label) + '</span>';
b.addEventListener('click', function(){
closeMenu();
it.onClick();
});
menu.appendChild(b);
});
document.body.appendChild(menu);
const r = anchor.getBoundingClientRect();
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
let left = r.left;
let top = r.bottom + 4;
if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - mh - 4);
menu.style.left = Math.max(8, left) + 'px';
menu.style.top = top + 'px';
activeMenu = menu;
setTimeout(function(){
document.addEventListener('mousedown', menuOutsideHandler);
window.addEventListener('scroll', closeMenu, true);
}, 0);
}
function openNoteMenu(anchor, note){
const inTrash = state.ui.scope === 'trash' || !!note.deletedAt;
const items = [];
items.push({ label: note.pinned ? 'Unpin' : 'Pin to top', icon: 'pin', onClick: function(){ togglePin(note.id); } });
state.notebooks.forEach(function(nb){
if (!inTrash && nb.id === note.notebookId) return;
items.push({
label: (inTrash ? 'Restore to ' : 'Move to ') + nb.name,
icon: 'corner',
onClick: function(){ moveNote(note.id, nb.id); },
});
});
items.push('sep');
items.push({ label: 'Duplicate', icon: 'copy', onClick: function(){ duplicateNote(note.id); } });
items.push('sep');
if (inTrash){
items.push({ label: 'Restore', icon: 'restore', onClick: function(){ restoreNote(note.id); } });
items.push({ label: 'Delete forever', icon: 'trash', danger: true, onClick: function(){ destroyNote(note.id); } });
} else {
items.push({ label: 'Delete', icon: 'trash', danger: true, onClick: function(){ deleteNote(note.id); } });
}
openMenu(anchor, items);
}
function openNotebookMenu(anchor, nb){
openMenu(anchor, [
{ label: 'Rename', icon: 'edit', onClick: function(){
const item = document.querySelector('#sb-body .sb-item[data-nb="' + nb.id + '"]');
if (item) startRenameNotebook(nb, item);
} },
{ label: 'Delete notebook', icon: 'trash', danger: true, onClick: function(){ deleteNotebook(nb); } },
]);
}
/* ============================================================
13. WIRING — topbar, list, editor, keys, resize, theme
============================================================ */
function wireTopbar(){
$('#btn-sidebar').innerHTML = icon('sidebar', 16);
$('#btn-sidebar').addEventListener('click', function(){
state.ui.sidebarOpen = !state.ui.sidebarOpen;
applySidebarOpen();
scheduleSave();
});
$('#btn-theme').addEventListener('click', function(){
state.ui.theme = state.ui.theme === 'dark' ? 'light' : 'dark';
applyTheme();
scheduleSave();
});
$('#btn-add-notebook').innerHTML = icon('plus', 15);
$('#btn-add-notebook').title = 'New notebook';
$('#btn-add-notebook').addEventListener('click', createNotebook);
$('#brand-mark').innerHTML = icon('feather', 14);
$('#es-icon').innerHTML = icon('feather', 26);
$('#search-ic').innerHTML = icon('search', 14);
$('#btn-pin').innerHTML = icon('pin', 15);
$('#btn-note-menu').innerHTML = icon('kebab', 16);
$('#btn-new-note').innerHTML = icon('plus', 13) + 'New note';
}
function wireList(){
$('#btn-sort').addEventListener('click', function(){
const i = SORT_ORDER.indexOf(state.ui.sort);
state.ui.sort = SORT_ORDER[(i + 1) % SORT_ORDER.length];
scheduleSave();
renderList();
});
$('#btn-new-note').addEventListener('click', function(){ createNote(); });
$('#btn-empty-new').addEventListener('click', function(){ createNote(); });
$('#search').addEventListener('input', function(e){
state.ui.search = e.target.value;
scheduleSave();
renderList();
});
$('#tag-chip').addEventListener('click', function(){
state.ui.tagFilter = null;
scheduleSave();
renderList();
});
$('#btn-pin').addEventListener('click', function(){
if (state.ui.noteId) togglePin(state.ui.noteId);
});
$('#btn-note-menu').addEventListener('click', function(e){
const note = getNote(state.ui.noteId);
if (note) openNoteMenu(e.currentTarget, note);
});
}
function wireTitle(){
const ti = $('#note-title');
ti.addEventListener('input', function(){
const note = getNote(state.ui.noteId);
if (!note) return;
note.title = ti.value;
note.updatedAt = Date.now();
scheduleSave();
clearTimeout(listTimer);
listTimer = setTimeout(function(){
renderList();
updateMeta(note);
}, CONFIG.LIST_REFRESH_MS);
});
ti.addEventListener('keydown', function(e){
if (e.key === 'Enter'){ e.preventDefault(); editor.focus(); focusEditorEnd(); }
if (e.key === 'Escape'){ ti.blur(); }
});
}
function wireEditor(){
editor.addEventListener('beforeinput', function(e){
if (e.isComposing) return;
const t = e.inputType || '';
if (t.indexOf('insert') === 0 || t.indexOf('delete') === 0) pushSnapshot('typing');
});
editor.addEventListener('input', function(){ afterEditorChange(); });
editor.addEventListener('keydown', function(e){
const mod = e.ctrlKey || e.metaKey;
const k = (e.key || '').toLowerCase();
if (mod){
if (k === 'z' && !e.shiftKey){ e.preventDefault(); e.stopPropagation(); undo(); return; }
if (k === 'z' && e.shiftKey){ e.preventDefault(); e.stopPropagation(); redo(); return; }
if (k === 'y' && e.shiftKey){ e.preventDefault(); e.stopPropagation(); redo(); return; }
if (k === 'b'){ e.preventDefault(); e.stopPropagation(); applyInline('b'); return; }
if (k === 'i'){ e.preventDefault(); e.stopPropagation(); applyInline('i'); return; }
if (k === 'u'){ e.preventDefault(); e.stopPropagation(); applyInline('u'); return; }
}
if (e.key === ' '){ handleMarkdownShortcut(e); return; }
if (e.key === 'Enter'){ handleEnter(e); return; }
if (e.key === 'Tab'){ handleTab(e); return; }
if (e.key === 'Backspace'){ handleBackspace(e); return; }
});
editor.addEventListener('click', function(e){
const box = e.target.closest('.ck-box');
if (!box) return;
e.preventDefault();
const li = box.closest('li');
if (!li) return;
pushOp();
const checked = li.getAttribute('data-checked') === 'true';
li.setAttribute('data-checked', checked ? 'false' : 'true');
afterEditorChange();
});
editor.addEventListener('paste', handlePaste);
document.addEventListener('selectionchange', function(){
if (editorSel()) updateToolbarState();
});
}
function wireKeys(){
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){
closeMenu();
const s = $('#search');
if (document.activeElement === s){
s.value = '';
state.ui.search = '';
scheduleSave();
renderList();
s.blur();
}
return;
}
if (e.key === '/' && !(e.ctrlKey || e.metaKey || e.altKey)){
const t = e.target;
const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
if (!inField){
e.preventDefault();
$('#search').focus();
}
}
});
}
function initResize(){
const app = $('#app');
bindHandle($('#handle-sb'), function(x){
const w = clamp(x - app.getBoundingClientRect().left, CONFIG.MIN_SIDEBAR, CONFIG.MAX_SIDEBAR);
app.style.setProperty('--sbw', w + 'px');
state.ui.sidebarW = w;
});
bindHandle($('#handle-list'), function(x){
const rect = app.getBoundingClientRect();
const sbw = state.ui.sidebarOpen ? state.ui.sidebarW : 0;
const w = clamp(x - rect.left - sbw - 6, CONFIG.MIN_LIST, CONFIG.MAX_LIST);
app.style.setProperty('--listw', w + 'px');
state.ui.listW = w;
});
}
function bindHandle(el, onMove){
el.addEventListener('mousedown', function(e){
e.preventDefault();
el.classList.add('active');
document.body.classList.add('resizing');
const move = function(ev){ onMove(ev.clientX); };
const up = function(){
el.classList.remove('active');
document.body.classList.remove('resizing');
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
flushSave();
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
}
function applyTheme(){
document.documentElement.setAttribute('data-theme', state.ui.theme);
$('#btn-theme').innerHTML = icon(state.ui.theme === 'dark' ? 'sun' : 'moon', 16);
}
function applySidebarOpen(){
$('#app').classList.toggle('sb-closed', !state.ui.sidebarOpen);
}
/* ============================================================
14. INIT
============================================================ */
function init(){
loadState();
saveInd = $('#save-ind');
saveIndText = $('#save-ind-text');
saveIndIc = $('#save-ind-ic');
applyTheme();
const app = $('#app');
app.style.setProperty('--sbw', state.ui.sidebarW + 'px');
app.style.setProperty('--listw', state.ui.listW + 'px');
applySidebarOpen();
buildToolbar();
wireTopbar();
wireList();
wireTitle();
wireEditor();
wireKeys();
initResize();
renderAll();
window.addEventListener('beforeunload', flushSave);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"77fccd33-c8ad-4cc7-975f-47be441b404e","tokensIn":5785434,"tokensOut":161889,"tokensTotal":5947323,"cost":0,"turns":68,"toolCalls":67,"failedToolCalls":0,"timestamp":"2026-09-01T21:56:39.341Z"} -->