Notes
ox alpha · 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 66,327 bytes · SHA-256 74f541874be5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inkwell — Notes</title>
<style>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
:root {
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
--font-editor: Georgia, "Iowan Old Style", "Times New Roman", serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
/* light theme */
--bg-app: #f6f2ec;
--bg-sidebar: #efe9df;
--bg-list: #f8f4ee;
--bg-editor: #fffdf9;
--bg-hover: rgba(60,45,20,.06);
--bg-active: rgba(180,120,50,.14);
--bg-selected: rgba(190,125,55,.18);
--accent: #b06f2e;
--accent-strong: #8f5720;
--text: #33291c;
--text-dim: #7a6c58;
--text-faint: #a3957f;
--border: rgba(80,60,30,.13);
--shadow: 0 1px 3px rgba(60,40,10,.08), 0 8px 24px rgba(60,40,10,.07);
--danger: #b3402e;
--code-bg: rgba(150,110,50,.12);
--quote-bar: #c99a5e;
--radius: 8px;
--sidebar-w: 232px;
--list-w: 288px;
}
html[data-theme="dark"] {
--bg-app: #1d1a16;
--bg-sidebar: #211d18;
--bg-list: #26221c;
--bg-editor: #2a251f;
--bg-hover: rgba(255,235,200,.07);
--bg-active: rgba(220,160,90,.12);
--bg-selected: rgba(220,160,90,.18);
--accent: #d29a58;
--accent-strong: #e0ad68;
--text: #ece4d6;
--text-dim: #a89a85;
--text-faint: #77695a;
--border: rgba(255,240,210,.10);
--shadow: 0 1px 3px rgba(0,0,0,.35), 0 8px 24px rgba(0,0,0,.25);
--danger: #d96a56;
--code-bg: rgba(230,180,100,.15);
--quote-bar: #a97e46;
}
/* ============================================================
BASE
============================================================ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font-ui);
background: var(--bg-app);
color: var(--text);
font-size: 14px;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
button { font-family: inherit; color: inherit; background: none; border: none; cursor: pointer; }
input { font-family: inherit; color: inherit; }
::selection { background: rgba(200,140,70,.30); }
/* ============================================================
APP LAYOUT
============================================================ */
#app { display: flex; height: 100vh; }
.pane-divider {
flex: 0 0 5px;
cursor: col-resize;
background: transparent;
position: relative;
z-index: 5;
transition: background .15s;
}
.pane-divider:hover, .pane-divider.dragging { background: var(--accent); opacity:.5; }
body.resizing { cursor: col-resize; user-select: none; }
body.resizing iframe, body.resizing .editor-scroll { pointer-events: none; }
/* ============================================================
SIDEBAR
============================================================ */
#sidebar {
width: var(--sidebar-w);
min-width: 170px;
max-width: 420px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: margin-left .28s cubic-bezier(.4,0,.2,1), opacity .28s;
overflow: hidden;
}
#sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-w)); opacity: 0; }
.sidebar-header {
display: flex; align-items: center; gap: 8px;
padding: 14px 14px 10px;
}
.app-title { font-weight: 700; font-size: 15px; letter-spacing: .3px; flex: 1; display:flex; align-items:center; gap:7px;}
.app-logo { width:20px;height:20px;border-radius:6px;background:linear-gradient(135deg,var(--accent),#d9a869);display:inline-flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:800;font-family:var(--font-editor);}
.icon-btn {
width: 26px; height: 26px; border-radius: 6px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-dim); font-size: 14px; line-height: 1;
transition: background .12s, color .12s;
flex-shrink: 0;
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.active { background: var(--bg-active); color: var(--accent-strong); }
.sidebar-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 16px; }
.sb-section-label {
font-size: 10.5px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;
color: var(--text-faint); padding: 12px 8px 5px;
display: flex; align-items: center;
}
.sb-section-label .icon-btn { margin-left: auto; width: 20px; height: 20px; font-size: 13px; visibility: hidden; }
.sidebar-scroll:hover .sb-section-label .icon-btn, .sb-section-label:hover .icon-btn { visibility: visible; }
.sb-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 7px;
cursor: pointer; color: var(--text-dim);
transition: background .12s, color .12s;
user-select: none;
font-size: 13.5px;
}
.sb-item:hover { background: var(--bg-hover); color: var(--text); }
.sb-item.selected { background: var(--bg-selected); color: var(--accent-strong); font-weight: 600; }
.sb-item.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; background: var(--bg-active); }
.sb-item .glyph { width: 17px; text-align: center; font-size: 13px; flex-shrink: 0; }
.sb-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-item .count { font-size: 11px; color: var(--text-faint); }
.sb-item .twisty { width: 14px; font-size: 9px; color: var(--text-faint); transition: transform .15s; flex-shrink:0; }
.sb-item .twisty.open { transform: rotate(90deg); }
.sb-notebook-notes { overflow: hidden; transition: max-height .22s ease; }
.sb-notebook-notes.collapsed { display: none; }
.sb-subitem { padding-left: 34px; font-size: 13px; }
.sb-rename-input {
flex: 1; min-width: 0; background: var(--bg-editor); border: 1px solid var(--accent);
border-radius: 4px; padding: 2px 6px; font-size: 13px; outline: none;
}
.tag-cloud { padding: 6px 8px; display: flex; flex-wrap: wrap; gap: 5px; }
.tag-chip {
font-size: 11.5px; padding: 2.5px 9px; border-radius: 999px;
background: var(--bg-hover); color: var(--text-dim); cursor: pointer;
border: 1px solid transparent; transition: all .12s;
}
.tag-chip:hover { color: var(--text); background: var(--bg-active); }
.tag-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.storage-foot { padding: 8px 14px; font-size: 10.5px; color: var(--text-faint); border-top: 1px solid var(--border); }
/* ============================================================
NOTE LIST PANE
============================================================ */
#notelist-pane {
width: var(--list-w);
min-width: 200px;
max-width: 480px;
background: var(--bg-list);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.nl-header { padding: 12px 12px 8px; }
.nl-title-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.nl-title { font-size: 15px; font-weight: 700; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-wrap { position: relative; }
.search-wrap svg { position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-faint); }
#search-input {
width: 100%; padding: 7px 10px 7px 30px;
border-radius: 8px; border: 1px solid var(--border);
background: var(--bg-editor); font-size: 13px; outline: none;
transition: border-color .15s, box-shadow .15s;
}
#search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(176,111,46,.15); }
.nl-controls { display: flex; align-items: center; padding: 6px 4px 2px; gap: 4px; }
.sort-btn {
font-size: 11.5px; color: var(--text-dim); padding: 3px 8px; border-radius: 6px;
transition: all .12s; display:inline-flex; align-items:center; gap:4px;
}
.sort-btn:hover { background: var(--bg-hover); }
.sort-btn.active { color: var(--accent-strong); font-weight: 600; background: var(--bg-active); }
.nl-count { font-size: 11px; color: var(--text-faint); margin-left: auto; }
.nl-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 16px; }
.note-card {
padding: 10px 12px; border-radius: var(--radius);
cursor: pointer; margin-bottom: 2px;
border: 1px solid transparent;
transition: background .13s, transform .13s, box-shadow .13s;
animation: cardIn .18s ease both;
}
@keyframes cardIn { from { opacity: 0; transform: translateY(4px);} to {opacity:1; transform:none;} }
.note-card:hover { background: var(--bg-hover); }
.note-card.selected { background: var(--bg-editor); border-color: var(--border); box-shadow: var(--shadow); }
.note-card.dragging { opacity: .45; }
.nc-title-row { display: flex; align-items: center; gap: 6px; }
.nc-title { font-weight: 600; font-size: 13.5px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pin-glyph { font-size: 11px; color: var(--accent); }
.nc-snippet { font-size: 12px; color: var(--text-dim); margin-top: 3px; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.nc-meta { font-size: 11px; color: var(--text-faint); margin-top: 5px; display: flex; gap: 6px; align-items: center; }
.nc-tags { display:flex; gap:4px; flex-wrap: wrap; }
.nc-tag { font-size: 10px; background: var(--bg-active); color: var(--text-dim); border-radius: 999px; padding: 1px 7px; }
mark.hl { background: rgba(230,180,80,.45); color: inherit; border-radius: 2px; padding: 0 1px; }
.section-head { font-size: 10.5px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color: var(--text-faint); padding: 8px 6px 4px; }
.empty-pane { text-align:center; color: var(--text-faint); padding: 40px 20px; font-size: 13px; }
/* ============================================================
EDITOR PANE
============================================================ */
#editor-pane { flex: 1; min-width: 300px; display: flex; flex-direction: column; background: var(--bg-editor); }
.ed-toolbar {
display: flex; align-items: center; gap: 2px; padding: 7px 12px;
border-bottom: 1px solid var(--border); flex-wrap: wrap;
background: var(--bg-editor);
}
.tb-btn {
min-width: 28px; height: 28px; padding: 0 6px; border-radius: 6px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-dim); font-size: 13px; transition: all .12s;
}
.tb-btn:hover { background: var(--bg-hover); color: var(--text); }
.tb-btn.active { background: var(--bg-active); color: var(--accent-strong); }
.tb-sep { width: 1px; height: 18px; background: var(--border); margin: 0 5px; }
.tb-btn b, .tb-btn i { font-style:normal; }
.tb-btn .serif-i { font-family: Georgia, serif; font-style: italic; }
.ed-scroll { flex: 1; overflow-y: auto; }
.ed-inner { max-width: 720px; margin: 0 auto; padding: 36px 48px 40vh; }
.ed-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.ed-time { font-size: 11.5px; color: var(--text-faint); }
.saved-ind { font-size: 11px; color: var(--text-faint); opacity: 0; transition: opacity .3s; display:inline-flex; align-items:center; gap:4px; }
.saved-ind.show { opacity: 1; }
.saved-dot { width:6px;height:6px;border-radius:50%;background:#5aa05a; }
#title-input {
width: 100%; border: none; outline: none; background: transparent;
font-family: var(--font-editor); font-size: 30px; font-weight: 700;
color: var(--text); padding: 6px 0 4px; border-bottom: 1px solid transparent;
transition: border-color .2s;
}
#title-input:focus { border-color: var(--border); }
#title-input::placeholder { color: var(--text-faint); }
.tags-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 0 4px; }
.ed-tag {
font-size: 11.5px; background: var(--bg-active); color: var(--text-dim);
border-radius: 999px; padding: 3px 6px 3px 10px; display: inline-flex; align-items: center; gap: 5px;
}
.ed-tag button { font-size: 12px; color: var(--text-faint); border-radius: 50%; width: 15px; height: 15px; line-height: 1; }
.ed-tag button:hover { color: var(--danger); }
#tag-input {
border: none; outline: none; background: transparent; font-size: 11.5px;
width: 90px; color: var(--text-dim);
}
#tag-input::placeholder { color: var(--text-faint); }
#editor {
font-family: var(--font-editor);
font-size: 16.5px; line-height: 1.72;
outline: none; padding-top: 10px; color: var(--text);
min-height: 300px; word-wrap: break-word;
}
#editor:focus { outline: none; }
#editor h1 { font-size: 27px; margin: 18px 0 8px; line-height: 1.25; font-weight: 700; }
#editor h2 { font-size: 22px; margin: 16px 0 6px; font-weight: 700; }
#editor h3 { font-size: 18.5px; margin: 14px 0 5px; font-weight: 600; }
#editor p { margin: 6px 0; }
#editor ul, #editor ol { padding-left: 26px; margin: 6px 0; }
#editor li { margin: 2px 0; }
#editor blockquote {
border-left: 3px solid var(--quote-bar); margin: 10px 0; padding: 4px 16px;
color: var(--text-dim); font-style: italic; background: var(--bg-hover); border-radius: 0 6px 6px 0;
}
#editor hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
#editor code, .inline-code {
font-family: var(--font-mono); font-size: .84em;
background: var(--code-bg); padding: 2px 6px; border-radius: 5px;
}
#editor a { color: var(--accent-strong); }
/* checklists */
#editor ul.checklist { list-style: none; padding-left: 4px; }
#editor ul.checklist li {
position: relative; padding-left: 27px; margin: 3px 0;
}
#editor ul.checklist li::before {
content: ""; position: absolute; left: 2px; top: .42em;
width: 16px; height: 16px; border-radius: 5px;
border: 1.6px solid var(--text-faint); cursor: pointer;
transition: all .15s; background: transparent;
}
#editor ul.checklist li[data-checked="true"] { color: var(--text-faint); text-decoration: line-through; text-decoration-color: var(--text-faint); }
#editor ul.checklist li[data-checked="true"]::before {
background: var(--accent); border-color: var(--accent);
}
#editor ul.checklist li[data-checked="true"]::after {
content: ""; position: absolute; left: 7px; top: .52em;
width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
transform: rotate(45deg); pointer-events: none;
}
/* empty state */
.editor-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--text-faint); gap: 14px;
}
.editor-empty .big-glyph { font-size: 44px; opacity: .55; }
.editor-empty h2 { font-family: var(--font-editor); font-weight: 500; font-size: 19px; color: var(--text-dim); }
.btn-primary {
background: var(--accent); color: #fff; padding: 8px 18px; border-radius: 8px;
font-size: 13.5px; font-weight: 600; transition: all .15s; box-shadow: var(--shadow);
}
.btn-primary:hover { background: var(--accent-strong); transform: translateY(-1px); }
/* menus & dialogs */
.ctx-menu {
position: fixed; z-index: 100; min-width: 168px;
background: var(--bg-editor); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow); padding: 5px;
animation: menuIn .12s ease both;
}
@keyframes menuIn { from { opacity:0; transform: scale(.97) translateY(-3px);} to{opacity:1;transform:none;} }
.ctx-menu button {
display: flex; width: 100%; text-align: left; padding: 7px 10px;
border-radius: 6px; font-size: 13px; gap: 9px; align-items: center; color: var(--text);
}
.ctx-menu button:hover { background: var(--bg-hover); }
.ctx-menu button.danger { color: var(--danger); }
.ctx-menu hr { border:none; border-top:1px solid var(--border); margin:4px 0; }
.modal-backdrop {
position: fixed; inset: 0; background: rgba(30,20,8,.35); z-index: 90;
display: flex; align-items: flex-start; justify-content: center; padding-top: 18vh;
animation: fadeIn .15s ease;
}
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
.modal {
background: var(--bg-editor); border-radius: 14px; box-shadow: var(--shadow);
padding: 22px 24px; width: 360px; animation: menuIn .18s ease;
}
.modal h3 { font-size: 15px; margin-bottom: 14px; }
.modal input[type=text] {
width: 100%; padding: 8px 11px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-app);
font-size: 14px; outline: none;
}
.modal input[type=text]:focus { border-color: var(--accent); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.btn-ghost { padding: 7px 14px; border-radius: 8px; color: var(--text-dim); font-size: 13px; }
.btn-ghost:hover { background: var(--bg-hover); }
/* scrollbars */
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; }
.trashed-banner {
background: var(--bg-active); color: var(--accent-strong);
padding: 8px 14px; border-radius: 8px; font-size: 12.5px;
display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
}
.trashed-banner button { color: var(--accent-strong); font-weight: 600; text-decoration: underline; font-size: 12.5px; }
.trashed-banner button.del { color: var(--danger); }
[contenteditable][data-placeholder]:empty::before,
#editor[data-empty="true"]::before {
content: attr(data-placeholder); color: var(--text-faint); pointer-events: none;
font-style: italic;
}
</style>
</head>
<body>
<div id="app">
<!-- ================= SIDEBAR ================= -->
<aside id="sidebar">
<div class="sidebar-header">
<span class="app-title"><span class="app-logo">N</span> Inkwell</span>
<button class="icon-btn" id="btn-theme" title="Toggle theme">◐</button>
<button class="icon-btn" id="btn-collapse-sb" title="Collapse sidebar">«</button>
</div>
<div class="sidebar-scroll" id="sidebar-nav"></div>
<div class="storage-foot" id="storage-foot">All data stored locally</div>
</aside>
<div class="pane-divider" id="divider-sb"></div>
<!-- ================= NOTE LIST ================= -->
<section id="notelist-pane">
<div class="nl-header">
<div class="nl-title-row">
<span class="nl-title" id="nl-title">All Notes</span>
<button class="icon-btn" id="btn-new-note" title="New note">+</button>
<button class="icon-btn" id="btn-nl-menu" title="Notebook options">⋯</button>
</div>
<div class="search-wrap">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="search-input" type="text" placeholder="Search notes…" autocomplete="off">
</div>
<div class="nl-controls" id="sort-controls">
<button class="sort-btn active" data-sort="updated">Modified ↓</button>
<button class="sort-btn" data-sort="created">Created</button>
<button class="sort-btn" data-sort="alpha">A–Z</button>
<span class="nl-count" id="nl-count"></span>
</div>
</div>
<div class="nl-scroll" id="note-list"></div>
</section>
<div class="pane-divider" id="divider-nl"></div>
<!-- ================= EDITOR ================= -->
<section id="editor-pane"></section>
</div>
<script>
"use strict";
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CFG = {
STORAGE_KEY: "inkwell.notes.v1",
AUTOSAVE_DEBOUNCE: 450,
SNIPPET_LEN: 120,
UNDO_LIMIT: 200,
SEARCH_HL: true,
};
/* ============================================================
UTILITIES
============================================================ */
const $ = (sel, el = document) => el.querySelector(sel);
const $$ = (sel, el = document) => [...el.querySelectorAll(sel)];
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const esc = s => s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
function relTime(ts) {
const d = new Date(ts), now = Date.now(), diff = (now - d) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400 && new Date().getDate() === d.getDate()) return "Today " + d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
if (diff < 172800) return "Yesterday " + d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric", year: d.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined });
}
/* ============================================================
DATA MODEL (single source of truth)
============================================================ */
let state = null;
function defaultState() {
const nb1 = { id: uid(), name: "Personal", collapsed: false };
const nb2 = { id: uid(), name: "Work", collapsed: false };
const t = Date.now();
return {
notebooks: [nb1, nb2],
notes: [
{
id: uid(), notebookId: nb1.id, title: "Welcome to Inkwell",
html: `<h1>Welcome ✨</h1><p>This is your <b>notebook</b>. A few things to try:</p><ul class="checklist"><li data-checked="true">Type <code># </code> at a line start for a heading</li><li data-checked="false">Type <code>- </code> for bullets or <code>[] </code> for checklists</li><li data-checked="false">Drag this note onto another notebook</li></ul><blockquote>Everything saves automatically to your browser.</blockquote><p>Select some text and use the toolbar, or press <code>Ctrl+B</code>.</p>`,
tags: ["guide"], pinned: true, createdAt: t, updatedAt: t, trashed: false,
},
{
id: uid(), notebookId: nb2.id, title: "Project kickoff",
html: "<h2>Kickoff agenda</h2><ol><li>Intros</li><li>Scope review</li><li>Timeline</li></ol>",
tags: ["meeting"], pinned: false, createdAt: t - 86400000, updatedAt: t - 3600000, trashed: false,
},
],
ui: {
view: "all", // 'all' | 'trash' | notebookId
selectedNoteId: null,
sortMode: "updated",
theme: "light",
sidebarCollapsed: false,
sbWidth: 232,
nlWidth: 288,
search: "",
tagFilter: null,
},
};
}
function saveState() {
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(state));
}
function loadState() {
try {
const raw = localStorage.getItem(CFG.STORAGE_KEY);
if (raw) { state = JSON.parse(raw); if (state && state.notes && state.notebooks) return; }
} catch (e) { /* fall through */ }
state = defaultState();
}
const getNote = id => state.notes.find(n => n.id === id);
const getNotebook = id => state.notebooks.find(n => n.id === id);
/* ============================================================
TEXT EXTRACTION & SANITIZATION
============================================================ */
function extractText(html) {
const div = document.createElement("div");
div.innerHTML = html;
// drop checkbox pseudo-content; textContent is enough
return (div.textContent || "").replace(/\s+/g, " ").trim();
}
// Allowlist sanitizer for stored/pasted HTML
const ALLOWED = {
tags: new Set(["B","STRONG","I","EM","U","S","STRIKE","DEL","CODE","H1","H2","H3",
"P","DIV","BR","UL","OL","LI","BLOCKQUOTE","HR","A","SPAN"]),
voidTags: new Set(["BR","HR"]),
};
function sanitizeNode(node, out) {
for (const child of [...node.childNodes]) {
if (child.nodeType === Node.TEXT_NODE) { out.appendChild(document.createTextNode(child.textContent)); continue; }
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const tag = child.tagName;
if (!ALLOWED.tags.has(tag)) { sanitizeNode(child, out); continue; }
if (ALLOWED.voidTags.has(tag)) { out.appendChild(document.createElement(tag)); continue; }
const el = document.createElement(tag);
if (tag === "LI") {
const chk = child.getAttribute("data-checked");
el.setAttribute("data-checked", chk === "true" ? "true" : "false");
}
if (tag === "A") {
const href = child.getAttribute("href") || "";
if (/^https?:\/\//i.test(href)) el.setAttribute("href", href);
else el.removeAttribute("href");
}
sanitizeNode(child, el);
// unwrap empty non-list containers
if ((tag === "P" || tag === "DIV" || tag === "SPAN") && !el.textContent.trim() && !el.querySelector("br,img")) continue;
out.appendChild(el);
}
}
function sanitizeHTML(html) {
const inDiv = document.createElement("div");
inDiv.innerHTML = html || "";
const out = document.createElement("div");
sanitizeNode(inDiv, out);
let h = out.innerHTML;
// normalize checklist items
h = h.replace(/<li([^>]*)>/gi, (m, attrs) => {
const mm = attrs.match(/data-checked=["']?(true|false)/i);
return `<li${mm ? ` data-checked="${mm[1].toLowerCase()}"` : ""}>`;
});
// keep checklist class on lists that contain checklist items
h = h.replace(/<ul>((?:(?!<\/ul>).)*?<li data-checked)/gis, "<ul class=\"checklist\">$1");
h = h.replace(/<div>(<br>)?<\/div>/gi, "");
return h.trim();
}
function ensureChecklistClass(html) {
return html.replace(/<ul>(?=\s*<li)/gi, m => {
// only mark as checklist if any li has data-checked OR it was already marked
return m;
});
}
/* ============================================================
RENDERING — SIDEBAR
============================================================ */
function visibleNotes() {
const v = state.ui.view;
let list;
if (v === "trash") list = state.notes.filter(n => n.trashed);
else list = state.notes.filter(n => !n.trashed && (v === "all" || n.notebookId === v));
const q = state.ui.search.trim().toLowerCase();
if (state.ui.tagFilter) list = list.filter(n => n.tags.includes(state.ui.tagFilter));
if (q) list = list.filter(n =>
n.title.toLowerCase().includes(q) || extractText(n.html).toLowerCase().includes(q));
return list;
}
function sortedNotes(list) {
const pinned = list.filter(n => n.pinned), rest = list.filter(n => !n.pinned);
const cmp = {
updated: (a, b) => b.updatedAt - a.updatedAt,
created: (a, b) => b.createdAt - a.createdAt,
alpha: (a, b) => a.title.localeCompare(b.title),
}[state.ui.sortMode];
return [...pinned.sort(cmp), ...rest.sort(cmp)];
}
function renderSidebar() {
const nav = $("#sidebar-nav");
const v = state.ui.view;
const counts = id => state.notes.filter(n => !n.trashed && n.notebookId === id).length;
let h = "";
h += sbItem("all", v, "🗂", "All Notes", state.notes.filter(n => !n.trashed).length);
h += `<div class="sb-section-label">Notebooks<button class="icon-btn" id="btn-new-nb" title="New notebook">+</button></div>`;
for (const nb of state.notebooks) {
h += `<div class="sb-item ${v === nb.id ? "selected" : ""}" data-view="${nb.id}" data-droppable="1">
<span class="twisty ${nb.collapsed ? "" : "open"}" data-twisty="${nb.id}">▶</span>
<span class="glyph">📓</span>
<span class="name">${esc(nb.name)}</span>
<span class="count">${counts(nb.id)}</span>
<button class="icon-btn" data-nb-menu="${nb.id}" style="width:20px;height:20px;font-size:12px">⋯</button>
</div>`;
if (!nb.collapsed) {
const subs = sortedNotes(state.notes.filter(n => !n.trashed && n.notebookId === nb.id)).slice(0, 6);
h += `<div class="sb-notebook-notes">` + subs.map(n =>
`<div class="sb-item sb-subitem ${state.ui.selectedNoteId === n.id ? "selected" : ""}" data-open-note="${n.id}">
<span class="glyph">•</span><span class="name">${esc(n.title || "Untitled")}</span>
</div>`).join("") + `</div>`;
}
}
h += `<div class="sb-section-label">Tags</div>`;
const tagSet = [...new Set(state.notes.filter(n => !n.trashed).flatMap(n => n.tags))].sort();
h += tagSet.length
? `<div class="tag-cloud">` + tagSet.map(t =>
`<span class="tag-chip ${state.ui.tagFilter === t ? "active" : ""}" data-tag-filter="${esc(t)}">#${esc(t)}</span>`).join("") + `</div>`
: `<div style="padding:2px 10px;font-size:11.5px;color:var(--text-faint)">No tags yet</div>`;
h += `<div class="sb-section-label">More</div>`;
h += sbItem("trash", v, "🗑", "Trash", state.notes.filter(n => n.trashed).length);
nav.innerHTML = h;
$$(".sb-item[data-view]", nav).forEach(el => {
el.addEventListener("click", e => {
if (e.target.closest("[data-twisty],[data-nb-menu]")) return;
selectView(el.dataset.view);
});
});
$$("[data-twisty]", nav).forEach(el => el.addEventListener("click", () => {
const nb = getNotebook(el.dataset.twisty); nb.collapsed = !nb.collapsed; saveState(); renderSidebar();
}));
$$("[data-open-note]", nav).forEach(el => el.addEventListener("click", () => openNote(el.dataset.openNote)));
$$("[data-tag-filter]", nav).forEach(el => el.addEventListener("click", () => {
state.ui.tagFilter = state.ui.tagFilter === el.dataset.tagFilter ? null : el.dataset.tagFilter;
renderSidebar(); renderNoteList();
}));
$("#btn-new-nb").addEventListener("click", () => promptModal("New Notebook", "", name => {
if (name.trim()) { state.notebooks.push({ id: uid(), name: name.trim(), collapsed: false }); saveState(); renderAll(); }
}));
$$("[data-nb-menu]", nav).forEach(el => el.addEventListener("click", e => {
e.stopPropagation();
showMenu(e.clientX, e.clientY, [
{ label: "Rename", icon: "✏️", fn: () => renameNotebook(el.dataset.nbMenu) },
{ label: "Delete notebook", icon: "🗑", danger: true, fn: () => deleteNotebook(el.dataset.nbMenu) },
]);
}));
setupSidebarDnD(nav);
}
function sbItem(view, curView, glyph, name, count) {
return `<div class="sb-item ${curView === view ? "selected" : ""}" data-view="${view}">
<span class="glyph">${glyph}</span><span class="name">${name}</span><span class="count">${count}</span></div>`;
}
function renameNotebook(id) {
const nb = getNotebook(id);
promptModal("Rename Notebook", nb.name, name => {
if (name.trim()) { nb.name = name.trim(); saveState(); renderAll(); }
});
}
function deleteNotebook(id) {
const count = state.notes.filter(n => n.notebookId === id && !n.trashed).length;
confirmModal(`Delete "${getNotebook(id).name}"?`, `${count} note(s) will move to Trash.`,
() => {
state.notes.forEach(n => { if (n.notebookId === id) { n.trashed = true; n.trashedFrom = n.notebookId; } });
state.notebooks = state.notebooks.filter(n => n.id !== id);
if (state.ui.view === id) state.ui.view = "all";
saveState(); renderAll();
});
}
/* ============================================================
SIDEBAR DRAG-AND-DROP (move notes onto notebooks)
============================================================ */
let dragNoteIds = null;
function setupSidebarDnD(root) {
root.addEventListener("dragover", e => {
const target = e.target.closest('[data-droppable]');
$$(".drop-target", root).forEach(x => x.classList.remove("drop-target"));
if (target && dragNoteIds) { e.preventDefault(); target.classList.add("drop-target"); }
});
root.addEventListener("dragleave", e => {
const t = e.target.closest('[data-droppable]'); if (t) t.classList.remove("drop-target");
});
root.addEventListener("drop", e => {
const target = e.target.closest('[data-droppable]');
$$(".drop-target", root).forEach(x => x.classList.remove("drop-target"));
if (!target || !dragNoteIds) return;
e.preventDefault();
moveNotes(dragNoteIds, target.dataset.view);
dragNoteIds = null;
});
}
function moveNotes(ids, nbId) {
ids.forEach(id => { const n = getNote(id); if (n) { n.notebookId = nbId; n.updatedAt = Date.now(); } });
saveState(); renderAll();
}
/* ============================================================
RENDERING — NOTE LIST
============================================================ */
function highlightSnippet(text, q) {
let snip = text.slice(0, CFG.SNIPPET_LEN * 2);
if (q && CFG.SEARCH_HL) {
const i = snip.toLowerCase().indexOf(q);
if (i >= 0) {
const start = Math.max(0, i - 30);
snip = (start > 0 ? "…" : "") + snip.slice(start, start + CFG.SNIPPET_LEN + 30);
const j = snip.toLowerCase().indexOf(q);
snip = esc(snip.slice(0, j)) + "<mark class='hl'>" + esc(snip.slice(j, j + q.length)) + "</mark>" + esc(snip.slice(j + q.length));
return snip;
}
}
return esc(text.slice(0, CFG.SNIPPET_LEN));
}
function renderNoteList() {
const v = state.ui.view;
$("#nl-title").textContent =
v === "all" ? "All Notes" : v === "trash" ? "Trash" : getNotebook(v)?.name || "Notes";
const q = state.ui.search.trim().toLowerCase();
$$("#sort-controls .sort-btn").forEach(b => {
b.classList.toggle("active", b.dataset.sort === state.ui.sortMode);
const labels = { updated: "Modified", created: "Created", alpha: "A–Z" };
b.textContent = labels[b.dataset.sort] + (b.dataset.sort === state.ui.sortMode ? " ↓" : "");
});
const listEl = $("#note-list");
const notes = sortedNotes(visibleNotes());
$("#nl-count").textContent = notes.length + " note" + (notes.length === 1 ? "" : "s");
if (!notes.length) {
listEl.innerHTML = `<div class="empty-pane">${
v === "trash" ? "Trash is empty" :
q ? "No notes match your search." :
state.ui.tagFilter ? `No notes tagged #${esc(state.ui.tagFilter)}.` :
"No notes yet.<br>Press + to create one."}</div>`;
return;
}
let h = "";
if (v !== "trash") {
const pinned = notes.filter(n => n.pinned), others = notes.filter(n => !n.pinned);
if (pinned.length) h += `<div class="section-head">📌 Pinned</div>`;
h += pinned.map(n => noteCard(n, q)).join("");
if (others.length) {
h += (pinned.length ? `<div class="section-head">Notes</div>` : "") + others.map(n => noteCard(n, q)).join("");
}
} else {
h = notes.map(n => noteCard(n, q)).join("");
}
listEl.innerHTML = h;
$$(".note-card", listEl).forEach(card => {
card.addEventListener("click", () => openNote(card.dataset.id));
card.addEventListener("contextmenu", e => { e.preventDefault(); noteContextMenu(e, card.dataset.id); });
card.addEventListener("dragstart", e => {
const sel = getSelectedIds(card.dataset.id);
dragNoteIds = sel;
e.dataTransfer.setData("text/plain", sel.join(","));
e.dataTransfer.effectAllowed = "move";
card.classList.add("dragging");
});
card.addEventListener("dragend", () => card.classList.remove("dragging"));
});
}
function noteCard(n, q) {
const snippet = highlightSnippet(extractText(n.html), q);
const titleMatch = q && n.title.toLowerCase().includes(q) && CFG.SEARCH_HL;
const titleHtml = titleMatch
? esc(n.title).replace(new RegExp("(" + q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "ig"), "<mark class='hl'>$1</mark>")
: esc(n.title || "Untitled");
return `<div class="note-card ${state.ui.selectedNoteId === n.id ? "selected" : ""}" data-id="${n.id}" draggable="true">
<div class="nc-title-row">
${n.pinned ? '<span class="pin-glyph">📌</span>' : ""}
<span class="nc-title">${titleHtml}</span>
</div>
<div class="nc-snippet">${snippet || "<i style='color:var(--text-faint)'>Empty note</i>"}</div>
<div class="nc-meta">
<span>${relTime(n.updatedAt)}</span>
<span class="nc-tags">${n.tags.map(t => `<span class="nc-tag">#${esc(t)}</span>`).join("")}</span>
</div>
</div>`;
}
function getSelectedIds(clickedId) {
if (multiSelection.has(clickedId)) return [...multiSelection];
return [clickedId];
}
const multiSelection = new Set();
function noteContextMenu(e, id) {
const n = getNote(id);
if (n.trashed) {
showMenu(e.clientX, e.clientY, [
{ label: "Restore", icon: "↩️", fn: () => restoreFromTrash([id]) },
{ label: "Delete permanently", icon: "🗑", danger: true, fn: () => purgeNotes([id]) },
]);
return;
}
const nbItems = state.notebooks.map(nb => ({
label: "Move to " + nb.name, icon: "📓", fn: () => moveNotes([id], nb.id),
}));
showMenu(e.clientX, e.clientY, [
{ label: n.pinned ? "Unpin" : "Pin to top", icon: "📌", fn: () => { n.pinned = !n.pinned; saveState(); renderAll(); } },
{ label: "Duplicate", icon: "⧉", fn: () => duplicateNote(id) },
...nbItems,
{ sep: true },
{ label: "Move to Trash", icon: "🗑", danger: true, fn: () => trashNotes([id]) },
]);
}
/* ============================================================
EDITOR — RENDER & MODEL SYNC
============================================================ */
let currentNote = null;
let undoStack = [], redoStack = [];
let suppressHistory = false;
function renderEditorPane() {
const pane = $("#editor-pane");
const n = currentNote ? getNote(currentNote) : null;
if (!n || n.trashed && state.ui.view !== "trash") {
pane.innerHTML = `<div class="editor-empty">
<div class="big-glyph">🖋</div>
<h2>Select or create a note</h2>
<button class="btn-primary" id="btn-empty-new">+ New note</button>
</div>`;
$("#btn-empty-new").addEventListener("click", createNote);
return;
}
pane.innerHTML = `
<div class="ed-toolbar" id="ed-toolbar">
<button class="tb-btn" data-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button class="tb-btn" data-cmd="italic" title="Italic (Ctrl+I)"><span class="serif-i">I</span></button>
<button class="tb-btn" data-cmd="underline" title="Underline (Ctrl+U)"><u>U</u></button>
<button class="tb-btn" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button>
<span class="tb-sep"></span>
<button class="tb-btn" data-block="code" title="Inline code" style="font-family:var(--font-mono);font-size:12px"></></button>
<span class="tb-sep"></span>
<button class="tb-btn" data-block="h1" title="Heading 1">H1</button>
<button class="tb-btn" data-block="h2" title="Heading 2">H2</button>
<button class="tb-btn" data-block="h3" title="Heading 3">H3</button>
<button class="tb-btn" data-block="p" title="Body text">¶</button>
<span class="tb-sep"></span>
<button class="tb-btn" data-list="ul" title="Bulleted list">• ≡</button>
<button class="tb-btn" data-list="ol" title="Numbered list">1. ≡</button>
<button class="tb-btn" data-list="checklist" title="Checklist">☑</button>
<span class="tb-sep"></span>
<button class="tb-btn" data-block="blockquote" title="Blockquote">❝</button>
<button class="tb-btn" data-hr="1" title="Horizontal rule">—</button>
<span style="flex:1"></span>
<button class="icon-btn" id="btn-ed-menu" title="More actions">⋯</button>
</div>
<div class="ed-scroll">
<div class="ed-inner">
${n.trashed ? `<div class="trashed-banner">🗑 This note is in the Trash.
<button id="btn-restore-here">Restore</button><span style="flex:1"></span>
<button class="del" id="btn-purge-here">Delete forever</button></div>` : ""}
<div class="ed-meta">
<span class="ed-time">${n.trashed ? "Deleted · " : "Edited "}${relTime(n.updatedAt)}</span>
<span class="saved-ind" id="saved-ind"><span class="saved-dot"></span> Saved</span>
${state.ui.view !== "trash" ? `<span style="flex:1"></span>
<button class="icon-btn" id="btn-pin" title="${n.pinned ? 'Unpin' : 'Pin'}">${n.pinned ? "📌" : "📍"}</button>` : ""}
</div>
<input id="title-input" type="text" placeholder="Untitled" value="${esc(n.title)}">
<div class="tags-row" id="tags-row">
${n.tags.map(t => `<span class="ed-tag">#${esc(t)}<button data-rm-tag="${esc(t)}">×</button></span>`).join("")}
${!n.trashed ? `<input id="tag-input" type="text" placeholder="+ add tag">` : ""}
</div>
<div id="editor" contenteditable="true" spellcheck="true"
${n.html.trim() ? "" : 'data-placeholder="Start writing…"'}>${n.html}</div>
</div>
</div>`;
bindEditorEvents();
}
function openNote(id) {
flushPendingSave();
currentNote = id;
state.ui.selectedNoteId = id;
multiSelection.clear();
undoStack = []; redoStack = [];
saveState();
renderSidebar(); renderNoteList(); renderEditorPane();
}
function selectView(v) {
flushPendingSave();
state.ui.view = v;
state.ui.tagFilter = null;
if (v === "trash") { /* keep selection */ }
saveState();
renderSidebar(); renderNoteList(); renderEditorPane();
}
/* ---------- autosave ---------- */
let saveTimer = null, pendingHtml = null, pendingTitle = null;
function scheduleSave() {
$("#saved-ind")?.classList.remove("show");
clearTimeout(saveTimer);
saveTimer = setTimeout(commitSave, CFG.AUTOSAVE_DEBOUNCE);
}
function commitSave() {
const n = currentNote && getNote(currentNote);
if (!n) return;
let changed = false;
if (pendingHtml !== null) {
const clean = ensureChecklist(sanitizeHTML(pendingHtml));
if (clean !== n.html) { n.html = clean; changed = true; }
pendingHtml = null;
}
if (pendingTitle !== null) { n.title = pendingTitle; pendingTitle = null; changed = true; }
if (changed) { n.updatedAt = Date.now(); saveState(); showSaved(); renderSidebar(); renderNoteList(); }
}
function flushPendingSave() { clearTimeout(saveTimer); commitSave(); }
function showSaved() {
const el = $("#saved-ind");
if (!el) return;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1600);
}
// make sure a <ul> containing checklist items carries .checklist
function ensureChecklist(html) {
if (!/<li[^>]*data-checked/.test(html)) return html.replace(/<ul class="checklist">/g, "<ul>");
return html.replace(/<ul>(?![^<]*<\/li>\s*<\/ul>\s*$)/g, "<ul>");
}
/* ============================================================
UNDO / REDO (snapshot-based, per note)
============================================================ */
function pushUndo() {
const n = currentNote && getNote(currentNote);
if (!n) return;
undoStack.push({ html: n.html, title: n.title });
if (undoStack.length > CFG.UNDO_LIMIT) undoStack.shift();
redoStack.length = 0;
}
function applySnapshot(s) {
const n = getNote(currentNote); if (!n) return;
n.html = s.html; n.title = s.title; n.updatedAt = Date.now();
saveState();
renderEditorPane(); renderNoteList(); renderSidebar();
}
function doUndo() {
if (!undoStack.length) { document.execCommand("undo"); return; }
const n = getNote(currentNote); if (!n) return;
redoStack.push({ html: n.html, title: n.title });
applySnapshot(undoStack.pop());
}
function doRedo() {
if (!redoStack.length) return;
const n = getNote(currentNote); if (!n) return;
undoStack.push({ html: n.html, title: n.title });
applySnapshot(redoStack.pop());
}
/* ============================================================
EDITOR EVENTS
============================================================ */
function bindEditorEvents() {
const ed = $("#editor");
const titleInput = $("#title-input");
// --- title ---
titleInput.addEventListener("input", () => {
pendingTitle = titleInput.value;
scheduleSave();
});
titleInput.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); ed.focus(); }
});
// --- body input: autosave + markdown shortcuts + history push ---
ed.addEventListener("input", () => {
ed.removeAttribute("data-placeholder");
// debounce model write; capture pre-change snapshot for undo once per burst
if (!pendingHtml) pushUndo();
pendingHtml = ed.innerHTML;
handleMarkdownShortcut(ed);
scheduleSave();
});
// --- keyboard ---
ed.addEventListener("keydown", e => {
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && e.key.toLowerCase() === "z") { e.preventDefault(); doUndo(); return; }
if ((mod && e.shiftKey && e.key.toLowerCase() === "z") || (mod && e.key.toLowerCase() === "y")) { e.preventDefault(); doRedo(); return; }
if (mod && e.key.toLowerCase() === "b") { e.preventDefault(); exec("bold"); updateToolbarState(); return; }
if (mod && e.key.toLowerCase() === "i") { e.preventDefault(); exec("italic"); updateToolbarState(); return; }
if (mod && e.key.toLowerCase() === "u") { e.preventDefault(); exec("underline"); updateToolbarState(); return; }
if (e.key === "Enter") handleEnter(e, ed);
if (e.key === "Tab") {
e.preventDefault();
document.execCommand(e.shiftKey ? "outdent" : "indent");
}
});
// --- toolbar state reflection ---
["keyup", "mouseup", "focus", "selectionchange-proxy"].forEach(ev =>
ed.addEventListener(ev, updateToolbarState));
document.addEventListener("selectionchange", () => {
const sel = window.getSelection();
if (sel.anchorNode && ed.contains(sel.anchorNode)) updateToolbarState();
});
// --- toolbar clicks ---
$("#ed-toolbar").addEventListener("mousedown", e => e.preventDefault()); // keep selection
$("#ed-toolbar").addEventListener("click", e => {
const btn = e.target.closest(".tb-btn"); if (!btn) return;
ed.focus({ preventScroll: true });
restoreRange();
if (btn.dataset.cmd) exec(btn.dataset.cmd);
else if (btn.dataset.block) applyBlock(btn.dataset.block);
else if (btn.dataset.list) applyList(btn.dataset.list);
else if (btn.dataset.hr) insertHR();
pushUndoFromDOM();
updateToolbarState();
scheduleSave();
});
// --- checkbox clicks ---
ed.addEventListener("click", e => {
const li = e.target.closest("ul.checklist li");
if (!li) return;
const r = document.createRange(), rect = li.getBoundingClientRect();
r.selectNodeContents(li);
const boxX = rect.left + 10;
if (e.clientX <= boxX) {
li.dataset.checked = li.dataset.checked === "true" ? "false" : "true";
pushUndoFromDOM();
pendingHtml = ed.innerHTML; scheduleSave();
}
});
// --- sanitized paste ---
ed.addEventListener("paste", e => {
e.preventDefault();
let html = e.clipboardData.getData("text/html");
if (!html) {
const text = e.clipboardData.getData("text/plain");
// simple markdown-ish plain text paste: split lines into paragraphs
html = text.split(/\n{2,}/).map(p =>
"<p>" + esc(p).replace(/\n/g, "<br>") + "</p>").join("");
}
const clean = sanitizeHTML(html);
document.execCommand("insertHTML", false, clean || esc(e.clipboardData.getData("text/plain")));
pushUndoFromDOM();
pendingHtml = ed.innerHTML;
scheduleSave();
});
// --- tags ---
$$("#tags-row [data-rm-tag]").forEach(b => b.addEventListener("click", () => {
const n = getNote(currentNote);
n.tags = n.tags.filter(t => t !== b.dataset.rmTag);
saveState(); renderAll();
}));
const tagIn = $("#tag-input");
tagIn?.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagIn.value);
} else if (e.key === "Backspace" && !tagIn.value) {
const n = getNote(currentNote);
if (n.tags.length) { n.tags.pop(); saveState(); renderAll(); }
}
});
tagIn?.addEventListener("blur", () => tagIn.value && addTag(tagIn.value));
// --- header buttons ---
$("#btn-pin")?.addEventListener("click", () => {
const n = getNote(currentNote); n.pinned = !n.pinned; saveState(); renderAll();
});
$("#btn-restore-here")?.addEventListener("click", () => restoreFromTrash([currentNote]));
$("#btn-purge-here")?.addEventListener("click", () => purgeNotes([currentNote]));
$("#btn-ed-menu").addEventListener("click", e => {
const n = getNote(currentNote);
if (n.trashed) {
showMenu(e.clientX, e.clientY, [
{ label: "Restore", icon: "↩️", fn: () => restoreFromTrash([currentNote]) },
{ label: "Delete permanently", icon: "🗑", danger: true, fn: () => purgeNotes([currentNote]) },
]);
} else {
showMenu(e.clientX, e.clientY, [
{ label: n.pinned ? "Unpin" : "Pin to top", icon: "📌", fn: () => { n.pinned = !n.pinned; saveState(); renderAll(); } },
{ label: "Duplicate", icon: "⧉", fn: () => duplicateNote(currentNote) },
...state.notebooks.map(nb => ({ label: "Move to " + nb.name, icon: "📓", fn: () => moveNotes([currentNote], nb.id) })),
{ sep: true },
{ label: "Move to Trash", icon: "🗑", danger: true, fn: () => trashNotes([currentNote]) },
]);
}
});
}
function addTag(raw) {
const t = raw.trim().replace(/^#/, "").toLowerCase();
const tagIn = $("#tag-input");
if (!t) { if (tagIn) tagIn.value = ""; return; }
const n = getNote(currentNote);
if (!n.tags.includes(t)) n.tags.push(t);
saveState(); renderAll();
}
/* ---------- selection helpers for toolbar ---------- */
let savedRange = null;
document.addEventListener("selectionchange", () => {
const sel = window.getSelection();
if (sel.rangeCount && !sel.isCollapsed) savedRange = sel.getRangeAt(0).cloneRange();
});
function restoreRange() {
const sel = window.getSelection();
if (savedRange) { sel.removeAllRanges(); sel.addRange(savedRange); }
}
function exec(cmd, val) {
document.execCommand(cmd, false, val);
pendingHtml = $("#editor")?.innerHTML ?? pendingHtml;
}
function pushUndoFromDOM() {
// called after DOM mutation via execCommand: record previous model state already pushed on input;
// here we just mark dirty
pendingHtml = $("#editor")?.innerHTML ?? null;
}
function currentBlock(el) {
const blocks = "H1,H2,H3,P,DIV,BLOCKQUOTE,LI,PRE";
let node = el;
while (node && node.id !== "editor") {
if (node.matches?.(blocks)) return node;
node = node.parentNode;
}
return null;
}
function updateToolbarState() {
const ed = $("#editor"); if (!ed) return;
const sel = window.getSelection();
const inEd = sel.anchorNode && ed.contains(sel.anchorNode);
$$("[data-cmd]", ed.closest("#editor-pane")).forEach(b =>
b.classList.toggle("active", inEd && document.queryCommandState(b.dataset.cmd)));
if (!inEd) return;
const block = currentBlock(sel.anchorNode);
const blockType = block ? block.tagName.toLowerCase()
: block?.closest?.("blockquote") ? "blockquote" : "";
const inQuote = block && !!block.closest("blockquote");
$$("[data-block]", ed.closest("#editor-pane")).forEach(b => {
let active = false;
if (b.dataset.block === "blockquote") active = inQuote;
else if (block) active = blockType === b.dataset.block ||
(b.dataset.block === "p" && ["p","div"].includes(blockType));
b.classList.toggle("active", active);
});
$$("[data-list]", ed.closest("#editor-pane")).forEach(b => {
const li = block && block.tagName === "LI" ? block : null;
let active = false;
if (li) {
const parentList = li.parentElement;
if (b.dataset.list === "ul") active = parentList.tagName === "UL" && !parentList.classList.contains("checklist");
if (b.dataset.list === "ol") active = parentList.tagName === "OL";
if (b.dataset.list === "checklist") active = parentList.classList.contains("checklist");
}
b.classList.toggle("active", active);
});
}
/* ============================================================
BLOCK FORMATTING
============================================================ */
function applyBlock(type) {
if (type === "code") {
const sel = window.getSelection();
if (!sel.isCollapsed) document.execCommand("insertHTML", false,
"<code>" + sel.toString().replace(/</g, "<") + "</code>");
else insertHTMLAtCaret("<code> </code>");
return;
}
if (type === "blockquote") {
const b = currentBlock(window.getSelection().anchorNode);
if (b && b.closest("blockquote")) unwrapBlockquote(b);
else document.execCommand("formatBlock", false, "blockquote");
return;
}
document.execCommand("formatBlock", false, type === "p" ? "p" : type);
}
function unwrapBlockquote(b) {
const quote = b.closest("blockquote");
while (quote.firstChild) quote.parentNode.insertBefore(quote.firstChild, quote);
quote.remove();
}
function insertHR() {
insertHTMLAtCaret("<hr>");
}
function insertHTMLAtCaret(html) {
document.execCommand("insertHTML", false, html);
}
function applyList(kind) {
const sel = window.getSelection();
const block = currentBlock(sel.anchorNode);
if (kind === "checklist") {
let li = block && (block.tagName === "LI" ? block : null);
if (!li) { document.execCommand("insertUnorderedList"); li = currentBlock(sel.anchorNode); }
const list = li?.parentElement;
if (list) {
if (list.classList.contains("checklist")) {
list.classList.remove("checklist");
$$("li", list).forEach(l => l.removeAttribute("data-checked"));
} else {
list.classList.add("checklist");
$$("li", list).forEach(l => l.setAttribute("data-checked", "false"));
}
}
return;
}
document.execCommand(kind === "ol" ? "insertOrderedList" : "insertUnorderedList");
}
/* ============================================================
ENTER BEHAVIOR — lists continue / exit on empty item
============================================================ */
function handleEnter(e, ed) {
const sel = window.getSelection();
const block = currentBlock(sel.anchorNode);
if (!block || block.tagName !== "LI") return;
const li = block;
const isEmpty = !li.textContent.trim();
if (isEmpty) {
e.preventDefault();
const list = li.parentElement;
const isLast = !li.nextElementSibling;
if (isLast) {
li.remove();
if (!list.children.length) list.remove();
// insert paragraph after list
const p = document.createElement("p");
p.innerHTML = "<br>";
list.parentNode.insertBefore(p, list.nextSibling);
placeCaret(p, 0);
} else {
// split: remove this empty li, put caret in new para after it
const p = document.createElement("p"); p.innerHTML = "<br>";
li.parentNode.insertBefore(p, li.nextSibling);
li.remove();
placeCaret(p, 0);
}
return;
}
// non-empty last checklist/bullet item: browser continues list fine,
// but ensure checklist items carry data-checked="false"
setTimeout(() => {
const s2 = window.getSelection();
const b2 = currentBlock(s2.anchorNode);
if (b2 && b2.tagName === "LI" && b2.parentElement.classList.contains("checklist")
&& !b2.hasAttribute("data-checked")) {
b2.setAttribute("data-checked", "false");
}
}, 0);
}
function placeCaret(el, offset) {
const r = document.createRange();
r.setStart(el, Math.min(offset, el.childNodes.length));
r.collapse(true);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
}
/* ============================================================
MARKDOWN INPUT SHORTCUTS
============================================================ */
const MD_RULES = [
{ re: /^#$/, block: "h1" },
{ re: /^##$/, block: "h2" },
{ re: /^###$/, block: "h3" },
{ re: /^[-*]$/, list: "ul" },
{ re: /^1[.)]$/, list: "ol" },
{ re: /^\[\]\(?$/, checklist: true },
{ re: /^>$/, block: "blockquote" },
];
function handleMarkdownShortcut(ed) {
const sel = window.getSelection();
if (!sel.isCollapsed || !sel.anchorNode) return;
const node = sel.anchorNode;
if (node.nodeType !== Node.TEXT_NODE) return;
const block = currentBlock(node);
if (!block || block.tagName === "LI") return; // only at line start of plain blocks
const textBefore = node.textContent.slice(0, sel.anchorOffset);
const m = textBefore.match(/^(#{1,3}|[-*]|1[.)]|\[\]\(?)$/);
if (!m) return;
// must be at line start: no earlier text in this block
if (getLineTextBefore(block, node, sel.anchorOffset).replace(m[0], "") !== "") return;
const rule = MD_RULES.find(r => r.re.test(textBefore));
if (!rule) return;
// remove the marker characters
node.textContent = node.textContent.slice(sel.anchorOffset);
if (rule.block) {
document.execCommand("formatBlock", false, rule.block);
} else if (rule.checklist) {
document.execCommand("insertUnorderedList");
const li = currentBlock(window.getSelection().anchorNode);
if (li) {
const list = li.parentElement;
list.classList.add("checklist");
$$("li", list).forEach(l => l.setAttribute("data-checked", "false"));
}
} else if (rule.list) {
document.execCommand(rule.list === "ol" ? "insertOrderedList" : "insertUnorderedList");
}
}
function getLineTextBefore(block, node, offset) {
let text = "";
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let cur;
while ((cur = walker.nextNode())) {
if (cur === node) { text += cur.textContent.slice(0, offset); break; }
text += cur.textContent;
}
return text;
}
/* ============================================================
NOTE CRUD / TRASH
============================================================ */
function createNote() {
flushPendingSave();
let nbId = state.ui.view;
if (nbId === "all" || nbId === "trash" || !getNotebook(nbId)) {
nbId = state.notebooks[0]?.id;
if (!nbId) { state.notebooks.push({ id: uid(), name: "Notebook", collapsed: false }); nbId = state.notebooks[0].id; }
}
const t = Date.now();
const n = {
id: uid(), notebookId: nbId, title: "",
html: "<p><br></p>", tags: [], pinned: false,
createdAt: t, updatedAt: t, trashed: false,
};
state.notes.unshift(n);
if (state.ui.view === "trash") state.ui.view = "all";
saveState();
openNote(n.id);
setTimeout(() => $("#title-input")?.focus(), 30);
}
function duplicateNote(id) {
const n = getNote(id); if (!n) return;
const copy = { ...n, id: uid(), title: n.title + " (copy)", pinned: false, createdAt: Date.now(), updatedAt: Date.now(), tags: [...n.tags] };
state.notes.splice(state.notes.indexOf(n) + 1, 0, copy);
saveState(); renderAll();
}
function trashNotes(ids) {
ids.forEach(id => {
const n = getNote(id);
if (n) { n.trashed = true; n.trashedFrom = n.notebookId; n.trashedAt = Date.now(); }
});
if (ids.includes(state.ui.selectedNoteId)) { currentNote = null; state.ui.selectedNoteId = null; }
saveState(); renderAll();
}
function restoreFromTrash(ids) {
ids.forEach(id => {
const n = getNote(id);
if (n) {
n.trashed = false;
n.notebookId = (n.trashedFrom && getNotebook(n.trashedFrom)) ? n.trashedFrom : (state.notebooks[0]?.id);
delete n.trashedAt;
}
});
saveState(); renderAll();
}
function purgeNotes(ids) {
state.notes = state.notes.filter(n => !ids.includes(n.id));
if (ids.includes(state.ui.selectedNoteId)) { currentNote = null; state.ui.selectedNoteId = null; }
saveState(); renderAll();
}
/* ============================================================
MENUS & MODALS
============================================================ */
let openMenuEl = null;
function closeMenu() { openMenuEl?.remove(); openMenuEl = null; }
function showMenu(x, y, items) {
closeMenu();
const m = document.createElement("div");
m.className = "ctx-menu";
m.innerHTML = items.map(it => it.sep ? "<hr>" :
`<button class="${it.danger ? "danger" : ""}" data-mi="${esc(it.label)}">${it.icon || ""}<span>${esc(it.label)}</span></button>`).join("");
document.body.appendChild(m);
const r = m.getBoundingClientRect();
m.style.left = Math.min(x, innerWidth - r.width - 8) + "px";
m.style.top = Math.min(y, innerHeight - r.height - 8) + "px";
openMenuEl = m;
items.forEach(it => {
if (it.sep) return;
m.querySelector(`[data-mi="${CSS.escape(it.label)}"]`).addEventListener("click", () => { closeMenu(); it.fn(); });
});
}
document.addEventListener("click", e => { if (openMenuEl && !openMenuEl.contains(e.target)) closeMenu(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") closeMenu(); });
function promptModal(title, value, onOk) {
const bd = document.createElement("div");
bd.className = "modal-backdrop";
bd.innerHTML = `<div class="modal"><h3>${esc(title)}</h3>
<input type="text" value="${esc(value)}">
<div class="modal-actions"><button class="btn-ghost" data-cancel>Cancel</button>
<button class="btn-primary" data-ok>Save</button></div></div>`;
document.body.appendChild(bd);
const inp = $("input", bd);
inp.focus(); inp.select();
const done = ok => {
bd.remove();
if (ok) onOk(inp.value);
};
$("[data-ok]", bd).addEventListener("click", () => done(true));
$("[data-cancel]", bd).addEventListener("click", () => done(false));
bd.addEventListener("keydown", e => {
if (e.key === "Enter") done(true);
if (e.key === "Escape") done(false);
});
bd.addEventListener("click", e => { if (e.target === bd) done(false); });
}
function confirmModal(title, msg, onOk) {
const bd = document.createElement("div");
bd.className = "modal-backdrop";
bd.innerHTML = `<div class="modal"><h3>${esc(title)}</h3>
<p style="color:var(--text-dim);font-size:13px">${esc(msg)}</p>
<div class="modal-actions"><button class="btn-ghost" data-cancel>Cancel</button>
<button class="btn-primary" data-ok style="background:var(--danger)">Delete</button></div></div>`;
document.body.appendChild(bd);
$("[data-ok]", bd).addEventListener("click", () => { bd.remove(); onOk(); });
$("[data-cancel]", bd).addEventListener("click", () => bd.remove());
bd.addEventListener("click", e => { if (e.target === bd) bd.remove(); });
}
/* ============================================================
TOP-LEVEL UI EVENTS
============================================================ */
function bindChrome() {
// theme
const applyTheme = () => document.documentElement.dataset.theme = state.ui.theme;
applyTheme();
$("#btn-theme").addEventListener("click", () => {
state.ui.theme = state.ui.theme === "dark" ? "light" : "dark";
applyTheme(); saveState();
});
// sidebar collapse
const sb = $("#sidebar");
sb.classList.toggle("collapsed", state.ui.sidebarCollapsed);
$("#btn-collapse-sb").addEventListener("click", () => {
state.ui.sidebarCollapsed = !state.ui.sidebarCollapsed;
sb.classList.toggle("collapsed", state.ui.sidebarCollapsed);
saveState();
});
document.addEventListener("keydown", e => {
if ((e.ctrlKey || e.metaKey) && e.key === "\\") {
e.preventDefault();
$("#btn-collapse-sb").click();
}
});
// new note
$("#btn-new-note").addEventListener("click", createNote);
$("#btn-nl-menu").addEventListener("click", e => {
const v = state.ui.view;
const items = [];
if (v !== "all" && v !== "trash") {
items.push({ label: "Rename notebook", icon: "✏️", fn: () => renameNotebook(v) });
items.push({ label: "Delete notebook", icon: "🗑", danger: true, fn: () => deleteNotebook(v) });
items.push({ sep: true });
}
items.push({ label: "New note", icon: "📝", fn: createNote });
items.push({ label: "New notebook", icon: "📓", fn: () => $("#btn-new-nb").click() });
showMenu(e.clientX, e.clientY, items);
});
// search
const searchInput = $("#search-input");
searchInput.value = state.ui.search || "";
let searchTimer;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.ui.search = searchInput.value;
renderNoteList();
}, 120);
});
searchInput.addEventListener("keydown", e => { if (e.key === "Escape") { searchInput.value = ""; state.ui.search = ""; renderNoteList(); } });
// sort
$("#sort-controls").addEventListener("click", e => {
const b = e.target.closest(".sort-btn"); if (!b) return;
state.ui.sortMode = b.dataset.sort;
saveState(); renderNoteList();
});
// pane resizing
setupDivider($("#divider-sb"), w => {
state.ui.sbWidth = Math.min(420, Math.max(170, w));
document.documentElement.style.setProperty("--sidebar-w", state.ui.sbWidth + "px");
});
setupDivider($("#divider-nl"), w => {
state.ui.nlWidth = Math.min(480, Math.max(200, w));
document.documentElement.style.setProperty("--list-w", state.ui.nlWidth + "px");
});
document.documentElement.style.setProperty("--sidebar-w", state.ui.sbWidth + "px");
document.documentElement.style.setProperty("--list-w", state.ui.nlWidth + "px");
// global shortcuts
document.addEventListener("keydown", e => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.shiftKey && e.key.toLowerCase() === "n") { e.preventDefault(); createNote(); }
if (mod && e.key.toLowerCase() === "f") { e.preventDefault(); searchInput.focus(); searchInput.select(); }
});
window.addEventListener("beforeunload", flushPendingSave);
}
function setupDivider(handle, onDrag) {
handle.addEventListener("mousedown", e => {
e.preventDefault();
handle.classList.add("dragging");
document.body.classList.add("resizing");
const startX = e.clientX;
const pane = handle.previousElementSibling;
const startW = pane.getBoundingClientRect().width;
const move = ev => onDrag(startW + (ev.clientX - startX));
const up = () => {
handle.classList.remove("dragging");
document.body.classList.remove("resizing");
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
saveState();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
}
/* ============================================================
RENDER ALL & INIT
============================================================ */
function renderAll() {
renderSidebar();
renderNoteList();
renderEditorPane();
}
function init() {
loadState();
bindChrome();
renderAll();
// restore selection
if (state.ui.selectedNoteId && getNote(state.ui.selectedNoteId) && !getNote(state.ui.selectedNoteId).trashed) {
openNote(state.ui.selectedNoteId);
} else {
state.ui.selectedNoteId = null;
renderEditorPane();
}
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"75bb24ad-8a32-4c13-a776-aaac884a0d24","tokensIn":1048313,"tokensOut":25466,"tokensTotal":1073779,"cost":0,"turns":27,"toolCalls":26,"failedToolCalls":1,"timestamp":"2026-08-21T21:42:11.727Z"} -->