Notes
Ornith 1.5 35B A3B · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 80,242 bytes · SHA-256 a88054dcf5d0
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes</title>
<style>
/* =========================================================================
TUNABLE CONSTANTS — colors, fonts, autosave debounce, snippet length
========================================================================= */
:root{
/* Light theme (warm) */
--bg: #f9f6f1;
--sidebar-bg: #f2ede5;
--list-bg: #faf8f4;
--editor-bg: #fffdf9;
--text: #2c2722;
--text-muted: #8a8077;
--text-faint: #b3aaa0;
--border: #e7e0d5;
--border-strong: #ddd4c6;
--accent: #b8562f;
--accent-hover: #a64a27;
--accent-soft: #f4e7dd;
--accent-text: #a64a27;
--hover: #efe9df;
--active: #ece4d8;
--danger: #c0392b;
--danger-soft: #f6e4e1;
--highlight: #ffe19a;
--shadow: rgba(60, 45, 30, 0.10);
--serif: 'Iowan Old Style','Palatino Linotype','Palatino',Georgia,'Times New Roman',serif;
--sans: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';
--radius: 8px;
--radius-sm: 5px;
--sidebar-w: 250px;
--list-w: 300px;
--toolbar-h: 46px;
--transition: 160ms cubic-bezier(.4,0,.2,1);
}
[data-theme="dark"]{
--bg: #1c1a17;
--sidebar-bg: #232019;
--list-bg: #201d18;
--editor-bg: #191713;
--text: #eae3d8;
--text-muted: #9a9188;
--text-faint: #6f685f;
--border: #34302a;
--border-strong: #403a33;
--accent: #e0895f;
--accent-hover: #ef9c73;
--accent-soft: #3a2c22;
--accent-text: #e8a079;
--hover: #2b2720;
--active: #322d25;
--danger: #e5776a;
--danger-soft: #3d2622;
--highlight: #8a6a2e;
--shadow: rgba(0, 0, 0, 0.4);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
overflow: hidden;
font-size: 14px;
}
::selection { background: var(--accent-soft); }
/* ---------- Layout shell ---------- */
#app {
display: grid;
grid-template-columns: var(--sidebar-w) var(--list-w) 1fr;
grid-template-rows: 1fr;
height: 100vh;
width: 100vw;
transition: grid-template-columns var(--transition);
}
#app.sidebar-collapsed { grid-template-columns: 0 var(--list-w) 1fr; }
.pane { overflow: hidden; min-width: 0; height: 100%; }
#sidebar { background: var(--sidebar-bg); border-right: 1px solid var(--border); }
#noteList { background: var(--list-bg); border-right: 1px solid var(--border); }
#editorPane{ background: var(--editor-bg); position: relative; }
#app.sidebar-collapsed #sidebar { border-right: 0; }
/* ---------- Resize handles ---------- */
.handle {
width: 4px; cursor: col-resize; background: transparent;
position: relative; z-index: 20; flex-shrink: 0;
transition: background var(--transition);
}
.handle:hover, .handle.active { background: var(--accent); }
.handle-h { height: 4px; width: 100%; cursor: row-resize; }
/* ---------- Sidebar ---------- */
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 16px 10px;
}
.brand { font-weight: 700; font-size: 17px; letter-spacing: -.2px; display:flex; align-items:center; gap:8px; }
.brand .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.sidebar-actions { display:flex; gap:4px; }
.icon-btn {
border: 0; background: transparent; color: var(--text-muted); cursor: pointer;
width: 28px; height: 28px; border-radius: 6px; display:grid; place-items:center;
font-size: 17px; line-height: 1; transition: all var(--transition);
}
.icon-btn:hover { background: var(--hover); color: var(--text); }
.icon-btn:active { transform: scale(.92); }
.search-box { padding: 0 12px 12px; }
.search-input-wrap {
display:flex; align-items:center; gap:8px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.search-input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search-input-wrap svg { color: var(--text-faint); flex-shrink:0; }
#searchInput {
flex: 1; border: 0; background: transparent; color: var(--text);
font-family: var(--sans); font-size: 13px; outline: none; min-width: 0;
}
#searchInput::placeholder { color: var(--text-faint); }
.tree { overflow-y: auto; padding: 4px 8px 16px; }
.tree::-webkit-scrollbar { width: 8px; }
.tree::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; }
.tree-item {
border-radius: 7px; margin: 1px 0;
}
.tree-row {
display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 8px; border-radius: 7px; color: var(--text);
font-size: 13.4px; user-select: none; position: relative;
transition: background var(--transition);
}
.tree-row:hover { background: var(--hover); }
.tree-row.active { background: var(--active); }
.tree-item.all-notes > .tree-row.active { background: var(--accent-soft); color: var(--accent-text); }
.tree-row.active.all-notes-row { background: var(--accent-soft); color: var(--accent-text); }
.tree-row.trash-row.active { background: var(--danger-soft); color: var(--danger); }
.tree-row .chevron { width: 14px; color: var(--text-faint); transition: transform var(--transition); flex-shrink:0; }
.tree-item.collapsed .chevron { transform: rotate(-90deg); }
.tree-item.has-children > .tree-row .chevron { visibility: visible; }
.tree-item:not(.has-children) > .tree-row .chevron { visibility: hidden; }
.tree-row .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.tree-row .count { color: var(--text-faint); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.tree-row .row-actions { display:none; gap:2px; }
.tree-row:hover .row-actions { display:flex; }
.tree-row .row-actions .icon-btn { width: 22px; height: 22px; font-size: 13px; }
.tree-children { padding-left: 12px; overflow: hidden; }
.tree-item.collapsed > .tree-children { display: none; }
.drag-over > .tree-row { box-shadow: inset 0 0 0 1.5px var(--accent); background: var(--accent-soft); }
/* ---------- Note list ---------- */
.list-head {
padding: 16px 16px 10px; border-bottom: 1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; gap:8px;
background: var(--list-bg);
}
.list-title { font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.list-sub { color: var(--text-faint); font-size: 11.5px; font-weight: 500; }
.sort-wrap { display:flex; align-items:center; gap:4px; }
.sort-btn {
border:0; background: transparent; color: var(--text-muted); cursor:pointer;
font-family: var(--sans); font-size: 11.5px; padding: 5px 8px; border-radius: 6px;
display:flex; align-items:center; gap:4px; transition: all var(--transition);
}
.sort-btn:hover { background: var(--hover); color: var(--text); }
.sort-btn.active { background: var(--accent-soft); color: var(--accent-text); font-weight:600; }
.tag-filter-bar { padding: 8px 14px; display:none; }
.tag-filter-bar.show { display:flex; align-items:center; gap:8px; padding-bottom:10px; }
.active-filter-pill {
display:inline-flex; align-items:center; gap:6px; background: var(--accent-soft);
color: var(--accent-text); border-radius: 20px; padding: 3px 6px 3px 10px; font-size: 12px; font-weight:600;
}
.active-filter-pill .x { border:0; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:1px 3px; border-radius:50%; }
.active-filter-pill .x:hover { background: rgba(0,0,0,.08); }
.note-list { overflow-y: auto; padding: 8px; }
.note-list::-webkit-scrollbar { width: 8px; }
.note-list::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; }
.note-card {
padding: 11px 13px; border-radius: 10px; cursor: pointer; margin-bottom: 3px;
border: 1px solid transparent; transition: all var(--transition); position: relative;
}
.note-card:hover { background: var(--hover); }
.note-card.active { background: var(--accent-soft); border-color: rgba(184,86,47,.25); }
.note-card .nc-top { display:flex; align-items:baseline; gap:8px; margin-bottom:3px; }
.note-card .nc-title { font-weight: 600; font-size: 13.6px; color: var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.note-card .pin-icon { color: var(--accent); font-size: 11px; flex-shrink:0; }
.note-card .nc-meta { display:flex; align-items:center; gap:8px; }
.note-card .nc-snippet { color: var(--text-muted); font-size: 12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; line-height:1.35; }
.note-card .nc-time { color: var(--text-faint); font-size: 10.5px; flex-shrink:0; font-variant-numeric: tabular-nums; }
.note-card .nc-tags { display:flex; gap:4px; margin-top:6px; flex-wrap:wrap; }
.note-card .tag-chip {
font-size: 10.5px; color: var(--text-muted); background: var(--bg);
border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; cursor:pointer; transition: all var(--transition);
}
.note-card .tag-chip:hover { color: var(--accent-text); border-color: var(--accent); background: var(--accent-soft); }
.note-card .card-actions {
position:absolute; top:8px; right:8px; display:none; gap:2px;
}
.note-card:hover .card-actions { display:flex; }
.note-card.active .card-actions { display:flex; }
.card-actions .icon-btn { width:24px; height:24px; font-size:12px; background: var(--editor-bg); border:1px solid var(--border); }
.card-actions .icon-btn:hover { background: var(--hover); border-color: var(--border-strong); }
.note-card .pin-toggle.active { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.note-card .nc-snippet .mark, .note-title-cell .mark { background: var(--highlight); color: inherit; border-radius: 2px; padding: 0 1px; }
/* Empty note list */
.empty-list {
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
color: var(--text-faint); text-align:center; padding: 30px; gap:10px;
}
.empty-list .empty-ico { font-size: 34px; opacity:.5; }
/* ---------- Editor ---------- */
.editor-head {
height: var(--toolbar-h); border-bottom: 1px solid var(--border);
display:flex; align-items:center; gap:6px; padding: 0 12px;
background: var(--editor-bg);
}
.editor-head .spacer { flex:1; }
.saved-indicator { color: var(--text-faint); font-size: 11.5px; font-weight:500; display:flex; align-items:center; gap:5px; white-space:nowrap; }
.saved-indicator.saving { color: var(--accent); }
.saved-indicator .pulse { width:7px; height:7px; border-radius:50%; background: currentColor; opacity:.4; }
.saved-indicator.saving .pulse { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:.25} 50%{opacity:1} }
/* Toolbar */
.toolbar {
display:flex; align-items:center; gap:3px; flex-wrap:wrap;
padding: 6px 12px; border-bottom: 1px solid var(--border);
background: var(--editor-bg); position: sticky; top:0; z-index:5;
}
.toolbar .sep { width:1px; height:18px; background: var(--border); margin: 0 3px; }
.tb {
border:0; background: transparent; color: var(--text-muted); cursor:pointer;
min-width: 28px; height: 30px; padding: 0 7px; border-radius: 6px;
font-family: var(--sans); font-size: 13px; font-weight:600; display:inline-flex;
align-items:center; justify-content:center; gap:4px; transition: all var(--transition);
}
.tb:hover { background: var(--hover); color: var(--text); }
.tb:active { transform: scale(.93); }
.tb.active { background: var(--accent-soft); color: var(--accent-text); }
.tb .lbl { font-size: 12px; }
.tb[data-cmd="code"].active, .tb[data-cmd="code"]:hover { font-family: var(--serif); }
.tb svg { width:16px; height:16px; }
/* Editor body */
.editor-scroll { height: 100%; overflow-y: auto; }
.editor-inner { max-width: 780px; margin: 0 auto; padding: 34px 56px 120px; }
.editor-meta {
display:flex; align-items:center; gap:8px; padding: 0 56px 14px; flex-wrap:wrap;
}
.editor-meta .note-tag-input-wrap { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.editor-meta .tag {
display:inline-flex; align-items:center; gap:5px; background: var(--accent-soft);
color: var(--accent-text); border-radius: 6px; padding: 3px 5px 3px 9px; font-size: 12px; font-weight:500;
}
.editor-meta .tag .x { border:0; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:0 2px; opacity:.7; }
.editor-meta .tag .x:hover { opacity:1; }
.tag-input {
border:0; background:transparent; color: var(--text); font-family: var(--sans);
font-size: 12.5px; outline:none; min-width: 110px; height: 24px;
}
.tag-input::placeholder { color: var(--text-faint); }
#noteTitle {
width: 100%; border: 0; outline: none; font-family: var(--serif);
font-size: 30px; font-weight: 700; line-height: 1.2; color: var(--text);
margin-bottom: 4px; letter-spacing: -.4px;
}
#noteTitle::placeholder { color: var(--text-faint); }
#editor {
font-family: var(--serif);
font-size: 17px; line-height: 1.7; color: var(--text);
outline: none; min-height: 60vh; letter-spacing: .01em;
}
#editor:focus { outline: none; }
#editor :first-child { margin-top: 0; }
#editor p { margin: 0 0 12px; }
#editor h1 { font-size: 26px; font-weight: 700; margin: 22px 0 12px; line-height: 1.25; letter-spacing: -.3px; }
#editor h2 { font-size: 21px; font-weight: 700; margin: 20px 0 10px; line-height: 1.3; letter-spacing: -.2px; }
#editor h3 { font-size: 18px; font-weight: 700; margin: 18px 0 8px; line-height: 1.35; }
#editor blockquote {
border-left: 3px solid var(--accent); margin: 10px 0; padding: 4px 16px;
color: var(--text-muted); background: var(--hover); border-radius: 0 6px 6px 0;
}
#editor ul, #editor ol { margin: 8px 0 12px; padding-left: 26px; }
#editor li { margin: 3px 0; }
#editor code {
font-family: 'SF Mono',ui-monospace,Menlo,Consolas,monospace; font-size: 88%;
background: var(--accent-soft); color: var(--accent-text); padding: 2px 5px; border-radius: 4px;
}
#editor pre {
font-family: 'SF Mono',ui-monospace,Menlo,Consolas,monospace; font-size: 13.5px;
background: var(--hover); padding: 14px 16px; border-radius: 8px; overflow-x: auto; margin: 10px 0;
}
#editor hr { border: 0; border-top: 1px solid var(--border); margin: 22px 0; }
#editor a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
/* Checklist */
#editor li.check { list-style: none; position: relative; margin: 5px 0; }
#editor li.check .ckb {
position: absolute; left: -22px; top: 5px; width: 16px; height: 16px; accent-color: var(--accent);
cursor: pointer; flex-shrink: 0;
}
#editor li.check .ck-body { margin-left: 4px; }
#editor li.check .ck-body:empty::before { content:'List item'; color: var(--text-faint); }
#editor li.check[data-checked="true"] .ck-body,
#editor li.check .ckb:checked + .ck-body { text-decoration: line-through; color: var(--text-faint); opacity:.75; }
#editor li.check[data-checked="true"] .ck-body:empty::before { content:''; }
/* Placeholder when no note selected */
.no-note {
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
color: var(--text-faint); gap:12px; text-align:center; padding: 40px;
}
.no-note .big { font-size: 52px; opacity:.4; }
.no-note .msg { font-size: 16px; font-weight: 500; color: var(--text-muted); }
.no-note .hint { font-size: 12.5px; }
.no-note kbd {
background: var(--hover); border:1px solid var(--border); border-bottom-width:2px;
border-radius:5px; padding:1px 6px; font-family: var(--sans); font-size:11px; color: var(--text-muted);
}
/* Dropdown menu */
.menu { position: absolute; min-width: 180px; background: var(--editor-bg); border:1px solid var(--border);
border-radius: 10px; box-shadow: 0 10px 30px var(--shadow); padding: 5px; z-index: 100;
animation: menuIn 120ms ease; }
@keyframes menuIn { from{opacity:0; transform: translateY(-4px);} to{opacity:1; transform:none;} }
.menu-item {
display:flex; align-items:center; gap:10px; width:100%; border:0; background:transparent;
color: var(--text); font-family: var(--sans); font-size: 13px; padding: 8px 10px; border-radius: 7px;
cursor:pointer; text-align:left;
}
.menu-item:hover { background: var(--hover); }
.menu-item.danger { color: var(--danger); }
.menu-item.danger:hover { background: var(--danger-soft); }
.menu-item .menu-kbd { margin-left:auto; color: var(--text-faint); font-size:11px; }
.menu-sep { height:1px; background: var(--border); margin: 5px 3px; }
/* Modal / confirm */
.modal-backdrop { position:fixed; inset:0; background: rgba(30,25,18,.45); z-index: 200;
display:flex; align-items:center; justify-content:center; animation: fadeIn 120ms ease; }
@keyframes fadeIn { from{opacity:0;} to{opacity:1;} }
.modal { background: var(--editor-bg); border-radius: 12px; padding: 22px; width: 340px;
box-shadow: 0 20px 60px var(--shadow); }
.modal h3 { margin: 0 0 12px; font-size: 16px; }
.modal input[type=text] { width:100%; border:1px solid var(--border); background: var(--bg); color:var(--text);
border-radius: 8px; padding: 10px 12px; font-family: var(--sans); font-size: 14px; outline:none; }
.modal input[type=text]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:18px; }
.modal-actions button { border:0; border-radius: 8px; padding: 8px 16px; font-family: var(--sans); font-size:13px; cursor:pointer; font-weight:600; }
.btn-primary { background: var(--accent); color:#fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost { background: transparent; color: var(--text-muted); }
.btn-ghost:hover { background: var(--hover); color: var(--text); }
.new-note-btn {
margin: 4px 8px 10px; display:flex; align-items:center; justify-content:center; gap:7px;
width: calc(100% - 24px); border:0; background: var(--accent); color:#fff; cursor:pointer;
padding: 9px; border-radius: 8px; font-family: var(--sans); font-size: 13px; font-weight:600;
transition: all var(--transition);
}
.new-note-btn:hover { background: var(--accent-hover); }
.new-note-btn:active { transform: scale(.98); }
/* Toast */
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: var(--editor-bg); padding: 10px 18px; border-radius: 10px;
font-size: 13px; font-weight:500; z-index: 300; box-shadow: 0 8px 24px var(--shadow);
opacity: 0; transition: all 220ms ease; pointer-events:none; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<div id="app">
<!-- ===================== SIDEBAR ===================== -->
<section class="pane" id="sidebar">
<div class="sidebar-head">
<div class="brand"><span class="dot"></span> Notes</div>
<div class="sidebar-actions">
<button class="icon-btn" id="themeToggle" title="Toggle theme (Ctrl+Shift+D)" aria-label="Toggle theme">◐</button>
</div>
</div>
<div class="search-box">
<div class="search-input-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="searchInput" type="text" placeholder="Search notes…" aria-label="Search notes">
</div>
</div>
<div class="tree" id="notebookTree"></div>
</section>
<div class="handle" id="handleSidebar" title="Resize sidebar"></div>
<!-- ===================== NOTE LIST ===================== -->
<section class="pane" id="noteList">
<div class="list-head">
<div>
<div class="list-title" id="listTitle">All Notes</div>
<div class="list-sub" id="listCount"></div>
</div>
<div class="sort-wrap" id="sortWrap">
<button class="sort-btn" data-sort="modified" title="Sort by last modified">Modified ▾</button>
</div>
</div>
<div class="tag-filter-bar" id="tagFilterBar">
<span style="color:var(--text-muted);font-size:12px;font-weight:600;">Tag:</span>
<span class="active-filter-pill" id="activeFilterPill">
<span id="activeFilterText"></span>
<button class="x" id="clearFilter" aria-label="Clear filter">×</button>
</span>
</div>
<div class="note-list" id="noteListEl"></div>
</section>
<div class="handle" id="handleList" title="Resize note list"></div>
<!-- ===================== EDITOR ===================== -->
<section class="pane" id="editorPane">
<div id="editorApp"><!-- injected by JS --></div>
</section>
</div>
<div id="toast" class="toast"></div>
<script>
/* =========================================================================
NOTES APPLICATION — vanilla JS
Sections: Constants → Utils → Sanitizer → Data model/persistence →
Editor (formatting, toolbar, markdown shortcuts, undo/redo) →
Rendering (sidebar, list, editor) → Interactions → Init
========================================================================= */
(function(){
'use strict';
/* ---------- Tunable constants ---------- */
const CFG = {
STORAGE_KEY: 'notesapp.v1',
AUTOSAVE_DEBOUNCE: 450, // ms
SNIPPET_LENGTH: 110,
HISTORY_LIMIT: 200,
NOTE_DATE_FORMAT: 'short'
};
/* ---------- Tiny DOM helpers ---------- */
const $ = (s, el=document) => el.querySelector(s);
const $$ = (s, el=document) => Array.from(el.querySelectorAll(s));
const el = (tag, attrs={}, ...children) => {
const e = document.createElement(tag);
for (const [k,v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else if (v !== false && v != null) e.setAttribute(k, v === true ? '' : v);
}
for (const c of children) if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(String(c)));
return e;
};
let _uid = 0;
const uid = () => 'id_' + Date.now().toString(36) + (++_uid).toString(36) + Math.random().toString(36).slice(2,6);
/* ---------- HTML escaping ---------- */
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
/* =========================================================================
SANITIZER — drop foreign markup/scripts, keep supported subset
========================================================================= */
const ALLOWED_TAGS = new Set(['b','i','u','s','strike','em','strong','code','pre','h1','h2','h3',
'p','br','hr','ul','ol','li','blockquote','a','span','input','img']);
const ALLOWED_STYLE = new Set(['color','background-color','font-weight','font-style',
'text-decoration','font-family','font-size','text-align','line-height','margin','padding']);
const FORBID = ['script','style','link','meta','title','object','embed','iframe','base','form','input[type!=checkbox]','button','table','video','audio'];
function sanitizeHtml(html) {
if (!html) return '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
cleanNode(tpl.content);
return tpl.innerHTML.trim();
}
function isAllowed(tag) {
if (tag === 'input') return true; // checkbox inputs allowed
return ALLOWED_TAGS.has(tag);
}
function cleanNode(node) {
// Remove forbidden block/element types first (recursively so their subtree dies)
node.querySelectorAll('script,style,link,meta,title,object,embed,iframe,base,form,button,table,video,audio').forEach(n => n.remove());
const unwrap = [];
node.querySelectorAll('*').forEach(child => {
if (child.tagName === 'INPUT' && (child.getAttribute('type')||'').toLowerCase() !== 'checkbox') unwrap.push(child);
else if (!isAllowed(child.tagName.toLowerCase())) unwrap.push(child);
});
unwrap.forEach(n => {
while (n.firstChild) node.insertBefore(n.firstChild, n);
node.removeChild(n);
});
// Now clean attributes on remaining elements
node.querySelectorAll('*').forEach(cleanAttrs);
}
function cleanAttrs(el) {
const tag = el.tagName.toLowerCase();
const remove = [];
[...el.attributes].forEach(a => {
const name = a.name.toLowerCase();
if (name.startsWith('on')) { remove.push(name); return; }
if (name === 'style') {
const clean = cleanStyle(el.getAttribute('style'));
if (clean) el.setAttribute('style', clean); else remove.push('style');
return;
}
if (name === 'href' || name === 'src') {
const u = sanitizeUrl(el.getAttribute(name));
if (u == null) remove.push(name);
else el.setAttribute(name, u);
return;
}
if (tag === 'input') {
if (!['type','checked','class','id','data-checked','aria-label','role'].includes(name)) remove.push(name);
return;
}
if (!['class','id','dir','lang','title'].includes(name)) remove.push(name);
});
remove.forEach(n => el.removeAttribute(n));
}
function cleanStyle(css) {
const out = [];
css.split(';').forEach(rule => {
const i = rule.indexOf(':');
if (i < 0) return;
const prop = rule.slice(0,i).trim().toLowerCase();
const val = rule.slice(i+1).trim();
if (!prop || !val) return;
if (ALLOWED_STYLE.has(prop) && !/expression|javascript:|behavior/i.test(val)) out.push(prop + ': ' + val);
});
return out.join('; ');
}
function sanitizeUrl(u) {
if (!u) return null;
const s = u.trim().replace(/\s+/g, ' ').toLowerCase();
if (/^(https?|mailto|tel|ftp):/i.test(u.trim())) return u.trim();
if (s.startsWith('data:image/')) return u.trim();
if (s.startsWith('javascript:') || s.startsWith('data:') || s.startsWith('vbscript:') || s.startsWith('file:')) return null;
return u.trim(); // relative URLs allowed
}
/* ---------- Text extraction (for snippets + search) ---------- */
function extractText(htmlOrNode) {
let node;
if (typeof htmlOrNode === 'string') {
const t = document.createElement('template'); t.innerHTML = sanitizeHtml(htmlOrNode);
node = t.content;
} else node = htmlOrNode;
let txt = node.textContent || '';
// Unwrap checkbox labels for checklists so they don't pollute text
txt = txt.replace(/✓/g, '');
return txt.replace(/\s+/g, ' ').trim();
}
function snippet(html, len = CFG.SNIPPET_LENGTH) {
const t = extractText(html);
if (t.length <= len) return t;
return t.slice(0, len).replace(/\S*$/,'').trimEnd() + '…';
}
/* ---------- Time formatting ---------- */
function fmtTime(ts) {
const d = new Date(ts), now = new Date();
const sameDay = d.getFullYear()===now.getFullYear()&&d.getMonth()===now.getMonth()&&d.getDate()===now.getDate();
const y = new Date(now); y.setDate(now.getDate()-1);
const isYest = d.getFullYear()===y.getFullYear()&&d.getMonth()===y.getMonth()&&d.getDate()===y.getDate();
const time = d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
if (sameDay) return 'Today ' + time;
if (isYest) return 'Yesterday';
const wk = new Date(now); wk.setDate(now.getDate()-7);
if (d >= wk) return d.toLocaleDateString([], {weekday:'short'});
return d.toLocaleDateString([], {month:'short', day:'numeric', year: d.getFullYear()!==now.getFullYear()?'numeric':undefined});
}
function fmtDateFull(ts) {
return new Date(ts).toLocaleString([], {weekday:'long', year:'numeric', month:'long', day:'numeric', hour:'2-digit', minute:'2-digit'});
}
/* =========================================================================
DATA MODEL + PERSISTENCE
========================================================================= */
const defaultData = () => ({
version: 1,
settings: { theme: 'light', layout: { sidebarW: 250, listW: 300 } },
notebooks: [], // {id, name, collapsed, parentId}
notes: [], // {id, notebookId, title, html, tags:[], pinned, createdAt, updatedAt, trashed, trashedAt}
ui: { view: 'all', sort: 'modified', activeTag: null, selectedNote: null }
});
let state = null;
let saveTimer = null;
function load() {
try {
const raw = localStorage.getItem(CFG.STORAGE_KEY);
if (raw) {
const d = JSON.parse(raw);
state = Object.assign(defaultData(), d, {
settings: Object.assign(defaultData().settings, d.settings||{}),
ui: Object.assign(defaultData().ui, d.ui||{})
});
if (!Array.isArray(d.notebooks)) state.notebooks = d.notebooks||[];
if (!Array.isArray(d.notes)) state.notes = d.notes||[];
} else {
state = seedData();
}
} catch(e) { state = seedData(); }
applyTheme();
applyLayout();
}
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(state)); }
catch(e) { toast('Storage full — could not save'); }
}, 80);
}
function markDirty() { save(); }
function seedData() {
const d = defaultData();
const sys = mkNotebook('System');
const personal = mkNotebook('Personal');
const work = mkNotebook('Work');
const n1 = mkNote({ notebookId: personal.id, title: 'Welcome to Notes',
html: '<h1>Welcome 👋</h1><p>This is a <b>rich-text</b> note. Try selecting this word and press <i>Ctrl+B</i> to make it bold.</p><blockquote>A quick way to capture ideas.</blockquote><ul><li>Bulleted lists like this one</li><li>And numbered lists</li></ul><ol><li>First</li><li>Second</li></ol><p>Type <code>#</code> at line start, or <code>- </code> for a list, or <code>[] </code> for a checklist.</p><p>Check out the <b>checkbox</b> below — toggle it and reload to see it persist:</p><ul><li class="check" data-checked="true"><input type="checkbox" class="ckb"><div class="ck-body">Checklist items persist their state</div></li></ul><hr><p>Your notes are saved locally and automatically as you type.</p>',
tags: ['getting-started'], pinned: true,
created: Date.now()-3600e3, updated: Date.now()-1200e3 });
const n2 = mkNote({ notebookId: work.id, title: 'Project Ideas',
html: '<h2>Project Ideas</h2><p><b>Brainstorm:</b></p><ul><li>A notes app like this one</li><li>Something with 3D graphics</li></ul><p>Remember to <u>ship it</u>.</p>',
tags: ['ideas','work'], created: Date.now()-86400e3, updated: Date.now()-86400e3+1000 });
const n3 = mkNote({ notebookId: personal.id, title: 'Shopping List',
html: '<p><span class="check" >Check off items as you go:</span></p><ul><li class="check"><input type="checkbox" class="ckb"><div class="ck-body">Coffee beans</div></li><li class="check"><input type="checkbox" class="ckb"><div class="ck-body">Oat milk</div></li><li class="check"><input type="checkbox" class="ckb"><div class="ck-body">Sourdough</div></li></ul>',
tags: [], created: Date.now()-172800e3, updated: Date.now()-172800e3+5 });
d.notebooks.push(sys, personal, work);
d.notes.push(n1, n2, n3);
return d;
}
function mkNotebook(name) {
const nb = { id: uid(), name: name || 'New Notebook', collapsed: false, parentId: null };
state.notebooks.push(nb); return nb;
}
function mkNote(opts = {}) {
const now = Date.now();
const note = {
id: uid(),
notebookId: opts.notebookId || null,
title: opts.title || 'Untitled',
html: opts.html || '<p><br></p>',
tags: opts.tags || [],
pinned: !!opts.pinned,
createdAt: opts.created || now,
updatedAt: opts.updated || (opts.created || now),
trashed: !!opts.trashed,
trashedAt: opts.trashedAt || null
};
state.notes.push(note); return note;
}
/* =========================================================================
NOTIFICATIONS / TOAST
========================================================================= */
let toastTimer = null;
function toast(msg) {
const t = $('#toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
/* =========================================================================
EDITOR — formatting, toolbar, markdown shortcuts, undo/redo, paste
========================================================================= */
let editor = null, titleEl = null, savedIndicator = null;
let hist = [], histIndex = -1, isRestoring = false;
let autosaveTimer = null, lastContentKey = '';
function initEditor() {
editor = $('#editor');
titleEl = $('#noteTitle');
savedIndicator = $('#savedIndicator');
editor.addEventListener('input', onEditorInput);
editor.addEventListener('keydown', onEditorKeydown);
editor.addEventListener('keyup', () => updateToolbar());
editor.addEventListener('mouseup', () => updateToolbar());
editor.addEventListener('paste', onPaste);
editor.addEventListener('blur', () => { updateToolbar(); });
}
function openNote(note) {
rebuildEditorDOM();
if (!note) {
showNoNote(); return;
}
hist = []; histIndex = -1; isRestoring = false;
titleEl.value = note.title;
editor.innerHTML = note.html;
histIndex = -1; // will push on first change / now
pushHistory(true);
updateSaved('saved');
updateToolbar();
restoreEditorSelection();
}
function rebuildEditorDOM() {
const host = $('#editorApp');
if (host.querySelector('#editorAppShell')) return; // already built
host.innerHTML = '';
host.append(buildEditorShell());
editor = $('#editor'); titleEl = $('#noteTitle'); savedIndicator = $('#savedIndicator');
editor.addEventListener('input', onEditorInput);
editor.addEventListener('keydown', onEditorKeydown);
editor.addEventListener('keyup', () => updateToolbar());
editor.addEventListener('mouseup', () => updateToolbar());
editor.addEventListener('paste', onPaste);
editor.addEventListener('blur', () => updateToolbar());
}
function showNoNote() {
const host = $('#editorApp');
host.innerHTML = '';
host.append(el('div', {className:'no-note'},
el('div', {className:'big'}, '📝'),
el('div', {className:'msg'}, 'Select or create a note'),
el('div', {className:'hint'}, 'Press <kbd>Ctrl+N</kbd> for a new note, or pick one from the list.')));
}
/* Build the editor shell (header + toolbar + body). Rebuilt when note changes. */
function buildEditorShell() {
const shell = el('div', {id:'editorAppShell'});
shell.append(buildEditorHead());
shell.append(buildToolbar());
const scroll = el('div', {className:'editor-scroll'});
const inner = el('div', {className:'editor-inner'});
const meta = el('div', {className:'editor-meta'});
inner.append(meta, titleEl2(), editorWrap());
scroll.append(inner);
shell.append(scroll);
return shell;
}
function buildEditorHead() {
const head = el('div', {className:'editor-head'});
head.append(
el('button', {className:'icon-btn', title:'Back to notes (Esc)', onclick: goBack}, arrowIcon()),
el('span', {className:'spacer'}),
el('div', {className:'saved-indicator', id:'savedIndicator'},
el('span', {className:'pulse'}), 'Saved'),
el('button', {className:'icon-btn', title:'More', id:'moreMenuBtn'}, dotMenuIcon())
);
$('#moreMenuBtn').addEventListener('click', (e)=>{ e.stopPropagation(); toggleMenu('moreMenu', moreMenuItems()); });
return head;
}
function titleEl2(){ titleEl = el('input',{id:'noteTitle', type:'text', placeholder:'Untitled'});
titleEl.addEventListener('input', onTitleInput); return titleEl; }
function editorWrap(){ return el('div',{id:'editorWrap'}, el('div',{id:'editor'})); }
function onTitleInput(e){
const note = currentNote(); if(!note) return;
note.title = e.target.value.trim() || 'Untitled';
note.updatedAt = Date.now();
debounceAutosave();
}
/* ---------- Formatting commands ---------- */
function format(cmd, arg) {
editor.focus();
if (cmd === 'undo') { doUndo(); return; }
if (cmd === 'redo') { doRedo(); return; }
if (cmd === 'code') { toggleCode(); return; }
if (cmd === 'h1' || cmd === 'h2' || cmd === 'h3' || cmd === 'quote' || cmd === 'hr' || cmd === 'removeFormat') {
runBlockFormat(cmd, arg); return;
}
if (cmd === 'bullet' || cmd === 'number') { toggleList(cmd); return; }
if (cmd === 'checklist') { toggleChecklist(); return; }
document.execCommand(cmd, false, arg || null);
pushHistory();
}
function runBlockFormat(cmd, arg) {
if (cmd === 'hr') {
document.execCommand('insertHorizontalRule'); return;
}
let tag = {h1:'h1',h2:'h2',h3:'h3',quote:'blockquote'}[cmd];
document.execCommand('formatBlock', false, tag);
}
function toggleList(kind) {
const active = window.getSelection();
const li = active.anchorNode && active.anchorNode.closest ? active.anchorNode.closest('li') : null;
// If a checklist item is active, convert it to a normal list first
if (li && li.classList.contains('check')) {
li.classList.remove('check'); li.removeAttribute('data-checked');
const inp = li.querySelector('.ckb'); if (inp) inp.remove();
const body = li.querySelector('.ck-body');
if (body) { while(body.firstChild) li.appendChild(body.firstChild); body.remove(); }
}
document.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');
pushHistory();
}
function toggleCode() {
const sel = window.getSelection();
if (!sel.rangeCount) { document.execCommand('insertText', false, '`'); return; }
const anchor = sel.anchorNode && sel.anchorNode.closest ? sel.anchorNode.closest('code') : null;
if (anchor) {
const txt = anchor.textContent;
anchor.replaceWith(document.createTextNode(txt));
} else {
document.execCommand('insertText', false, '``');
// select between the backticks
}
pushHistory();
}
function toggleChecklist() {
const sel = window.getSelection();
const li = sel.anchorNode && sel.anchorNode.closest ? sel.anchorNode.closest('li') : null;
if (li && li.closest('#editor')) {
if (li.classList.contains('check')) { checklistToLi(li); }
else { liToChecklist(li); }
pushHistory(); return;
}
// Not in a list: create one
document.execCommand('insertUnorderedList');
const newLi = window.getSelection().anchorNode?.closest?.('li');
if (newLi && newLi.closest('#editor')) liToChecklist(newLi);
}
function liToChecklist(li) {
const body = el('div', {className:'ck-body'});
while (li.firstChild) body.appendChild(li.firstChild);
const inp = el('input', {type:'checkbox', className:'ckb', role:'checkbox', 'aria-label':'checklist item'});
inp.addEventListener('change', onCheckChange);
li.prepend(inp); li.appendChild(body);
li.classList.add('check'); li.setAttribute('data-checked','false');
}
function checklistToLi(li) {
const inp = li.querySelector('.ckb'); if (inp) inp.remove();
const body = li.querySelector('.ck-body');
if (body) { while(body.firstChild) li.appendChild(body.firstChild); body.remove(); }
li.classList.remove('check'); li.removeAttribute('data-checked');
}
function onCheckChange(e) {
const li = e.target.closest('li.check');
li.setAttribute('data-checked', e.target.checked ? 'true' : 'false');
debounceAutosave();
}
/* ---------- Paste sanitize ---------- */
function onPaste(e) {
const clip = e.clipboardData;
if (!clip) return;
const html = clip.getData('text/html');
const plain = clip.getData('text/plain');
e.preventDefault();
let out;
if (html) {
out = sanitizeHtml(html);
if (!out.trim()) out = esc(plain) || esc(clip.getData('text/rtf')||'');
} else {
out = esc(plain);
}
document.execCommand('insertHTML', false, out);
pushHistory();
}
/* ---------- Markdown input shortcuts ---------- */
function onEditorKeydown(e) {
// Keyboard shortcuts
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.altKey) {
const k = e.key.toLowerCase();
if (k === 'z') { e.preventDefault(); e.shiftKey ? doRedo() : doUndo(); return; }
if (k === 'y') { e.preventDefault(); doRedo(); return; }
if (k === 'b') { e.preventDefault(); format('bold'); return; }
if (k === 'i') { e.preventDefault(); format('italic'); return; }
if (k === 'u') { e.preventDefault(); format('underline'); return; }
}
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
handleEnter(e);
}
if (e.key === 'Escape') {
closeMenus();
const li = window.getSelection().anchorNode?.closest?.('li');
if (li && li.closest('#editor') && isListItemEmpty(li)) {
e.preventDefault();
document.execCommand('formatBlock', false, 'p');
pushHistory();
}
}
}
// Detect a markdown prefix at the START of the current block and convert on space.
function onSpaceTrigger() {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const block = sel.anchorNode?.closest?.('p,h1,h2,h3,li,blockquote,pre');
if (!block || !block.closest('#editor')) return;
const text = block.textContent;
// Only trigger if the prefix is exactly the trigger (nothing typed after it yet)
const m = text.match(/^\s*(#{1,3}\s|[-*]\s|\d+\.\s|\[\s*\]\s|>\s|---\s|~~~\s)$/);
if (!m) return;
const prefix = m[1];
const trigger = prefix.trim();
// Determine target type
let type = null;
if (/^#{1,3}$/.test(trigger)) type = 'heading';
else if (/^[-*]$/.test(trigger)) type = 'bullet';
else if (/^\d+\.$/.test(trigger)) type = 'number';
else if (/^\[$/.test(trigger) || /^\]\s?$/.test(trigger)) type = 'checklist';
else if (/^>$/.test(trigger)) type = 'quote';
else if (/^---$/.test(trigger)) type = 'hr';
else if (/^~~~$/.test(trigger)) type = 'pre';
if (!type) return;
// Convert: remove prefix, apply format.
document.execCommand('selectAll');
const range = document.createRange();
const sel2 = window.getSelection();
// Rebuild: select from block start to caret, delete prefix
const carets = sel2.getRangeAt(0);
range.selectNodeContents(block);
range.setEnd(carets.startContainer, carets.startOffset);
// But caret is at prefix end (space not inserted since we're in keydown). Actually space not yet inserted.
// We'll instead just delete the prefix text nodes.
range.collapse(true);
sel2.removeAllRanges();
sel2.addRange(carets);
// Simplest robust approach: clear block then set new content
applyMarkdown(type, block, prefix.trim());
}
function applyMarkdown(type, block, trigger) {
// Remove any leading spaces and the trigger from the block, keeping content
const content = block.textContent.replace(/^\s*#{1,3}\s?/, '').replace(/^\s*[-*]\s?/, '').replace(/^\s*\d+\.\s?/, '')
.replace(/^\s*\[\s*\]\s?/, '').replace(/^\s*>\s?/, '').replace(/^\s*---\s?/, '').replace(/^\s*~~~\s?/, '');
const carets = window.getSelection().getRangeAt(0);
const newEl = document.createElement(type === 'hr' ? 'hr' : type === 'pre' ? 'pre' :
type === 'quote' ? 'blockquote' : type === 'heading' ? ('h'+(content?3:1)) : 'ul');
if (type === 'hr') {
carets.deleteContents();
carets.insertEnd ? null : null;
document.execCommand('insertHorizontalRule');
return;
}
if (type === 'bullet' || type === 'number') {
const listType = type === 'bullet' ? 'ul' : 'ol';
const li = document.createElement('li');
const text = document.createTextNode(content);
li.appendChild(text);
newEl.appendChild(li);
carets.deleteContents();
carets.insertNode(newEl);
// place caret at end of li text
const r = document.createRange();
r.setStart(text, text.nodeValue.length); r.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
} else if (type === 'checklist') {
const ul = document.createElement('ul');
const li = document.createElement('li');
li.className = 'check'; li.setAttribute('data-checked','false');
const inp = document.createElement('input');
inp.type = 'checkbox'; inp.className = 'ckb'; inp.setAttribute('role','checkbox');
inp.addEventListener('change', onCheckChange);
const body = document.createElement('div'); body.className = 'ck-body';
body.appendChild(document.createTextNode(content));
li.appendChild(inp); li.appendChild(body); ul.appendChild(li);
carets.deleteContents(); carets.insertNode(ul);
const r = document.createRange();
r.setStart(body, 0); r.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
} else {
// heading / quote / pre
if (type === 'heading') newEl.tagName = 'H'+(content?3:1);
newEl.textContent = content;
carets.deleteContents(); carets.insertNode(newEl);
const r = document.createRange();
r.selectNodeContents(newEl); r.collapse(false);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
}
pushHistory();
}
// Simpler, reliable markdown shortcut handler using keydown on space
function handleMarkdownShortcut(e) {
if (e.key !== ' ' && e.key !== 'Spacebar') return;
const sel = window.getSelection();
if (!sel.rangeCount) return;
const block = sel.anchorNode?.closest?.('p,h1,h2,h3,li,blockquote,pre');
if (!block || !block.closest('#editor')) return;
const text = block.textContent;
const m = text.match(/^\s*(#{1,3}\s|[-*]\s|\d+\.\s|\[\s*\]\s|>\s|---\s|~~~\s)$/);
if (!m) return;
e.preventDefault();
const trigger = m[1].trim();
let type = 'heading';
if (/^#{1,3}$/.test(trigger)) type = 'heading';
else if (/^[-*]$/.test(trigger)) type = 'bullet';
else if (/^\d+\.$/.test(trigger)) type = 'number';
else if (/^\[$/.test(trigger) || /^\]\s?$/.test(trigger)) type = 'checklist';
else if (/^>$/.test(trigger)) type = 'quote';
else if (/^---$/.test(trigger)) type = 'hr';
else if (/^~~~$/.test(trigger)) type = 'pre';
applyMarkdown(type, block, trigger);
}
function handleEnter(e) {
const sel = window.getSelection();
const li = sel.anchorNode?.closest?.('li');
if (li && li.closest('#editor')) {
const atEnd = caretAtEnd(li);
const isEmpty = isListItemEmpty(li);
if (isEmpty) {
// Enter on empty item → exit list (let browser do formatBlock to p)
return; // allow default
}
if (atEnd) {
// Let default create a new list item (continues list). Allow default.
return;
}
return; // allow default line break within item
}
// At block end (non-list) → allow default (new paragraph via block)
}
function caretAtEnd(node) {
const sel = window.getSelection();
if (!sel.rangeCount) return false;
const r = sel.getRangeAt(0);
if (r.collapsed) {
const after = document.createRange();
after.setStart(r.endContainer, r.endOffset);
after.setEnd(node, node.childNodes.length);
return after.toString().trim() === '';
}
return false;
}
function isListItemEmpty(li) {
const sel = window.getSelection();
if (!sel.rangeCount) return false;
const r = sel.getRangeAt(0);
if (!r.collapsed) return false;
// Caret must be at the very start of the li's content
const before = document.createRange();
before.setStart(li, 0); before.setEnd(r.startContainer, r.startOffset);
const inp = li.querySelector('.ckb');
let inpLen = inp ? 1 : 0;
return before.toString().replace(/\u00a0/g,'').length <= inpLen;
}
/* ---------- Undo / Redo (custom snapshot history) ---------- */
function getEditorHtml() { return sanitizeHtml(editor.innerHTML); }
function pushHistory(silent) {
if (isRestoring) return;
const html = getEditorHtml();
const key = html + '||' + titleEl.value;
if (key === lastContentKey && histIndex >= 0) {
// same content (e.g., formatting no-op); update top snapshot in place
if (hist[histIndex]) { hist[histIndex].sel = saveSelection(); }
return;
}
hist = hist.slice(0, histIndex + 1);
hist.push({ html, title: titleEl.value, sel: saveSelection() });
if (hist.length > CFG.HISTORY_LIMIT) hist.shift(), histIndex--;
histIndex = hist.length - 1;
lastContentKey = key;
if (!silent) debounceAutosave();
}
function doUndo() {
if (histIndex > 0) {
histIndex--;
restoreSnapshot(hist[histIndex]);
}
}
function doRedo() {
if (histIndex < hist.length - 1) {
histIndex++;
restoreSnapshot(hist[histIndex]);
}
}
function restoreSnapshot(snap) {
isRestoring = true;
titleEl.value = snap.title || 'Untitled';
editor.innerHTML = snap.html;
restoreSelection(snap.sel);
lastContentKey = snap.html + '||' + snap.title;
histIndex = histIndex; // keep
isRestoring = false;
autosaveImmediately();
updateToolbar();
}
function debounceAutosave() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(autosaveImmediately, CFG.AUTOSAVE_DEBOUNCE);
updateSaved('saving');
}
function autosaveImmediately() {
const note = currentNote();
if (!note) return;
const html = getEditorHtml();
const title = (titleEl.value || '').trim() || 'Untitled';
if (note.html === html && note.title === title) { updateSaved('saved'); return; }
note.html = html; note.title = title; note.updatedAt = Date.now();
markDirty();
renderNoteList();
updateSaved('saved');
}
function onEditorInput() {
pushHistory();
}
/* ---------- Selection save/restore by text offset ---------- */
function saveSelection() {
const sel = window.getSelection();
if (!sel.rangeCount || !editor.contains(sel.anchorNode)) return {start:0,end:0};
const s = textOffset(sel.anchorNode, sel.anchorOffset);
const en = textOffset(sel.focusNode, sel.focusOffset);
return { start: Math.min(s,en), end: Math.max(s,en) };
}
function textOffset(node, offset) {
if (node.nodeType === 3) return offset;
let count = 0;
for (let i=0;i<node.childNodes.length;i++){
const c = node.childNodes[i];
if (c.nodeName === 'INPUT') { /* checkbox = 0 width in our model */ continue; }
if (i < offset) count += c.nodeType===3 ? c.nodeLength : textOffset(c, c.nodeType===3?c.nodeLength:0);
else if (i === offset) { count += textOffset(c, 0); break; }
}
return count;
}
function restoreSelection(s) {
if (!s) return;
const target = {node:null}, off = {v:0};
let start = Math.max(0, Math.min(s.start||0, textLen(editor)));
(function walk(node){
if (target.node) return;
if (node.nodeType === 3) {
const len = node.nodeLength;
if (start < off.v + len) { target.node = node; off.v = start - off.v; }
else off.v += len;
} else {
for (const c of node.childNodes) { if (c.nodeName==='INPUT') continue; walk(c); if (target.node) return; }
}
})(editor);
if (!target.node) {
const tn = lastTextNode(editor);
if (tn) { target.node = tn; off.v = Math.min(start, tn.nodeLength); }
else return;
}
const range = document.createRange();
range.setStart(target.node, Math.min(off.v, target.node.nodeLength));
range.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
}
function textLen(node){ let n=0; (function w(x){ for(const c of x.childNodes){ if(c.nodeName==='INPUT')continue; if(c.nodeType===3)n+=c.nodeLength; else w(c);} })(node); return n; }
function lastTextNode(node){ let last=null; (function w(x){ for(const c of x.childNodes){ if(c.nodeName==='INPUT')continue; if(c.nodeType===3)last=c; else w(c); if(last)return;} })(node); return last; }
/* ---------- Toolbar state ---------- */
function updateToolbar() {
if (!editor) return;
const sel = window.getSelection();
const inEditor = sel.anchorNode && editor.contains(sel.anchorNode);
const set = (cmd, on) => { const b = $('[data-cmd="'+cmd+'"]', editor.closest('#editorApp')); if(b) b.classList.toggle('active', on); };
if (!inEditor) { ['bold','italic','underline','strike','code','h1','h2','h3','bullet','number','checklist','quote'].forEach(c=>set(c,false)); return; }
set('bold', document.queryCommandState('bold'));
set('italic', document.queryCommandState('italic'));
set('underline', document.queryCommandState('underline'));
set('strike', document.queryCommandState('strikeThrough') || document.queryCommandState('strikethrough'));
const anchor = sel.anchorNode;
set('code', !!(anchor && anchor.closest && anchor.closest('code')));
const block = anchor && anchor.closest ? anchor.closest('h1,h2,h3') : null;
set('h1', block && block.tagName === 'H1');
set('h2', block && block.tagName === 'H2');
set('h3', block && block.tagName === 'H3');
set('bullet', document.queryCommandState('insertUnorderedList'));
set('number', document.queryCommandState('insertOrderedList'));
set('checklist', !!(anchor && anchor.closest && anchor.closest('li.check')));
set('quote', !!(anchor && anchor.closest && anchor.closest('blockquote')));
}
function updateSaved(state) {
if (!savedIndicator) return;
const pulse = $('.pulse', savedIndicator);
if (state === 'saving') { savedIndicator.classList.add('saving'); savedIndicator.lastChild.textContent = ' Saving…'; }
else { savedIndicator.classList.remove('saving'); savedIndicator.lastChild.textContent = ' Saved'; }
}
/* =========================================================================
NOTE OPERATIONS (single source of truth mutations)
========================================================================= */
function currentNote() {
const id = state.ui.selectedNote;
if (!id) return null;
return state.notes.find(n => n.id === id);
}
function getNotebooks() { return state.notebooks; }
function childrenOf(pid) { return state.notebooks.filter(nb => nb.parentId === pid); }
function findNotebook(id) { return state.notebooks.find(nb => nb.id === id); }
function createNote() {
let notebookId = null;
if (state.ui.view === 'notebook') notebookId = state.ui.viewId;
else if (state.ui.view === 'tag') notebookId = null;
else if (state.ui.view === 'trash') notebookId = null;
const note = mkNote({ notebookId, title: '', created: Date.now(), updated: Date.now() });
state.ui.selectedNote = note.id;
state.ui.view = 'notebook'; state.ui.viewId = note.notebookId;
markDirty();
renderAll();
setTimeout(()=>{ if(titleEl) titleEl.focus(); }, 30);
}
function selectNote(id) {
state.ui.selectedNote = id;
// If the note is in trash, view it in trash context
const note = state.notes.find(n=>n.id===id);
if (note && note.trashed) { state.ui.view='trash'; state.ui.viewId=null; }
markDirty();
renderAll();
}
function selectNotebook(id) {
state.ui.view = 'notebook'; state.ui.viewId = id; state.ui.activeTag = null;
markDirty(); renderAll();
}
function viewAllNotes() { state.ui.view='all'; state.ui.viewId=null; state.ui.activeTag=null; markDirty(); renderAll(); }
function viewTrash() { state.ui.view='trash'; state.ui.viewId=null; state.ui.activeTag=null; markDirty(); renderAll(); }
function setSort(s) { state.ui.sort = s; markDirty(); renderAll(); }
function setActiveTag(t) {
state.ui.activeTag = (state.ui.activeTag === t) ? null : t;
markDirty(); renderAll();
}
function clearTagFilter() { state.ui.activeTag = null; markDirty(); renderAll(); }
function togglePin(note) {
note.pinned = !note.pinned; note.updatedAt = Date.now();
markDirty(); renderAll();
}
function duplicateNote(note) {
const copy = mkNote({
notebookId: note.notebookId, title: note.title + ' copy',
html: note.html, tags: [...note.tags], pinned:false,
created: Date.now(), updated: Date.now()
});
state.ui.selectedNote = copy.id;
markDirty(); renderAll();
toast('Note duplicated');
}
function deleteNote(note) {
if (note.trashed) {
// permanent delete
state.notes = state.notes.filter(n => n.id !== note.id);
if (state.ui.selectedNote === note.id) state.ui.selectedNote = null;
markDirty(); renderAll();
toast('Deleted permanently');
} else {
note.trashed = true; note.trashedAt = Date.now();
if (state.ui.selectedNote === note.id) state.ui.selectedNote = null;
markDirty(); renderAll();
toast('Moved to Trash');
}
}
function restoreNote(note) {
note.trashed = false; note.trashedAt = null; note.updatedAt = Date.now();
markDirty(); renderAll();
toast('Restored from Trash');
}
function moveNote(note, notebookId) {
note.notebookId = notebookId; note.updatedAt = Date.now();
markDirty(); renderAll();
toast(notebookId ? 'Moved to notebook' : 'Moved to All Notes');
}
function renameNotebook(nb) { promptRename(nb); }
function deleteNotebook(nb) {
confirmModal('Delete notebook?', `"${nb.name}" and its notes will be moved to Trash.`, 'Delete', ()=>{
const kids = childrenOf(nb.id);
kids.forEach(k => deleteNotebookRecursive(k));
// Move notes directly under this notebook to trash? Spec: move notes to trash.
state.notes.filter(n => n.notebookId === nb.id).forEach(n => { n.trashed=true; n.trashedAt=Date.now(); });
state.notebooks = state.notebooks.filter(x => x.id !== nb.id);
if (state.ui.view==='notebook' && state.ui.viewId===nb.id) viewAllNotes();
markDirty(); renderAll();
toast('Notebook deleted');
});
}
function deleteNotebookRecursive(nb) {
childrenOf(nb.id).forEach(k => deleteNotebookRecursive(k));
state.notebooks = state.notebooks.filter(x => x.id !== nb.id);
}
function addNotebook() {
const nb = mkNotebook('New Notebook');
nb.collapsed = false;
markDirty(); renderAll();
setTimeout(()=>promptRename(nb), 40);
}
function promptRename(nb) {
modalInput('Rename', nb.name || 'New Notebook', (val)=>{
const t = (val||'').trim();
if (t) { nb.name = t; markDirty(); renderAll(); }
});
}
/* =========================================================================
RENDERING — SIDEBAR
========================================================================= */
function renderSidebar() {
const tree = $('#notebookTree');
tree.innerHTML = '';
// New note button
// (placed in list pane actually)
// All Notes
const all = el('div', {className:'tree-item all-notes'+(state.ui.view==='all'?' active':'')});
all.append(el('div', {className:'tree-row all-notes-row'+(state.ui.view==='all'?' active':'')},
el('span',{className:'chevron'}, chevronIcon()),
el('span',{className:'label'}, 'All Notes'),
el('span',{className:'count'}, String(countNotes(false)))
));
all.querySelector('.tree-row').addEventListener('click', viewAllNotes);
tree.append(all);
// Root notebooks (parentId === null)
const roots = state.notebooks.filter(nb => !nb.parentId);
roots.forEach(nb => tree.append(renderNotebookItem(nb)));
// Trash
const trashCount = state.notes.filter(n=>n.trashed).length;
const trash = el('div', {className:'tree-item trash'+(state.ui.view==='trash'?' active':'')});
trash.append(el('div', {className:'tree-row trash-row'+(state.ui.view==='trash'?' active':'')},
el('span',{className:'label'}, '🗑 Trash'),
el('span',{className:'count'}, trashCount?String(trashCount):'')
));
trash.querySelector('.tree-row').addEventListener('click', viewTrash);
tree.append(trash);
}
function renderNotebookItem(nb) {
const item = el('div', {className:'tree-item'+(nb.collapsed?' collapsed':'')+(childrenOf(nb.id).length?' has-children':'')});
item.dataset.id = nb.id;
const count = countNotesForNotebook(nb.id);
const row = el('div', {className:'tree-row'+(state.ui.view==='notebook' && state.ui.viewId===nb.id?' active':'')});
row.append(
el('span',{className:'chevron'}, chevronIcon()),
el('span',{className:'label'}, nb.name),
el('span',{className:'count'}, count?String(count):'')
);
// actions
const actions = el('div',{className:'row-actions'});
const ren = el('button',{className:'icon-btn', title:'Rename'}, '✎');
ren.addEventListener('click', (e)=>{ e.stopPropagation(); renameNotebook(nb); });
const del = el('button',{className:'icon-btn', title:'Delete'}, '🗑');
del.addEventListener('click', (e)=>{ e.stopPropagation(); deleteNotebook(nb); });
actions.append(ren, del);
row.append(actions);
row.addEventListener('click', ()=> selectNotebook(nb.id));
row.addEventListener('dblclick', ()=> promptRename(nb));
// collapse toggle
row.addEventListener('click', (e)=>{ if(e.target.closest('.row-actions')) return; nb.collapsed=!nb.collapsed; renderAll(); });
const kids = childrenOf(nb.id);
const wrap = el('div',{className:'tree-children'});
kids.forEach(k => wrap.append(renderNotebookItem(k)));
item.append(row, wrap);
// drag & drop to move notes into this notebook
item.addEventListener('dragover', (e)=>{ if(isNoteDrag(e)){ e.preventDefault(); item.classList.add('drag-over'); }});
item.addEventListener('dragleave', ()=> item.classList.remove('drag-over'));
item.addEventListener('drop', (e)=>{ if(isNoteDrag(e)){ e.preventDefault(); item.classList.remove('drag-over'); const nid=dragNoteId; if(nid){ const n=state.notes.find(x=>x.id===nid); if(n&&!n.trashed) moveNote(n, nb.id);} }});
return item;
}
function countNotes(trashedOnly) {
return state.notes.filter(n => trashedOnly ? n.trashed : !n.trashed).length;
}
function countNotesForNotebook(nbId) {
return state.notes.filter(n => !n.trashed && n.notebookId === nbId).length;
}
/* =========================================================================
RENDERING — NOTE LIST
========================================================================= */
function visibleNotes() {
let list;
const q = ($('#searchInput').value||'').trim().toLowerCase();
if (state.ui.view === 'trash') {
list = state.notes.filter(n => n.trashed);
} else if (state.ui.view === 'tag') {
list = state.notes.filter(n => !n.trashed && n.tags.includes(state.ui.activeTag));
} else if (state.ui.view === 'notebook') {
list = state.notes.filter(n => !n.trashed && n.notebookId === state.ui.viewId);
} else { // all
list = state.notes.filter(n => !n.trashed);
}
// tag filter
if (state.ui.activeTag && state.ui.view !== 'tag') {
list = list.filter(n => n.tags.includes(state.ui.activeTag));
}
// search
if (q) {
list = list.filter(n => (n.title||'').toLowerCase().includes(q) || (n._searchText||'').includes(q));
}
// sort
const sort = state.ui.sort;
list.sort((a,b)=>{
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (sort === 'created') return b.createdAt - a.createdAt;
if (sort === 'alpha') return (a.title||'').localeCompare(b.title||'');
return b.updatedAt - a.updatedAt; // modified
});
return list;
}
function renderNoteList() {
const el0 = $('#noteListEl');
const titleEl2 = $('#listTitle');
const countEl = $('#listCount');
const sortWrap = $('#sortWrap');
const tagBar = $('#tagFilterBar');
// Header title
if (state.ui.view === 'trash') { titleEl2.textContent = 'Trash'; }
else if (state.ui.view === 'tag') { titleEl2.textContent = 'Tag: ' + state.ui.activeTag; }
else if (state.ui.view === 'notebook') { const nb = findNotebook(state.ui.viewId); titleEl2.textContent = nb ? nb.name : 'Notebook'; }
else { titleEl2.textContent = 'All Notes'; }
// Sort button label
const sortLabel = {modified:'Modified', created:'Created', alpha:'A–Z'}[state.ui.sort] || 'Modified';
sortWrap.innerHTML = '';
sortWrap.append(el('button',{className:'sort-btn'+(state.ui.sort==='modified'?' active':''), 'data-sort':'modified', title:'Sort by last modified'}, 'Modified ▾'));
sortWrap.append(el('button',{className:'sort-btn'+(state.ui.sort==='created'?' active':''), 'data-sort':'created'}, 'Created'));
sortWrap.append(el('button',{className:'sort-btn'+(state.ui.sort==='alpha'?' active':''), 'data-sort':'alpha'}, 'A–Z'));
$$('.sort-btn', sortWrap).forEach(b => b.addEventListener('click', ()=> setSort(b.dataset.sort)));
// Tag filter bar
if (state.ui.activeTag) {
tagBar.classList.add('show');
$('#activeFilterText').textContent = state.ui.activeTag;
} else {
tagBar.classList.remove('show');
}
const notes = visibleNotes();
countEl.textContent = notes.length + (notes.length===1?' note':' notes');
el0.innerHTML = '';
if (notes.length === 0) {
el0.append(el('div',{className:'empty-list'},
el('div',{className:'empty-ico'}, state.ui.view==='trash'?'🗑':'🔍'),
el('div',{className:'msg'}, state.ui.view==='trash'?'Trash is empty':'No notes here')
));
return;
}
const q = ($('#searchInput').value||'').trim().toLowerCase();
notes.forEach(note => {
el0.append(renderNoteCard(note, q));
});
}
function renderNoteCard(note, q) {
const card = el('div',{className:'note-card'+(state.ui.selectedNote===note.id?' active':'')});
const snippetText = snippet(note.html);
const time = fmtTime(note.updatedAt);
const titleHtml = highlight(esc(note.title), q);
const snippetHtml = highlight(esc(snippetText), q);
card.append(
el('div',{className:'nc-top'},
note.pinned ? el('span',{className:'pin-icon'}, '📌') : el('span'),
el('span',{className:'nc-title'}, html2node(titleHtml))
),
el('div',{className:'nc-meta'},
el('span',{className:'nc-snippet'}, html2node(snippetHtml)),
el('span',{className:'nc-time'}, time)
)
);
if (note.tags.length) {
const tags = el('div',{className:'nc-tags'});
note.tags.slice(0,4).forEach(t => tags.append(el('span',{className:'tag-chip'}, '#'+t)));
card.append(tags);
}
// Actions
const actions = el('div',{className:'card-actions'});
const pin = el('button',{className:'icon-btn pin-toggle'+(note.pinned?' active':''), title: note.pinned?'Unpin':'Pin'}, '📌');
pin.addEventListener('click', (e)=>{ e.stopPropagation(); togglePin(note); });
const more = el('button',{className:'icon-btn', title:'More'}, '•••');
more.addEventListener('click', (e)=>{ e.stopPropagation(); toggleMenu('card-'+note.id, [
{label:'Duplicate', onclick:()=>{ duplicateNote(note); closeMenus(); }},
{label:'Move to…', submenu: moveMenuItems(note)},
{sep:true},
{label: state.ui.view==='trash'?'Restore from Trash':'Delete', danger: state.ui.view!=='trash', onclick:()=>{ deleteNote(note); closeMenus(); }}
]);
actions.append(pin, more);
card.append(actions);
// Click to select
card.addEventListener('click', ()=> selectNote(note.id));
// Tag click
$$('.tag-chip', card).forEach(chip => chip.addEventListener('click', (e)=>{ e.stopPropagation(); setActiveTag(chip.textContent.replace('#','')); }));
// Drag to move
card.draggable = true;
card.addEventListener('dragstart', (e)=>{ dragNoteId = note.id; e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', note.id); });
card.addEventListener('dragend', ()=>{ dragNoteId=null; });
return card;
}
function html2node(html){ const t=document.createElement('span'); t.innerHTML=html; return t; }
function highlight(text, q) {
if (!q) return text;
const idx = text.toLowerCase().indexOf(q);
if (idx < 0) return text;
return text.slice(0,idx) + '<mark class="mark">'+text.slice(idx, idx+q.length)+'</mark>' + text.slice(idx+q.length);
}
/* =========================================================================
RENDERING — EDITOR
========================================================================= */
function renderEditor() {
const host = $('#editorApp');
const note = currentNote();
if (!state.ui.selectedNote) { showNoNote(); return; }
if (!note) { showNoNote(); return; }
rebuildEditorDOM();
// meta bar (tags input)
const meta = $('.editor-meta');
meta.innerHTML = '';
meta.append(el('div',{className:'note-tag-input-wrap'}, tagChips(note)));
// tag input
const inp = el('input',{className:'tag-input', type:'text', placeholder:'+ Add tag'});
inp.addEventListener('keydown', (e)=>{
if (e.key==='Enter') {
e.preventDefault();
const val = inp.value.trim().toLowerCase().replace(/^#/,'');
if (val && !note.tags.includes(val)) { note.tags.push(val); note.updatedAt=Date.now(); markDirty(); renderEditor(); renderNoteList(); }
} else if (e.key==='Backspace' && inp.value==='' && note.tags.length) {
note.tags.pop(); note.updatedAt=Date.now(); markDirty(); renderEditor(); renderNoteList();
}
});
meta.append(inp);
openNote(note);
}
function tagChips(note) {
const wrap = el('div',{className:'tags-row'});
note.tags.forEach(t => {
const chip = el('span',{className:'tag'}, t, el('button',{className:'x', type:'button', title:'Remove tag'}, '×'));
chip.querySelector('.x').addEventListener('click', ()=>{ note.tags = note.tags.filter(x=>x!==t); note.updatedAt=Date.now(); markDirty(); renderEditor(); renderNoteList(); });
chip.addEventListener('click', ()=> setActiveTag(t));
wrap.append(chip);
});
return wrap;
}
/* =========================================================================
MENUS (dropdowns)
========================================================================= */
let openMenu = null;
function closeMenus() { if (openMenu) { openMenu.remove(); openMenu=null; } }
function toggleMenu(id, items) {
if (openMenu) { openMenu.remove(); openMenu=null; }
if (id==='moreMenu') {
// build from moreMenuItems
items = moreMenuItems();
}
const menu = el('div',{className:'menu', 'data-menu':id});
items.forEach(it => {
if (it.sep) { menu.append(el('div',{className:'menu-sep'})); return; }
if (it.submenu) {
const mi = el('button',{className:'menu-item', type:'button'}, it.label, el('span',{className:'menu-kbd'}, '›'));
mi.addEventListener('mouseenter', ()=>{ closeSub(); const subWrap=el('div'); const sub=toggleMenu('sub', it.submenu); subWrap.append(sub); subWrap.style.position='absolute'; subWrap.style.left='100%'; subWrap.style.top=0; mi.append(subWrap); });
menu.append(mi);
} else {
const mi = el('button',{className:'menu-item'+(it.danger?' danger':''), type:'button'}, it.label, it.kbd?el('span',{className:'menu-kbd'}, it.kbd):null);
mi.addEventListener('click', ()=>{ it.onclick&&it.onclick(); closeMenus(); });
menu.append(mi);
}
});
document.body.append(menu);
const btn = $('#moreMenuBtn');
const r = btn.getBoundingClientRect();
menu.style.top = (r.bottom + 4) + 'px';
menu.style.right = '12px';
openMenu = menu;
setTimeout(()=> document.addEventListener('click', closeMenusOuter), 0);
}
function closeMenusOuter(e) { if (openMenu && !openMenu.contains(e.target)) closeMenus(); }
function closeSub() {}
function moreMenuItems(note) {
note = note || currentNote();
if (!note) return [];
const items = [
{label:'Duplicate', onclick:()=>{ duplicateNote(note); }},
{label:'Move to…', submenu: moveMenuItems(note)},
];
if (state.ui.view === 'trash') {
items.push({sep:true}, {label:'Restore', onclick:()=>{ restoreNote(note); }},
{label:'Delete permanently', danger:true, onclick:()=>{ deleteNote(note); }});
} else {
items.push({sep:true}, {label:'Move to Trash', onclick:()=>{ deleteNote(note); }},
{label:'Delete permanently', danger:true, onclick:()=>{ deleteNote(note); toast('Delete permanently requires opening Trash'); }});
}
return items;
}
function moveMenuItems(note) {
const items = [{label:'All Notes', onclick:()=>{ moveNote(note, null); }}, {sep:true}];
state.notebooks.forEach(nb => items.push({label: nb.name, onclick:()=>{ moveNote(note, nb.id); }}));
if (state.notebooks.length===0) items.push({label:'(no notebooks)', onclick:()=>{}});
return items;
}
/* =========================================================================
DRAG & DROP (notes)
========================================================================= */
let dragNoteId = null;
function isNoteDrag(e) { return e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('text/plain'); }
/* =========================================================================
SEARCH
========================================================================= */
function precomputeSearchText() {
state.notes.forEach(n => { n._searchText = extractText(n.html).toLowerCase(); });
}
/* =========================================================================
THEME + LAYOUT
========================================================================= */
function applyTheme() { document.documentElement.setAttribute('data-theme', state.settings.theme); }
function toggleTheme() { state.settings.theme = state.settings.theme==='light'?'dark':'light'; applyTheme(); markDirty(); }
function applyLayout() {
const l = state.settings.layout;
document.documentElement.style.setProperty('--sidebar-w', (l.sidebarW||250)+'px');
document.documentElement.style.setProperty('--list-w', (l.listW||300)+'px');
const app = $('#app');
if (state.settings.sidebarCollapsed) app.classList.add('sidebar-collapsed');
else app.classList.remove('sidebar-collapsed');
}
/* =========================================================================
RESIZABLE PANES
========================================================================= */
function initResize() {
makeResize('#handleSidebar', 'x', (delta)=>{
const l = state.settings.layout;
l.sidebarW = Math.max(180, Math.min(460, (l.sidebarW||250)+delta));
applyLayout();
});
makeResize('#handleList', 'x', (delta)=>{
const l = state.settings.layout;
l.listW = Math.max(220, Math.min(560, (l.listW||300)+delta));
applyLayout();
});
// Sidebar collapse via brand double-click or button
const collapseBtn = el('button',{className:'icon-btn', id:'collapseSidebar', title:'Collapse sidebar'}, '«');
// attach to sidebar head actions
const sha = $('.sidebar-actions');
sha.insertBefore(collapseBtn, sha.firstChild);
collapseBtn.addEventListener('click', ()=>{ state.settings.sidebarCollapsed = !state.settings.sidebarCollapsed; applyLayout(); });
}
function makeResize(selector, dir, onDelta) {
const handle = $(selector);
let start = 0, base = 0, active = false;
handle.addEventListener('mousedown', (e)=>{
e.preventDefault();
active = true; start = e.clientX;
const l = state.settings.layout;
base = dir==='x' ? (l.sidebarW||250) : (l.listW||300);
handle.classList.add('active');
document.body.style.userSelect='none';
document.addEventListener('mousemove', onMouse);
document.addEventListener('mouseup', ()=>{
active=false; handle.classList.remove('active');
document.body.style.userSelect='';
document.removeEventListener('mousemove', onMouse);
markDirty();
});
function onMouse(ev){ if(!active) return; onDelta(ev.clientX - start); }
});
}
/* =========================================================================
ICONS (inline SVG)
========================================================================= */
function chevronIcon(){ return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; }
function arrowIcon(){ return '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>'; }
function dotMenuIcon(){ return '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>'; }
/* =========================================================================
MODALS
========================================================================= */
function modalInput(title, defVal, onOk) {
const bd = el('div',{className:'modal-backdrop'});
const m = el('div',{className:'modal'});
m.append(el('h3',{},title), el('input',{type:'text', id:'modalInput', value:defVal}),
el('div',{className:'modal-actions'},
el('button',{className:'btn-ghost', id:'modalCancel'}, 'Cancel'),
el('button',{className:'btn-primary', id:'modalOk'}, 'Save')
));
bd.append(m); document.body.append(bd);
const input = $('#modalInput'); input.focus(); input.select();
const close = (ok)=>{ bd.remove(); if(ok) onOk(input.value); };
$('#modalCancel', m).addEventListener('click', ()=> close(false));
$('#modalOk', m).addEventListener('click', ()=> close(true));
input.addEventListener('keydown', (e)=>{ if(e.key==='Enter') close(true); if(e.key==='Escape') bd.remove(); });
bd.addEventListener('click', (e)=>{ if(e.target===bd) bd.remove(); });
}
function confirmModal(title, msg, btnLabel, onOk) {
const bd = el('div',{className:'modal-backdrop'});
const m = el('div',{className:'modal'});
m.append(el('h3',{},title), el('p',{style:'margin:0 0 4px;color:var(--text-muted);font-size:13px;line-height:1.4'}, msg),
el('div',{className:'modal-actions'},
el('button',{className:'btn-ghost', id:'modalCancel'}, 'Cancel'),
el('button',{className:'btn-primary', id:'modalOk', style: btnLabel==='Delete'||btnLabel==='Delete permanently'?'background:var(--danger);':''}, btnLabel)
));
bd.append(m); document.body.append(bd);
const close=(ok)=>{ bd.remove(); if(ok) onOk(); };
$('#modalCancel', m).addEventListener('click', ()=> close(false));
$('#modalOk', m).addEventListener('click', ()=> close(true));
input=null;
bd.addEventListener('click', (e)=>{ if(e.target===bd) bd.remove(); });
document.addEventListener('keydown', function once(e){ if(e.key==='Escape'){ bd.remove(); document.removeEventListener('keydown', once);} });
}
/* =========================================================================
NAV / BACK
========================================================================= */
function goBack() { viewAllNotes(); }
/* =========================================================================
GLOBAL EVENTS
========================================================================= */
function initGlobal() {
$('#themeToggle').addEventListener('click', toggleTheme);
$('#clearFilter').addEventListener('click', clearTagFilter);
$('#searchInput').addEventListener('input', ()=>{ precomputeSearchText(); renderNoteList(); });
// Global keyboard shortcuts
document.addEventListener('keydown', (e)=>{
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase()==='n') { e.preventDefault(); createNote(); }
if (mod && e.shiftKey && (e.key.toLowerCase()==='z'||e.key.toLowerCase()==='y')) { e.preventDefault(); const note=currentNote(); if(note){ editor.focus(); doRedo(); } }
if (mod && e.key.toLowerCase()==='d') { e.preventDefault(); toggleTheme(); }
if (e.key==='Escape') { closeMenus(); }
if (e.key==='Enter' && e.key.toLowerCase()==='n' && mod) {}
});
// Click outside to close menus
document.addEventListener('click', (e)=>{ if(openMenu && !openMenu.contains(e.target)) closeMenus(); });
// New note button
document.body.insertAdjacentHTML('beforeend','');
}
/* =========================================================================
RENDER ALL
========================================================================= */
function renderAll() {
precomputeSearchText();
renderSidebar();
renderNoteList();
renderEditor();
}
/* =========================================================================
INIT
========================================================================= */
function init() {
load();
initEditor();
initResize();
initGlobal();
// New note button in sidebar area (append into list head area is not ideal; use a dedicated button)
// We'll inject a "New note" button into the list head.
const listHead = $('.list-head');
const newBtn = el('button',{className:'new-note-btn', id:'newNoteBtn'}, '+ New Note');
newBtn.addEventListener('click', createNote);
listHead.append(newBtn);
precomputeSearchText();
// Restore selection
if (state.ui.selectedNote) {
const note = state.notes.find(n=>n.id===state.ui.selectedNote);
if (!note || note.trashed) state.ui.selectedNote = state.notes.find(n=>!n.trashed)?.id || null;
}
renderAll();
// Markdown shortcut: space triggers conversion
editor && editor.addEventListener('keydown', (e)=>{ if(e.key===' '||e.key==='Spacebar') handleMarkdownShortcut(e); });
}
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"6afab0b1-711e-4771-aa4b-6305d9d0af90","tokensIn":8209354,"tokensOut":114547,"tokensTotal":8323901,"cost":0,"turns":110,"toolCalls":109,"failedToolCalls":6,"timestamp":"2026-08-23T20:53:27.164Z"} -->