Notes
Qwen 3.8 · typescript

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 117,160 bytes · SHA-256 8919a83d42cb
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes — a calm place to write</title>
<style>
/* ── css: base ── */
/* ============ base: reset, variables, app frame ============ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font-ui);
background: var(--bg);
color: var(--text);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
:root {
--side-w: 264px;
--list-w: 320px;
}
#app {
display: grid;
grid-template-columns: var(--side-w) 6px var(--list-w) 6px 1fr;
height: 100vh;
width: 100vw;
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
#app.side-collapsed { grid-template-columns: 0 0 var(--list-w) 6px 1fr; }
body.resizing { cursor: col-resize; user-select: none; }
body.resizing * { cursor: col-resize !important; }
.pane { min-width: 0; min-height: 0; overflow: hidden; }
.pane-sidebar {
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
#app.side-collapsed .pane-sidebar,
#app.side-collapsed #rs-sidebar { opacity: 0; pointer-events: none; }
.side-toggle {
position: absolute;
left: calc(var(--side-w) - 6px);
top: 12px;
z-index: 30;
transition: left 0.28s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s;
width: 26px; height: 26px;
display: grid; place-items: center;
border-radius: 7px;
background: var(--panel2);
border: 1px solid var(--line);
color: var(--text2);
font-size: 13px;
cursor: pointer;
}
.side-toggle:hover { color: var(--text); background: var(--hover); }
/* when collapsed the button rests at the left edge, fully visible */
#app.side-collapsed .side-toggle { left: 8px; }
.rs {
width: 6px;
cursor: col-resize;
position: relative;
z-index: 20;
}
.rs::before {
content: '';
position: absolute; left: 2px; top: 0; bottom: 0; width: 1px;
background: var(--line);
transition: background 0.15s, width 0.15s, left 0.15s;
}
.rs:hover::before { width: 3px; left: 1.5px; background: var(--accent); }
/* ── css: sidebar ── */
/* ============ sidebar ============ */
.pane-sidebar > * { overflow: hidden; }
.side-group { padding: 10px 10px 4px; }
.side-group.grow { flex: 1; overflow-y: auto; }
.side-label {
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text3);
padding: 4px 8px 6px;
}
.side-rows { display: flex; flex-direction: column; gap: 1px; }
.side-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
color: var(--text);
font-size: 13.5px;
line-height: 1.35;
transition: background 0.12s, color 0.12s;
position: relative;
user-select: none;
}
.side-row:hover { background: var(--hover); }
.side-row.active { background: var(--accentSoft); color: var(--accent); font-weight: 600; }
.side-row .row-name {
flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.side-row .row-count {
font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums;
}
.side-row.active .row-count { color: var(--accent); opacity: 0.7; }
.side-row.drop-hint { background: var(--accentSoft); outline: 1.5px dashed var(--accent); outline-offset: -2px; }
.side-row .ic { width: 15px; height: 15px; flex: none; color: var(--text3); }
.side-row.active .ic { color: var(--accent); }
.side-row .chev { width: 16px; height: 16px; font-size: 11px; color: var(--text3); }
.side-row .nb-menu { opacity: 0; }
.side-row:hover .nb-menu { opacity: 1; }
.ic { display: block; }
.ic path, .ic rect, .ic circle {
fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
.tag-dot {
width: 8px; height: 8px; border-radius: 3px; flex: none;
background: var(--accent); opacity: 0.65;
}
.nb-notes { padding: 2px 0 6px 26px; display: flex; flex-direction: column; gap: 1px; }
.nb-note {
font-size: 12.5px; color: var(--text2);
padding: 4px 8px; border-radius: 6px;
cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.nb-note:hover { background: var(--hover); color: var(--text); }
.nb-note.sel { background: var(--accentSoft); color: var(--accent); }
.nb-add-note {
border: none; background: none; text-align: left;
font-size: 12px; color: var(--text3);
padding: 4px 8px; border-radius: 6px; cursor: pointer;
font-family: var(--font-ui);
}
.nb-add-note:hover { background: var(--hover); color: var(--accent); }
.nb-empty { font-size: 12px; color: var(--text3); padding: 4px 8px; font-style: italic; }
.side-foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid var(--line);
}
.side-foot-note { font-size: 11px; color: var(--text3); flex: 1; }
/* ── css: note-list ── */
/* ============ note list pane ============ */
.pane-list {
background: var(--panel);
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
}
#list-head { padding: 14px 14px 10px; border-bottom: 1px solid var(--line); }
.lh-row1 { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.lh-title { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lh-actions { display: flex; gap: 2px; flex: none; }
.lh-row2 { margin-top: 2px; }
.lh-count { font-size: 11.5px; color: var(--text3); }
.list-body { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.note-card {
background: var(--editor-bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px 9px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.12s, box-shadow 0.15s;
position: relative;
}
.note-card:hover { border-color: var(--text3); box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
.note-card.sel {
border-color: var(--accent);
background: var(--accentSoft);
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
}
.nc-title {
font-size: 13.5px; font-weight: 600; line-height: 1.35;
display: flex; align-items: center; gap: 6px;
overflow: hidden;
}
.nc-title > *:not(.pin-dot) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-title { white-space: nowrap; }
.nc-title .pin-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; opacity: 0.9; }
.nc-snippet {
font-size: 12.5px; color: var(--text2);
margin-top: 3px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
line-height: 1.45;
}
.nc-meta {
display: flex; align-items: center; gap: 6px; margin-top: 7px;
font-size: 11px; color: var(--text3);
}
.nc-date { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-tag {
font-size: 10.5px; color: var(--accent); background: var(--accentSoft);
padding: 1px 6px; border-radius: 5px; flex: none;
}
#list-foot { padding: 8px 12px; border-top: 1px solid var(--line); }
.list-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
color: var(--text3); font-size: 13px;
padding: 40px 16px; text-align: center;
}
.le-ic { font-size: 26px; opacity: 0.7; }
/* ── css: editor ── */
/* ============ editor pane ============ */
.pane-editor {
background: var(--editor-bg);
display: flex;
flex-direction: column;
position: relative;
}
.ed-topbar {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px;
border-bottom: 1px solid var(--line);
background: var(--editor-bg);
min-height: 44px;
}
#ed-title-area { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tb-note { font-size: 12px; color: var(--text3); font-variant-numeric: tabular-nums; }
.tb-sep { opacity: 0.5; margin: 0 4px; }
#ed-meta-right { display: flex; align-items: center; gap: 10px; flex: none; }
.ed-toolbar {
display: flex; align-items: center; gap: 1px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 9px;
padding: 2px;
flex-wrap: nowrap;
max-width: 100%;
overflow-x: auto;
}
.ed-toolbar.disabled { opacity: 0.55; }
.tb-btn {
border: none; background: none;
min-width: 28px; height: 28px;
padding: 0 7px;
border-radius: 6px;
font-family: var(--font-ui);
font-size: 12.5px; font-weight: 600;
color: var(--text2);
cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
}
.tb-btn:hover { background: var(--hover); color: var(--text); }
.tb-btn.active { background: var(--accentSoft); color: var(--accent); }
.tb-sep { width: 1px; height: 18px; background: var(--line); margin: 0 3px; flex: none; }
.tb-code { font-family: var(--font-mono); font-size: 11px; font-weight: 700; }
.tb-ul { font-size: 15px; line-height: 1; }
.tb-ol { font-family: var(--font-mono); font-size: 11px; font-weight: 700; }
.tb-task { font-size: 12px; }
.saved-indicator {
font-size: 11.5px; color: var(--text3);
opacity: 0; transition: opacity 0.3s;
font-variant-numeric: tabular-nums;
}
.saved-indicator.show { opacity: 1; }
.saved-indicator.flash { color: var(--accent); }
#editor-host { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.ed-scroll { flex: 1; overflow-y: auto; }
.ed-doc {
max-width: 780px;
margin: 0 auto;
padding: 34px 48px 120px;
width: 100%;
}
.ed-meta { margin-bottom: 6px; }
.ed-title {
font-family: var(--font-ui);
font-size: 27px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25;
outline: none;
caret-color: var(--accent);
min-height: 34px;
}
.ed-title:empty::before { content: attr(data-ph); color: var(--text3); }
.ed-title.qn-empty-title:empty::before { content: 'Untitled'; }
.ed-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; min-height: 0; }
.ed-tags:empty { display: none; }
.tag-chip {
display: inline-flex; align-items: center; gap: 3px;
background: var(--accentSoft); color: var(--accent);
border-radius: 6px; padding: 2px 4px 2px 8px;
font-size: 11.5px; font-weight: 600;
transition: filter 0.12s;
}
.tag-chip:hover { filter: brightness(0.96); }
.tag-x {
border: none; background: none; color: inherit; cursor: pointer;
font-size: 13px; line-height: 1; padding: 1px 4px; border-radius: 4px; opacity: 0.7;
}
.tag-x:hover { opacity: 1; background: rgba(0,0,0,0.06); }
.tag-input {
border: none; background: none; outline: none;
font-family: var(--font-ui); font-size: 11.5px; color: var(--text2);
width: 84px; padding: 3px 4px; border-radius: 6px;
}
.tag-input::placeholder { color: var(--text3); }
.tag-input:focus { background: var(--hover); }
/* --- editor content (serif reading typography) --- */
.ed-content {
font-family: var(--font-editor);
font-size: 16.5px;
line-height: 1.72;
outline: none;
caret-color: var(--accent);
color: var(--text);
word-wrap: break-word;
}
.ed-content p { margin: 0 0 0.65em; }
.ed-content p:empty { min-height: 1.4em; }
.ed-content p:only-child:empty { margin: 0; }
.ed-content h1 { font-size: 1.85em; line-height: 1.25; margin: 0.9em 0 0.5em; letter-spacing: -0.02em; }
.ed-content h2 { font-size: 1.45em; line-height: 1.3; margin: 0.9em 0 0.45em; letter-spacing: -0.015em; }
.ed-content h3 { font-size: 1.18em; line-height: 1.35; margin: 0.85em 0 0.4em; }
.ed-content h1:first-child, .ed-content h2:first-child, .ed-content h3:first-child { margin-top: 0.2em; }
.ed-content blockquote {
border-left: 3px solid var(--accent);
margin: 0.8em 0;
padding: 0.35em 1.1em;
color: var(--text2);
background: var(--accentSoft);
border-radius: 0 8px 8px 0;
}
.ed-content blockquote p { margin-bottom: 0.2em; }
.ed-content blockquote p:last-child { margin-bottom: 0; }
.ed-content ul, .ed-content ol { margin: 0.4em 0 0.8em; padding-left: 1.5em; }
.ed-content li { margin: 0.18em 0; }
.ed-content li::marker { color: var(--text3); }
.ed-content ul { list-style: disc; }
.ed-content ol { list-style: decimal; }
/* task list */
.ed-content li.qn-task { list-style: none; margin-left: -1.45em; }
.ed-content li.qn-task .qn-task-box {
display: inline-block;
width: 15px; height: 15px;
border: 1.5px solid var(--text3);
border-radius: 4px;
margin-right: 9px;
vertical-align: -2px;
cursor: pointer;
position: relative;
transition: background 0.12s, border-color 0.12s;
background: transparent;
}
.ed-content li.qn-task .qn-task-box:hover { border-color: var(--accent); }
.ed-content li.qn-task[data-checked="1"] .qn-task-box {
background: var(--accent);
border-color: var(--accent);
}
.ed-content li.qn-task[data-checked="1"] .qn-task-box::after {
content: '✓';
position: absolute; inset: 0;
display: grid; place-items: center;
color: #fff; font-size: 10px; font-weight: 700;
font-family: var(--font-ui);
}
.ed-content li.qn-task[data-checked="1"] .qn-task-label {
text-decoration: line-through;
color: var(--text3);
}
.ed-content .qn-task-label { display: inline; }
.ed-content code {
font-family: var(--font-mono);
font-size: 0.82em;
background: var(--panel2);
border-radius: 4px;
padding: 1.5px 5px;
border: 1px solid var(--line);
}
.ed-content hr {
border: none;
border-top: 1px solid var(--line);
margin: 1.6em 0;
}
.ed-content b, .ed-content strong { font-weight: 700; }
/* empty state */
.ed-empty {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px;
color: var(--text3);
pointer-events: none;
transition: opacity 0.2s;
}
.ed-empty.hidden { opacity: 0; }
.ee-ic {
font-size: 34px;
width: 74px; height: 74px;
display: grid; place-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
color: var(--accent);
margin-bottom: 10px;
}
.ee-title { font-size: 16px; font-weight: 600; color: var(--text2); }
.ee-sub { font-size: 13px; }
.ee-hints { display: flex; gap: 14px; margin-top: 16px; font-size: 12px; }
kbd {
font-family: var(--font-ui);
font-size: 10.5px;
background: var(--panel2);
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 6px;
color: var(--text2);
}
/* ── css: overlays ── */
/* ============ search overlay, menus, buttons ============ */
.overlay {
position: fixed; inset: 0;
background: rgba(30, 24, 12, 0.28);
backdrop-filter: blur(2px);
z-index: 100;
display: none;
padding: 9vh 0 0;
}
.overlay.open { display: block; }
.search-panel {
width: min(620px, 88vw);
margin: 0 auto;
background: var(--editor-bg);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
overflow: hidden;
animation: dropIn 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes dropIn {
from { opacity: 0; transform: translateY(-10px) scale(0.985); }
to { opacity: 1; transform: none; }
}
.search-input-row {
display: flex; align-items: center; gap: 10px;
padding: 13px 16px;
border-bottom: 1px solid var(--line);
}
.search-input-row .ic { width: 17px; height: 17px; color: var(--text3); }
.search-input {
flex: 1; border: none; background: none; outline: none;
font-family: var(--font-ui);
font-size: 15.5px; color: var(--text);
}
.search-input::placeholder { color: var(--text3); }
.search-results { max-height: 52vh; overflow-y: auto; }
.sr-empty { padding: 22px 16px; color: var(--text3); font-size: 13px; text-align: center; }
.sr-row {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--line);
transition: background 0.1s;
}
.sr-row:last-child { border-bottom: none; }
.sr-row:hover { background: var(--hover); }
.sr-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.sr-hit {
font-size: 10px; font-weight: 600; color: var(--accent);
background: var(--accentSoft); padding: 1px 6px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.05em;
}
.sr-ctx { font-size: 12.5px; color: var(--text2); margin-top: 2px; line-height: 1.5; font-family: var(--font-editor); }
.sr-ctx mark, .nc-title mark, mark {
background: var(--mark); color: inherit; border-radius: 2px; padding: 0 1px;
}
.menu {
position: fixed; z-index: 200;
background: var(--editor-bg);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 5px;
min-width: 190px;
display: flex; flex-direction: column; gap: 1px;
animation: menuIn 0.12s ease-out;
}
@keyframes menuIn { from { opacity: 0; transform: translateY(-3px) scale(0.98); } to { opacity: 1; transform: none; } }
.menu-item {
border: none; background: none; text-align: left;
font-family: var(--font-ui);
font-size: 13px; color: var(--text);
padding: 7px 10px;
border-radius: 7px;
cursor: pointer;
transition: background 0.1s;
white-space: nowrap;
}
.menu-item:hover { background: var(--hover); }
.menu-item.danger { color: var(--danger); }
.menu-item.danger:hover { background: rgba(191, 59, 31, 0.1); }
.menu-item.active { color: var(--accent); font-weight: 600; }
/* buttons */
.btn {
font-family: var(--font-ui);
font-size: 13px; font-weight: 600;
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
border: 1px solid var(--line);
background: var(--panel2);
color: var(--text);
transition: background 0.12s, border-color 0.12s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.ghost { background: none; border: none; color: var(--accent); padding: 6px 10px; }
.btn.ghost:hover { background: var(--accentSoft); }
.btn.danger-ghost { background: none; border: none; color: var(--danger); width: 100%; text-align: center; font-family: var(--font-ui); font-size: 12.5px; font-weight: 600; padding: 6px; border-radius: 7px; cursor: pointer; }
.btn.danger-ghost:hover { background: rgba(191, 59, 31, 0.1); }
/* icon buttons */
.icon-btn {
border: none; background: none;
width: 26px; height: 26px;
display: grid; place-items: center;
border-radius: 7px;
color: var(--text2);
font-size: 14px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
font-family: var(--font-ui);
}
.icon-btn:hover { background: var(--hover); color: var(--text); }
.icon-btn.tiny { width: 22px; height: 22px; font-size: 12px; }
/* scrollbars */
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--text3); background-clip: content-box; border: 2px solid transparent; }
::-webkit-scrollbar-track { background: none; }
/* ── css: vars-alias ── */
/* ============ var aliases (camel → kebab) ============ */
:root {
--accentSoft: var(--accent-soft);
--editorBg: var(--editor-bg);
}
/* ── css: editor-extra ── */
/* ============ editor pane additions ============ */
.pane-editor.no-note #editor-host { visibility: hidden; }
.pane-editor.no-note .tb-note { visibility: hidden; }
</style>
</head>
<body>
<div id="app">
<div id="side-toggle" class="icon-btn side-toggle" title="Toggle sidebar">☰</div>
<aside id="sidebar" class="pane pane-sidebar"></aside>
<div id="rs-sidebar" class="rs" title="Drag to resize"></div>
<section id="note-list" class="pane pane-list">
<div id="list-head"></div>
<div id="list-body" class="list-body"></div>
<div id="list-foot"></div>
</section>
<div id="rs-list" class="rs" title="Drag to resize"></div>
<main id="editor-pane" class="pane pane-editor">
<div class="ed-topbar">
<div id="ed-title-area"></div>
<div id="ed-toolbar" class="ed-toolbar"></div>
<div id="ed-meta-right">
<span id="saved-indicator" class="saved-indicator"></span>
</div>
</div>
<div id="editor-host"></div>
<div id="ed-empty" class="ed-empty">
<div class="ee-ic">✎</div>
<div class="ee-title">Select or create a note</div>
<div class="ee-sub">Notes you pin float to the top of their list.</div>
<div class="ee-hints">
<span><kbd>⌘K</kbd> search</span><span><kbd>⌘B</kbd> bold</span><span><kbd>⌘Z</kbd> undo</span>
</div>
</div>
</main>
<div id="overlay" class="overlay"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./ui":"src/ui.ts","./editor":"src/editor.ts","./text":"src/text.ts","./types":"src/types.ts"},"src/state.ts":{"./constants":"src/constants.ts","./text":"src/text.ts","./types":"src/types.ts"},"src/editor.ts":{"./state":"src/state.ts","./types":"src/types.ts","./text":"src/text.ts"},"src/ui.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./text":"src/text.ts","./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Main — boot sequence: load state, wire UI + editor, sync.
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
const state_1 = require("./state");
const ui_1 = require("./ui");
const editor_1 = require("./editor");
const text_1 = require("./text");
function escapeAttr(t) {
return t.replace(/"/g, '"');
}
function main() {
const s = (0, state_1.load)();
const sidebar = document.getElementById('sidebar');
const list = document.getElementById('note-list');
const editorHost = document.getElementById('editor-host');
const overlay = document.getElementById('overlay');
const savedEl = document.getElementById('saved-indicator');
const toolbar = document.getElementById('ed-toolbar');
buildToolbar(toolbar);
let editor = null;
const ui = (0, ui_1.initUi)(s, { sidebar, list, editorHost, overlay, saved: savedEl }, () => {
syncEditor();
});
editor = (0, editor_1.createEditor)(editorHost, s, () => {
ui.render();
});
(0, ui_1.applyTheme)();
let lastSyncedId = null;
const paneEditor = document.getElementById('editor-pane');
function syncEditor() {
if (!editor)
return;
const note = (0, state_1.getNote)(s, s.selectedNoteId);
const id = note && !note.deleted ? note.id : null;
const emptyEl = document.getElementById('ed-empty');
if (id !== lastSyncedId) {
lastSyncedId = id;
if (note && !note.deleted) {
emptyEl.classList.add('hidden');
paneEditor.classList.remove('no-note');
editor.selectNote(note);
// newly created, untitled note → jump straight into the title
if (!note.title) {
const t = document.querySelector('.ed-title');
if (t)
t.focus();
}
}
else {
emptyEl.classList.remove('hidden');
paneEditor.classList.add('no-note');
editor.selectNote(null);
}
}
else {
// same note — refresh metadata (tags row) in place
editor.syncNote(note);
}
if (note && !note.deleted)
updateMeta(note);
(0, ui_1.updateSavedEl)(s);
}
function updateMeta(n) {
const area = document.getElementById('ed-title-area');
const words = n.content.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
area.innerHTML = `<span class="tb-note">${n.title ? escapeAttr(n.title) : 'Untitled'} <span class="tb-sep">·</span> ${words} words</span>`;
}
syncEditor();
ui.render();
// keep the "Saved" clock fresh
setInterval(() => (0, ui_1.updateSavedEl)(s), 2000);
window.__notes = {
state: s,
flush: () => (0, state_1.flushSave)(s),
editor: () => editor,
render: () => ui.render(),
actions: state_1.actions,
search: (q) => (0, state_1.searchNotes)(s, q),
sanitize: text_1.sanitizeHtml,
extract: text_1.extractText,
};
}
// --- formatting toolbar -----------------------------------------
function buildToolbar(toolbar) {
const mk = (cmd, title, html) => {
const b = document.createElement('button');
b.className = 'tb-btn';
b.dataset.cmd = cmd;
b.title = title;
b.innerHTML = html;
b.addEventListener('mousedown', e => e.preventDefault()); // keep editor focus
toolbar.appendChild(b);
};
const sep = () => {
const d = document.createElement('span');
d.className = 'tb-sep';
toolbar.appendChild(d);
};
mk('bold', 'Bold (⌘/Ctrl+B)', '<b>B</b>');
mk('italic', 'Italic (⌘/Ctrl+I)', '<i>I</i>');
mk('underline', 'Underline (⌘/Ctrl+U)', '<u>U</u>');
mk('strike', 'Strikethrough', '<s>S</s>');
mk('code', 'Inline code', '<span class="tb-code"></></span>');
sep();
mk('h1', 'Heading 1', 'H1');
mk('h2', 'Heading 2', 'H2');
mk('h3', 'Heading 3', 'H3');
sep();
mk('ul', 'Bulleted list', '<span class="tb-ul">•</span>List');
mk('ol', 'Numbered list', '<span class="tb-ol">1.</span>List');
mk('task', 'To-do list', '<span class="tb-task">☑</span>Tasks');
sep();
mk('quote', 'Quote', '❝');
mk('hr', 'Divider', '—');
toolbar.addEventListener('click', e => {
const b = e.target.closest('.tb-btn');
if (!b)
return;
const cmd = b.dataset.cmd;
const ed = window.__notes;
ed?.editor()?.command(cmd);
});
}
main();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.C = void 0;
// ============================================================
// TUNABLE CONSTANTS — everything the app looks/feels is here.
// Colors are applied as CSS custom properties at boot.
// ============================================================
exports.C = {
// --- persistence ---
STORAGE_KEY: 'quietnotes.v1',
AUTOSAVE_DEBOUNCE_MS: 600, // typing → "Saved" latency
HISTORY_LIMIT: 120, // undo/redo snapshots per note
// --- content ---
SNIPPET_LEN: 110, // characters shown in the note list
SEARCH_BEFORE: 28, // context chars before a match in results
SEARCH_AFTER: 52, // context chars after a match in results
// --- layout ---
SIDEBAR_W: 264,
LIST_W: 320,
SIDEBAR_MIN: 200,
SIDEBAR_MAX: 380,
LIST_MIN: 230,
LIST_MAX: 500,
// --- typography ---
FONT_UI: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, 'Helvetica Neue', Arial, sans-serif",
FONT_EDITOR: "Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Times New Roman', serif",
FONT_MONO: "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
// --- color themes (keys become --kebab-case vars) ---
THEMES: {
light: {
bg: '#f6f3ec',
panel: '#efeae0',
panel2: '#e6dfd0',
editorBg: '#faf8f3',
text: '#2a2418',
text2: '#6e6553',
text3: '#9d927d',
line: '#e0d8c6',
accent: '#b45309',
accentSoft: 'rgba(180,83,9,0.10)',
hover: 'rgba(70,55,30,0.06)',
mark: '#fbe08a',
danger: '#bf3b1f',
shadow: '0 6px 20px rgba(70,50,20,0.14)',
},
dark: {
bg: '#16130e',
panel: '#1d1913',
panel2: '#262019',
editorBg: '#1a1712',
text: '#ebe4d6',
text2: '#a2957f',
text3: '#776c58',
line: '#2d271d',
accent: '#e0a458',
accentSoft: 'rgba(224,164,88,0.14)',
hover: 'rgba(255,245,225,0.055)',
mark: 'rgba(224,164,88,0.34)',
danger: '#e2725b',
shadow: '0 6px 20px rgba(0,0,0,0.45)',
},
},
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Data model — the single source of truth is serializable JSON.
// The DOM is only ever a projection of this model.
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/text.ts ──
__mods["src/text.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Text & content utilities — pure functions.
// sanitizeHtml is the single gate for all incoming HTML
// (paste, drag, and anything the model stores).
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.uid = uid;
exports.escapeHtml = escapeHtml;
exports.escapeReg = escapeReg;
exports.sanitizeHtml = sanitizeHtml;
exports.normalizeHtml = normalizeHtml;
exports.extractText = extractText;
exports.snippet = snippet;
exports.countWords = countWords;
exports.fmtDate = fmtDate;
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function escapeReg(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// --- Allowed formatting subset -------------------------------
// Everything a note can contain. Anything else is dropped by
// the sanitizer, so the model can never hold foreign markup.
const ALLOWED = {
p: true, div: true, br: true, b: true, strong: true, i: true, em: true,
u: true, s: true, del: true, code: true,
h1: true, h2: true, h3: true,
ul: true, ol: true, li: true,
blockquote: true, hr: true,
span: true, // only with our own data-* / class markers
};
const DROP_TAGS = ['script', 'style', 'iframe', 'object', 'img', 'video', 'audio', 'meta', 'link', 'embed', 'form', 'input', 'button', 'table', 'tr', 'td', 'th', 'iframe'];
/** Strip everything that isn't in ALLOWED; drop scripts, attrs, events, styles. */
function sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(`<div id="qn-root">${html}</div>`, 'text/html');
const root = doc.getElementById('qn-root');
const process = (el) => {
const tag = el.tagName.toLowerCase();
if (DROP_TAGS.includes(tag)) {
el.remove();
return;
}
if (!ALLOWED[tag]) {
// unwrap unknown containers; children are kept (and still sanitized)
const frag = doc.createDocumentFragment();
while (el.firstChild)
frag.appendChild(el.firstChild);
el.replaceWith(frag);
return;
}
if (tag === 'strike') {
const s = doc.createElement('s');
const frag = doc.createDocumentFragment();
while (el.firstChild)
frag.appendChild(el.firstChild);
s.appendChild(frag);
el.replaceWith(s);
return;
}
// clean attributes
for (const attr of Array.from(el.attributes)) {
const n = attr.name.toLowerCase();
const v = attr.value;
if (n.startsWith('on') || n === 'href' || n === 'src' || n === 'style' ||
n === 'class' && !/^(qn-[\w-]+|qn-task( checked)?|qn-title( empty)?)$/.test(v)) {
el.removeAttribute(attr.name);
}
else if (n === 'data-tag' && (v.includes('"') || v.includes(' ') || v.length > 60)) {
el.removeAttribute(attr.name);
}
}
if (tag === 'li') {
// no nested lists — flatten them out
for (const inner of Array.from(el.querySelectorAll('ul,ol')))
inner.remove();
}
};
const walk = (el) => {
for (const child of Array.from(el.children)) {
process(child);
walk(child);
}
};
walk(root);
root.innerHTML = normalizeHtml(root.innerHTML);
return root.innerHTML;
}
/** Normalize <div>/<br> soup into tidy p / h* / li blocks. */
function normalizeHtml(html) {
const doc = new DOMParser().parseFromString(`<div id="qn-root">${html}</div>`, 'text/html');
const root = doc.getElementById('qn-root');
const BLOCK = /^(H1|H2|H3|UL|OL|BLOCKQUOTE|HR|DIV|P)$/;
// 1) split DIV/P containing <br> into one block per line
for (const el of Array.from(root.querySelectorAll('div,p'))) {
let cur = el;
for (const br of Array.from(el.children).filter(c => c.nodeName === 'BR')) {
const frag = doc.createDocumentFragment();
let n = br.nextSibling;
while (n) {
const nx = n.nextSibling;
frag.appendChild(n);
n = nx;
}
const sib = doc.createElement('div');
cur.after(sib);
sib.appendChild(frag);
cur = sib;
}
}
// 2) DIV → P unless it contains block children
for (const el of Array.from(root.querySelectorAll('div'))) {
const hasBlockChild = Array.from(el.children).some(c => BLOCK.test(c.nodeName) && c.nodeName !== 'DIV');
if (!hasBlockChild) {
const p = doc.createElement('p');
const frag = doc.createDocumentFragment();
while (el.firstChild)
frag.appendChild(el.firstChild);
p.appendChild(frag);
el.replaceWith(p);
}
else {
// container holding block children: unwrap (children keep their place)
const frag = doc.createDocumentFragment();
while (el.firstChild)
frag.appendChild(el.firstChild);
el.replaceWith(frag);
}
}
// 3) empty lines get a <br> so they stay visible/editable
for (const el of Array.from(root.querySelectorAll('p,div'))) {
if (el.textContent === '' && !el.querySelector('br'))
el.innerHTML = '<br>';
}
// 4) strip trailing <br> inside li and blockquote
for (const el of Array.from(root.querySelectorAll('li,blockquote'))) {
while (el.lastChild && el.lastChild.nodeName === 'BR')
el.lastChild.remove();
}
// 5) blockquotes: recursively tidy inner HTML
for (const q of Array.from(root.querySelectorAll('blockquote'))) {
q.innerHTML = normalizeHtml(q.innerHTML);
}
return root.innerHTML;
}
/** Pure text extraction — feeds search & snippets. Ignores tags,
* includes task labels, never yields tag names like "b" or "div". */
function extractText(html) {
const doc = new DOMParser().parseFromString(`<div id="qn-root">${html}</div>`, 'text/html');
const root = doc.getElementById('qn-root');
const lines = [];
const walk = (el) => {
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 3) {
lines.push('T:' + (child.textContent || ''));
continue;
}
if (child.nodeType !== 1)
continue;
const tag = child.tagName.toLowerCase();
if (tag === 'br')
continue;
if (tag === 'hr') {
lines.push('B:hr');
continue;
}
const block = /^(h1|h2|h3|li|blockquote|p|div)$/.test(tag);
walk(child);
if (block)
lines.push('B:');
}
};
walk(root);
// fold: T segments join with spaces inside a block, B starts a new block
const out = [];
let cur = '';
for (const seg of lines) {
if (seg.startsWith('T:')) {
const t = seg.slice(2);
if (t) {
if (cur && !cur.endsWith(' ') && !t.startsWith(' '))
cur += ' ';
cur += t;
}
}
else {
out.push(cur);
cur = seg === 'B:hr' ? '\u0000hr' : '';
}
}
out.push(cur);
return out
.map(s => (s === '\u0000hr' ? '——————' : s.trim()))
.filter((s, i, a) => s !== '' || (i > 0 && i < a.length - 1))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** One-line snippet for the note list. */
function snippet(html, len = 110) {
const t = extractText(html).replace(/\n+/g, ' ').trim();
return t.length > len ? t.slice(0, len).trimEnd() + '…' : t;
}
/** Word count (title + body) for the status bar. */
function countWords(html, title) {
const t = (title + ' ' + extractText(html)).trim();
return t ? t.split(/\s+/).length : 0;
}
function fmtDate(ts) {
const d = new Date(ts);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const sameYear = d.getFullYear() === now.getFullYear();
const hm = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const md = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
if (sameDay)
return hm;
if (sameYear)
return md;
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// State — single source of truth, persistence, and actions.
// Every mutation flows through an action which mutates the
// model, bumps savedness, and persists (debounced).
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.actions = void 0;
exports.defaultState = defaultState;
exports.load = load;
exports.scheduleSave = scheduleSave;
exports.flushSave = flushSave;
exports.getNote = getNote;
exports.getNotebook = getNotebook;
exports.allTags = allTags;
exports.visibleNotes = visibleNotes;
exports.sortNotes = sortNotes;
exports.searchNotes = searchNotes;
const constants_1 = require("./constants");
const text_1 = require("./text");
// --- load / save --------------------------------------------
function defaultState() {
const t = Date.now();
const nb = [
{ id: 'nb-personal', name: 'Personal', createdAt: t },
{ id: 'nb-work', name: 'Work', createdAt: t },
];
const welcome = {
id: 'note-welcome',
title: 'Welcome to Notes',
content: '<p>A quiet place to think. This is your first note — everything you write is saved automatically on this device.</p>' +
'<h2>Editing</h2>' +
'<p>Select text and use the toolbar, or press <b>Ctrl/⌘</b> <i>B</i> for bold, <i>I</i> for italic, <i>U</i> for underline. Undo and redo work the way you expect.</p>' +
'<h2>Markdown shortcuts</h2>' +
'<ul>' +
'<li>Start a line with <code>#</code> <code>space</code> for a heading</li>' +
'<li>Start a line with <code>-</code> <code>space</code> for a list</li>' +
'<li>Start a line with <code>[ ]</code> <code>space</code> for a task list</li>' +
'</ul>' +
'<h2>Tasks</h2>' +
'<ul>' +
'<li class="qn-task" data-checked="1"><span class="qn-task-box"></span><span class="qn-task-label">Check off a task</span></li>' +
'<li class="qn-task" data-checked="0"><span class="qn-task-box"></span><span class="qn-task-label">Try toggling the box above</span></li>' +
'</ul>' +
'<blockquote>Deleted notes go to the Trash, where you can restore them.</blockquote>' +
'<p>Add tags from the field under the title. Drag a note in the list onto a notebook in the sidebar to move it.</p>',
notebookId: 'nb-personal',
tags: ['welcome'],
pinned: true,
createdAt: t - 86400000,
updatedAt: t - 3600000,
deleted: false,
};
const recipes = {
id: 'note-recipes',
title: 'Reading list',
content: '<p>Books and essays I want to sit with.</p>' +
'<ul>' +
'<li class="qn-task" data-checked="0"><span class="qn-task-box"></span><span class="qn-task-label">The Shallows — Nicholas Carr</span></li>' +
'<li class="qn-task" data-checked="0"><span class="qn-task-box"></span><span class="qn-task-label">Four Thousand Weeks</span></li>' +
'</ul>',
notebookId: 'nb-personal',
tags: ['books'],
pinned: false,
createdAt: t - 7200000,
updatedAt: t - 1800000,
deleted: false,
};
return {
notebooks: nb,
notes: [welcome, recipes],
view: { kind: 'notebook', id: 'nb-personal' },
selectedNoteId: 'note-welcome',
sortBy: 'updated',
collapsed: {},
theme: 'light',
layout: { sidebarCollapsed: false, sidebarW: constants_1.C.SIDEBAR_W, listW: constants_1.C.LIST_W },
dirty: false,
lastSaved: t,
};
}
function load() {
try {
const raw = localStorage.getItem(constants_1.C.STORAGE_KEY);
if (!raw)
return defaultState();
const p = JSON.parse(raw);
if (!p || p.v !== 1 || !Array.isArray(p.notes) || !Array.isArray(p.notebooks))
return defaultState();
// sanitize stored HTML once at boot
for (const n of p.notes)
n.content = (0, text_1.sanitizeHtml)(n.content || '');
return {
notebooks: p.notebooks,
notes: p.notes,
view: p.view && p.view.kind ? p.view : { kind: 'all', id: null },
selectedNoteId: p.selectedNoteId ?? null,
sortBy: ['updated', 'created', 'alpha'].includes(p.sortBy) ? p.sortBy : 'updated',
collapsed: p.collapsed || {},
theme: p.theme === 'dark' ? 'dark' : 'light',
layout: {
sidebarCollapsed: !!p.layout?.sidebarCollapsed,
sidebarW: p.layout?.sidebarW ?? constants_1.C.SIDEBAR_W,
listW: p.layout?.listW ?? constants_1.C.LIST_W,
},
dirty: false,
lastSaved: Date.now(),
};
}
catch {
return defaultState();
}
}
let saveTimer;
function persistNow(s) {
const p = {
v: 1,
notebooks: s.notebooks,
notes: s.notes,
view: s.view,
selectedNoteId: s.selectedNoteId,
sortBy: s.sortBy,
collapsed: s.collapsed,
theme: s.theme,
layout: s.layout,
};
try {
localStorage.setItem(constants_1.C.STORAGE_KEY, JSON.stringify(p));
s.dirty = false;
s.lastSaved = Date.now();
}
catch {
/* storage full — keep running in memory */
}
}
function scheduleSave(s) {
s.dirty = true;
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => persistNow(s), constants_1.C.AUTOSAVE_DEBOUNCE_MS);
}
function flushSave(s) {
clearTimeout(saveTimer);
if (s.dirty)
persistNow(s);
}
// --- lookups --------------------------------------------------
function getNote(s, id) {
return s.notes.find(n => n.id === id) || null;
}
function getNotebook(s, id) {
return s.notebooks.find(n => n.id === id) || null;
}
function allTags(s) {
const set = new Set();
for (const n of s.notes)
if (!n.deleted)
for (const t of n.tags)
set.add(t);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
function visibleNotes(s) {
let list = s.notes.filter(n => !n.deleted);
switch (s.view.kind) {
case 'notebook':
list = list.filter(n => n.notebookId === s.view.id);
break;
case 'trash':
list = s.notes.filter(n => n.deleted);
break;
case 'tag':
list = list.filter(n => n.tags.includes(s.view.id));
break;
}
return sortNotes(list, s.sortBy);
}
function sortNotes(list, sortBy) {
const arr = list.slice();
arr.sort((a, b) => {
if (a.pinned !== b.pinned)
return a.pinned ? -1 : 1;
switch (sortBy) {
case 'created': return b.createdAt - a.createdAt;
case 'alpha': return (a.title || 'Untitled').localeCompare(b.title || 'Untitled') || b.updatedAt - a.updatedAt;
default: return b.updatedAt - a.updatedAt;
}
});
return arr;
}
// --- actions --------------------------------------------------
function touch(n) { n.updatedAt = Date.now(); }
exports.actions = {
/** Create a note in a notebook (or unfiled), select it, return it. */
createNote(s, notebookId) {
const t = Date.now();
const n = {
id: (0, text_1.uid)(), title: '', content: '<p><br></p>',
notebookId, tags: [], pinned: false,
createdAt: t, updatedAt: t, deleted: false,
};
s.notes.unshift(n);
s.selectedNoteId = n.id;
if (notebookId && s.view.kind === 'notebook' && s.view.id !== notebookId) {
s.view = { kind: 'notebook', id: notebookId };
}
if (s.view.kind === 'trash')
s.view = { kind: 'all', id: null };
scheduleSave(s);
return n;
},
updateContent(s, noteId, html) {
const n = getNote(s, noteId);
if (!n || n.content === html)
return;
n.content = (0, text_1.sanitizeHtml)(html);
touch(n);
scheduleSave(s);
},
updateTitle(s, noteId, title) {
const n = getNote(s, noteId);
if (!n || n.title === title)
return;
n.title = title.slice(0, 200);
touch(n);
scheduleSave(s);
},
selectNote(s, noteId) {
s.selectedNoteId = noteId;
scheduleSave(s);
},
setView(s, view, selectFirst = true) {
s.view = view;
if (selectFirst) {
const list = visibleNotes(s);
s.selectedNoteId = list.length ? list[0].id : null;
}
scheduleSave(s);
},
setSort(s, sortBy) { s.sortBy = sortBy; scheduleSave(s); },
setTheme(s, theme) { s.theme = theme; scheduleSave(s); },
toggleCollapse(s, nbId) {
s.collapsed[nbId] = !s.collapsed[nbId];
scheduleSave(s);
},
setSidebarCollapsed(s, collapsed) {
s.layout.sidebarCollapsed = collapsed;
scheduleSave(s);
},
setLayout(s, sidebarW, listW) {
if (sidebarW !== undefined)
s.layout.sidebarW = Math.max(constants_1.C.SIDEBAR_MIN, Math.min(constants_1.C.SIDEBAR_MAX, sidebarW));
if (listW !== undefined)
s.layout.listW = Math.max(constants_1.C.LIST_MIN, Math.min(constants_1.C.LIST_MAX, listW));
scheduleSave(s);
},
pinNote(s, noteId) {
const n = getNote(s, noteId);
if (!n)
return;
n.pinned = !n.pinned;
touch(n);
scheduleSave(s);
},
duplicateNote(s, noteId) {
const n = getNote(s, noteId);
if (!n)
return null;
const t = Date.now();
const copy = {
...n, id: (0, text_1.uid)(),
title: n.title ? n.title + ' (copy)' : 'Untitled (copy)',
createdAt: t, updatedAt: t, pinned: false,
};
s.notes.unshift(copy);
s.selectedNoteId = copy.id;
scheduleSave(s);
return copy;
},
/** Soft-delete → Trash (remembers its notebook for restore). */
deleteNote(s, noteId) {
const n = getNote(s, noteId);
if (!n || n.deleted)
return;
n.deleted = true;
n.deletedAt = Date.now();
if (s.selectedNoteId === noteId)
s.selectedNoteId = null;
scheduleSave(s);
},
restoreNote(s, noteId) {
const n = getNote(s, noteId);
if (!n || !n.deleted)
return;
n.deleted = false;
delete n.deletedAt;
touch(n);
// jump back to its home notebook if we're not already in a sensible view
if (n.notebookId)
s.view = { kind: 'notebook', id: n.notebookId };
s.selectedNoteId = n.id;
scheduleSave(s);
},
/** Permanent delete — no coming back. */
destroyNote(s, noteId) {
const i = s.notes.findIndex(n => n.id === noteId);
if (i < 0)
return;
s.notes.splice(i, 1);
if (s.selectedNoteId === noteId)
s.selectedNoteId = null;
scheduleSave(s);
},
moveNote(s, noteId, toNotebookId) {
const n = getNote(s, noteId);
if (!n || n.notebookId === toNotebookId)
return;
n.notebookId = toNotebookId;
touch(n);
scheduleSave(s);
},
addNote(s, notebookId) {
exports.actions.createNote(s, notebookId);
},
createNotebook(s, name) {
const nb = { id: (0, text_1.uid)(), name: name.trim() || 'Untitled', createdAt: Date.now() };
s.notebooks.push(nb);
s.view = { kind: 'notebook', id: nb.id };
scheduleSave(s);
return nb;
},
renameNotebook(s, id, name) {
const nb = getNotebook(s, id);
if (!nb)
return;
nb.name = name.trim() || nb.name;
scheduleSave(s);
},
deleteNotebook(s, id) {
const nb = getNotebook(s, id);
if (!nb)
return;
s.notebooks = s.notebooks.filter(x => x.id !== id);
for (const n of s.notes)
if (n.notebookId === id)
n.notebookId = null;
delete s.collapsed[id];
if (s.view.kind === 'notebook' && s.view.id === id) {
s.view = { kind: 'all', id: null };
const list = visibleNotes(s);
s.selectedNoteId = list.length ? list[0].id : null;
}
scheduleSave(s);
},
setNoteTags(s, noteId, tags) {
const n = getNote(s, noteId);
if (!n)
return;
const cleaned = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
if (cleaned.join('\u0000') === n.tags.join('\u0000'))
return;
n.tags = cleaned.slice(0, 24);
touch(n);
scheduleSave(s);
},
};
/** Case-insensitive live search over titles + body text (not HTML tags). */
function searchNotes(s, query) {
const q = query.trim().toLowerCase();
if (!q)
return [];
const hits = [];
const live = s.notes.filter(n => !n.deleted);
const scored = live.map(n => {
const title = (n.title || '').toLowerCase();
const body = (0, text_1.extractText)(n.content).toLowerCase();
let ti = title.indexOf(q);
let bi = body.indexOf(q);
let score = -1;
if (ti >= 0)
score = 0;
else if (bi >= 0)
score = 1;
return { n, ti, bi, score };
}).filter(x => x.score >= 0).sort((a, b) => a.score - b.score || b.n.updatedAt - a.n.updatedAt);
for (const { n, ti, bi, score } of scored) {
if (score === 0) {
hits.push({ note: n, inTitle: true, context: n.title || 'Untitled' });
}
else {
const plain = (0, text_1.extractText)(n.content);
const idx = plain.toLowerCase().indexOf(q);
const start = Math.max(0, idx - constants_1.C.SEARCH_BEFORE);
const end = Math.min(plain.length, idx + q.length + constants_1.C.SEARCH_AFTER);
let ctx = plain.slice(start, end).replace(/\n+/g, ' ');
if (start > 0)
ctx = '…' + ctx;
if (end < plain.length)
ctx = ctx + '…';
hits.push({ note: n, inTitle: false, context: ctx });
}
}
return hits;
}
};
// ── module: src/editor.ts ──
__mods["src/editor.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Editor — contenteditable core: toolbar + state reflection,
// markdown input shortcuts, list behavior, task lists,
// sanitized paste, and a reliable snapshot undo/redo.
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEditor = createEditor;
const state_1 = require("./state");
const text_1 = require("./text");
const HISTORY_LIMIT = 120;
const BURST_MS = 400; // keystrokes within 400ms coalesce into one undo step
function createEditor(host, state, onChange) {
// ----------------------------------------------------------
// DOM scaffolding
// ----------------------------------------------------------
host.innerHTML = `
<div class="ed-scroll">
<div class="ed-doc">
<div class="ed-meta">
<div class="ed-title" contenteditable="true" spellcheck="false" data-ph="Untitled"></div>
<div class="ed-tags"></div>
</div>
<div class="ed-content" contenteditable="true" spellcheck="false"></div>
</div>
</div>`;
const titleEl = host.querySelector('.ed-title');
const tagsEl = host.querySelector('.ed-tags');
const editor = host.querySelector('.ed-content');
let note = null;
let history = []; // snapshots of {title\0html}; [0] = state at select
let redoStack = [];
let lastBurst = 0;
let suppressInput = false; // while we mutate the DOM programmatically
const serialize = () => (titleEl.textContent || '') + '\u0000' + editor.innerHTML;
const deserialize = (s) => {
const i = s.indexOf('\u0000');
const t = s.slice(0, i);
const h = s.slice(i + 1);
suppressInput = true;
titleEl.textContent = t;
syncTitleEmpty();
editor.innerHTML = h;
suppressInput = false;
};
// ----------------------------------------------------------
// Caret offset helpers (text-length based) — used to restore
// the caret after undo/redo.
// ----------------------------------------------------------
function caretOffset(el) {
const s = window.getSelection();
if (!s || !s.rangeCount)
return null;
const r = s.getRangeAt(0);
const sc = r.startContainer;
if (!el.contains(sc))
return null;
const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT);
let off = 0;
let t;
if (sc.nodeType === 3) {
while ((t = it.nextNode())) {
if (t === sc)
return off + r.startOffset;
off += t.length;
}
return null;
}
const kids = sc.childNodes;
const start = Math.min(r.startOffset, kids.length);
for (let i = 0; i < start; i++)
off += (kids[i].textContent || '').length;
return off;
}
function restoreCaretOffset(el, off) {
const sel = window.getSelection();
if (!sel)
return;
sel.removeAllRanges();
if (off == null) {
caretAtEnd(el);
return;
}
let remaining = off;
const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT);
let t;
while ((t = it.nextNode())) {
if (remaining <= t.length) {
const r = document.createRange();
r.setStart(t, remaining);
r.collapse(true);
sel.addRange(r);
return;
}
remaining -= t.length;
}
caretAtEnd(el);
}
function caretAtEnd(el) {
const sel = window.getSelection();
if (!sel)
return;
sel.removeAllRanges();
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
sel.addRange(r);
}
function caretAtStart(el) {
const sel = window.getSelection();
if (!sel)
return;
sel.removeAllRanges();
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(true);
sel.addRange(r);
}
// ----------------------------------------------------------
// History — history[] is [initial state, pre-edit snapshot of
// each burst/structural change…]; undo pops to the previous.
// Keystrokes within BURST_MS coalesce into a single step.
// ----------------------------------------------------------
function pushHistory(snap) {
history.push(snap);
if (history.length > HISTORY_LIMIT)
history.shift();
redoStack = [];
}
let pending = null;
let lastPost = ''; // state after the last committed change = pre-state of the next burst
function commitPending() {
if (pending != null) {
history.push(pending);
if (history.length > HISTORY_LIMIT)
history.shift();
redoStack = [];
}
pending = null;
}
/** Apply a structural edit: record pre-state, run fn. Structural
* edits are their own undo boundary — typing afterwards starts
* a fresh burst. */
function edit(fn) {
const pre = serialize();
fn();
pushHistory(pre);
pending = null;
lastBurst = Date.now();
lastPost = serialize();
}
function doUndo() {
commitPending();
if (history.length <= 1)
return;
redoStack.push(serialize());
const snap = history.pop();
const off = caretOffset(editor);
suppressInput = true;
deserialize(snap);
suppressInput = false;
lastPost = serialize();
restoreCaretOffset(editor, off);
emitChange();
refreshToolbar();
}
function doRedo() {
commitPending();
if (!redoStack.length)
return;
history.push(serialize());
const snap = redoStack.pop();
const off = caretOffset(editor);
suppressInput = true;
deserialize(snap);
suppressInput = false;
lastPost = serialize();
restoreCaretOffset(editor, off);
emitChange();
refreshToolbar();
}
function emitChange() {
if (!note || suppressInput)
return;
state_1.actions.updateContent(state, note.id, editor.innerHTML);
onChange();
}
// ----------------------------------------------------------
// Selection / block helpers
// ----------------------------------------------------------
function getSel() {
const s = window.getSelection();
return s && s.anchorNode && (editor.contains(s.anchorNode) || titleEl.contains(s.anchorNode)) ? s : null;
}
function liOf(node) {
if (!node)
return null;
const el = node.nodeType === 1 ? node : node.parentElement;
if (!el)
return null;
const li = el.closest('li');
return li && editor.contains(li) ? li : null;
}
function blockOf(node) {
if (!node)
return null;
const el = node.nodeType === 1 ? node : node.parentElement;
if (!el)
return null;
const b = el.closest('li,blockquote,h1,h2,h3,p,div');
return (b && editor.contains(b)) ? b : null;
}
function ensureCaretVisible(el) {
if (!el.textContent && !el.querySelector('br')) {
el.appendChild(document.createElement('br'));
}
}
function makeTaskLi(checked, labelHtml) {
const li = document.createElement('li');
li.className = 'qn-task';
li.dataset.checked = checked ? '1' : '0';
const box = document.createElement('span');
box.className = 'qn-task-box';
box.setAttribute('contenteditable', 'false');
const label = document.createElement('span');
label.className = 'qn-task-label';
label.innerHTML = labelHtml || '<br>';
li.appendChild(box);
li.appendChild(label);
return li;
}
function stripLeadingText(block, count) {
let remaining = count;
const it = document.createNodeIterator(block, NodeFilter.SHOW_TEXT);
let t;
while ((t = it.nextNode()) && remaining > 0) {
if (remaining >= t.length) {
t.remove();
remaining -= t.length;
}
else {
t.data = (t.textContent || '').slice(remaining);
remaining = 0;
}
}
}
/** Replace a block with a freshly-built element, handling li→list unwrap. */
function replaceBlockWith(block, newEl) {
const parent = block.parentElement;
if (block.tagName === 'LI' && parent && (parent.tagName === 'UL' || parent.tagName === 'OL')) {
if (parent.children.length === 1) {
parent.replaceWith(newEl);
}
else {
parent.after(newEl);
block.remove();
if (!parent.children.length)
parent.remove();
}
}
else {
block.replaceWith(newEl);
}
}
// ----------------------------------------------------------
// Markdown input shortcuts — only at line start.
// Checked on keydown before the space is inserted: the text
// "so far on this line" must be exactly one of the patterns.
// ----------------------------------------------------------
const MD_PATTERNS = ['#', '-', '*', '->', '>', '[ ]', '[]', '1.'];
function markdownKeydown(e) {
const sel = window.getSelection();
if (!sel || !sel.isCollapsed)
return false;
const block = blockOf(sel.anchorNode);
if (!block || block === editor)
return false; // only inside real blocks
const r0 = sel.getRangeAt(0);
const upto = document.createRange();
upto.selectNodeContents(block);
upto.setEnd(r0.startContainer, r0.startOffset);
const prefix = upto.toString().split('\n').pop() || '';
const pat = MD_PATTERNS.find(p => prefix === p);
if (!pat)
return false;
e.preventDefault();
const kind = pat === '#' ? 'h1'
: (pat === '-' || pat === '*' || pat === '->') ? 'ul'
: pat === '>' ? 'quote'
: (pat === '[]' || pat === '[ ]') ? 'task'
: 'ol';
const after = document.createRange();
after.selectNodeContents(block);
after.setStart(r0.startContainer, r0.startOffset);
const suffixHtml = after.cloneContents();
edit(() => {
stripLeadingText(block, pat.length);
let newEl;
let caretEl;
if (kind === 'h1') {
newEl = document.createElement('h1');
newEl.appendChild(suffixHtml);
caretEl = newEl;
}
else if (kind === 'quote') {
newEl = document.createElement('blockquote');
const p = document.createElement('p');
p.appendChild(suffixHtml);
newEl.appendChild(p);
caretEl = p;
}
else if (kind === 'task') {
newEl = document.createElement('ul');
const li = makeTaskLi(false, '');
const label = li.querySelector('.qn-task-label');
label.innerHTML = '';
label.appendChild(suffixHtml);
ensureCaretVisible(label);
newEl.appendChild(li);
caretEl = label;
}
else if (kind === 'ol') {
newEl = document.createElement('ol');
const li = document.createElement('li');
li.appendChild(suffixHtml);
ensureCaretVisible(li);
newEl.appendChild(li);
caretEl = li;
}
else {
newEl = document.createElement('ul');
const li = document.createElement('li');
li.appendChild(suffixHtml);
ensureCaretVisible(li);
newEl.appendChild(li);
caretEl = li;
}
replaceBlockWith(block, newEl);
editor.focus();
caretAtStart(caretEl);
ensureCaretVisible(caretEl);
});
emitChange();
refreshToolbar();
return true;
}
// ----------------------------------------------------------
// List behaviors: Enter continuation, exit, Escape
// ----------------------------------------------------------
function isLiEmpty(li) {
return li.textContent.trim() === '' && !li.querySelector('blockquote,hr,ul,ol');
}
function enterInList(e) {
const sel = window.getSelection();
if (!sel || !sel.isCollapsed)
return false;
const li = liOf(sel.anchorNode);
if (!li)
return false;
e.preventDefault();
if (isLiEmpty(li)) {
edit(() => {
const p = document.createElement('p');
p.innerHTML = '<br>';
const ul = li.parentElement;
if (ul && (ul.tagName === 'UL' || ul.tagName === 'OL')) {
if (ul.children.length === 1)
ul.replaceWith(p);
else {
ul.after(p);
li.remove();
if (!ul.children.length)
ul.remove();
}
}
else {
li.replaceWith(p);
}
editor.focus();
caretAtStart(p);
});
emitChange();
refreshToolbar();
return true;
}
edit(() => {
const r = sel.getRangeAt(0);
const after = document.createRange();
after.selectNodeContents(li);
after.setStart(r.startContainer, r.startOffset);
const frag = after.cloneContents();
after.deleteContents();
const newLi = document.createElement('li');
if (li.classList.contains('qn-task')) {
newLi.className = 'qn-task';
newLi.dataset.checked = '0';
const box = document.createElement('span');
box.className = 'qn-task-box';
box.setAttribute('contenteditable', 'false');
const label = document.createElement('span');
label.className = 'qn-task-label';
newLi.appendChild(box);
newLi.appendChild(label);
label.appendChild(frag);
ensureCaretVisible(label);
}
else {
newLi.appendChild(frag);
ensureCaretVisible(newLi);
}
li.after(newLi);
editor.focus();
caretAtStart(newLi);
});
emitChange();
refreshToolbar();
return true;
}
function escapeInList(e) {
const sel = window.getSelection();
if (!sel)
return false;
const li = liOf(sel.anchorNode);
if (!li || !isLiEmpty(li))
return false;
e.preventDefault();
edit(() => {
const ul = li.parentElement;
if (ul && (ul.tagName === 'UL' || ul.tagName === 'OL')) {
li.remove();
if (!ul.children.length)
ul.remove();
}
else {
li.remove();
}
editor.focus();
});
emitChange();
refreshToolbar();
return true;
}
// ----------------------------------------------------------
// Toolbar actions
// ----------------------------------------------------------
function withSel(fn) {
editor.focus();
edit(fn);
emitChange();
refreshToolbar();
}
function toggleBlock(kind) {
const sel = getSel();
if (!sel)
return;
const block = blockOf(sel.anchorNode);
const parent = block ? block.parentElement : null;
const inUl = !!(block && block.tagName === 'LI' && parent && parent.tagName === 'UL');
const inOl = !!(block && block.tagName === 'LI' && parent && parent.tagName === 'OL');
const inTask = inUl && !!block.classList.contains('qn-task');
const inQuote = !!block && block.tagName === 'BLOCKQUOTE';
const fmt = (tag) => withSel(() => document.execCommand('formatBlock', false, tag));
if (kind === 'h1') {
if (block && block.tagName === 'H1')
fmt('p');
else
fmt('h1');
return;
}
if (kind === 'h2') {
if (block && block.tagName === 'H2')
fmt('p');
else
fmt('h2');
return;
}
if (kind === 'h3') {
if (block && block.tagName === 'H3')
fmt('p');
else
fmt('h3');
return;
}
if (kind === 'quote') {
if (inQuote)
fmt('p');
else
fmt('blockquote');
return;
}
if (kind === 'task') {
if (inTask) {
withSel(() => {
const ul = parent;
for (const item of Array.from(ul.querySelectorAll(':scope > li'))) {
const liE = item;
const label = liE.querySelector('.qn-task-label');
if (label) {
const frag = document.createDocumentFragment();
while (label.firstChild)
frag.appendChild(label.firstChild);
liE.innerHTML = '';
liE.appendChild(frag);
liE.classList.remove('qn-task');
delete liE.dataset.checked;
}
}
});
return;
}
withSel(() => {
if (inUl) {
const ul = parent;
for (const item of Array.from(ul.querySelectorAll(':scope > li'))) {
const liE = item;
if (!liE.classList.contains('qn-task')) {
liE.replaceWith(makeTaskLi(false, liE.innerHTML));
}
}
}
else if (block && block !== editor) {
const content = block.innerHTML;
const newUl = document.createElement('ul');
const liE = makeTaskLi(false, content);
newUl.appendChild(liE);
replaceBlockWith(block, newUl);
editor.focus();
caretAtStart(liE.querySelector('.qn-task-label'));
}
else {
const newUl = document.createElement('ul');
const liE = makeTaskLi(false, '');
newUl.appendChild(liE);
editor.appendChild(newUl);
editor.focus();
caretAtStart(liE.querySelector('.qn-task-label'));
}
});
return;
}
if (kind === 'ul' && inUl && !inTask) {
withSel(unlist);
return;
}
if (kind === 'ol' && inOl) {
withSel(unlist);
return;
}
withSel(() => document.execCommand(kind === 'ul' ? 'insertUnorderedList' : 'insertOrderedList', false));
}
function unlist() {
const sel = window.getSelection();
if (!sel)
return;
const li = liOf(sel.anchorNode);
if (!li)
return;
const ul = li.parentElement;
if (!ul || (ul.tagName !== 'UL' && ul.tagName !== 'OL'))
return;
const items = Array.from(ul.children);
const ps = items.map(item => {
const p = document.createElement('p');
const frag = document.createDocumentFragment();
while (item.firstChild)
frag.appendChild(item.firstChild);
p.appendChild(frag);
ensureCaretVisible(p);
return p;
});
ul.replaceWith(...ps);
editor.focus();
caretAtStart(ps[0] || editor);
}
function toggleInlineCode() {
const sel = getSel();
if (!sel)
return;
withSel(() => {
const s = window.getSelection();
if (!s || !s.rangeCount)
return;
const anchor = s.anchorNode;
const codeEl = anchor
? (anchor.nodeType === 1 ? anchor.closest('code') : anchor.parentElement?.closest('code'))
: null;
if (codeEl) {
const code = codeEl;
const frag = document.createDocumentFragment();
while (code.firstChild)
frag.appendChild(code.firstChild);
code.replaceWith(frag);
}
else if (s.toString().length > 0) {
const r = s.getRangeAt(0);
const code = document.createElement('code');
try {
r.surroundContents(code);
}
catch {
const text = s.toString();
r.deleteContents();
code.textContent = text;
r.insertNode(code);
}
}
});
}
function insertHr() {
withSel(() => {
const sel = window.getSelection();
const block = sel ? blockOf(sel.anchorNode) : null;
const hr = document.createElement('hr');
const p = document.createElement('p');
p.innerHTML = '<br>';
if (block && block !== editor) {
block.after(hr);
hr.after(p);
}
else {
editor.appendChild(hr);
editor.appendChild(p);
}
editor.focus();
caretAtStart(p);
});
}
// ----------------------------------------------------------
// Paste — sanitize everything that arrives, then insert at
// block level (splitting the anchor block when needed).
// ----------------------------------------------------------
function onPaste(e) {
e.preventDefault();
const cd = e.clipboardData;
if (!cd)
return;
const sel = window.getSelection();
if (!sel || !editor.contains(sel.anchorNode)) {
editor.focus();
return;
}
const html = cd.getData('text/html');
const text = cd.getData('text/plain');
let insertHtml = '';
if (html && /<[a-z][\s\S]*>/i.test(html)) {
const cleaned = (0, text_1.sanitizeHtml)(html);
if (cleaned.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '').trim())
insertHtml = cleaned;
}
const s = window.getSelection();
const range = s && s.rangeCount ? s.getRangeAt(0) : null;
if (!range) {
editor.focus();
return;
}
edit(() => {
if (insertHtml) {
const tmp = document.createElement('div');
tmp.innerHTML = insertHtml;
const lastChild = tmp.lastElementChild;
if (lastChild && /^(P|DIV)$/.test(lastChild.tagName) && !lastChild.textContent.trim())
lastChild.remove();
const kids = Array.from(tmp.children);
const hasBlocks = kids.some(k => /^(P|DIV|UL|OL|H1|H2|H3|BLOCKQUOTE|HR)$/.test(k.tagName));
if (!hasBlocks) {
// plain inline content → insert at the caret
const frag = document.createDocumentFragment();
while (tmp.firstChild)
frag.appendChild(tmp.firstChild);
range.deleteContents();
range.insertNode(frag);
editor.focus();
const blk = blockOf(range.startContainer);
caretAtEnd(blk && blk !== editor ? blk : editor);
return;
}
// block content → insert at block level
const anchor = s ? s.anchorNode : null;
const B = anchor ? blockOf(anchor) : null;
let target = null;
if (B && B !== editor && /^(P|DIV)$/.test(B.tagName)) {
const before = document.createRange();
before.selectNodeContents(B);
before.setEnd(range.startContainer, range.startOffset);
const after = document.createRange();
after.selectNodeContents(B);
after.setStart(range.endContainer, range.endOffset);
const sequence = [];
if (before.toString().trim()) {
const pb = document.createElement('p');
pb.appendChild(before.cloneContents());
sequence.push(pb);
}
sequence.push(...kids);
const ph = document.createElement('p');
ph.innerHTML = '<br>';
sequence.push(ph);
if (after.toString().trim()) {
const pa = document.createElement('p');
pa.appendChild(after.cloneContents());
sequence.push(pa);
}
B.replaceWith(...sequence);
target = kids[kids.length - 1] || ph;
}
else if (B && B !== editor) {
B.after(...kids);
target = kids[kids.length - 1];
}
else {
const off = caretOffset(editor) || 0;
if (off === 0 && editor.firstElementChild) {
editor.firstElementChild.before(...kids);
}
else {
editor.append(...kids);
}
target = kids[kids.length - 1];
}
editor.focus();
const r2 = document.createRange();
r2.setStartAfter(target);
r2.collapse(true);
const sel2 = window.getSelection();
if (sel2) {
sel2.removeAllRanges();
sel2.addRange(r2);
}
}
else {
const t = (text || '').replace(/\r\n/g, '\n');
document.execCommand('insertText', false, t);
}
});
emitChange();
refreshToolbar();
}
// ----------------------------------------------------------
// Toolbar state reflection
// ----------------------------------------------------------
const toolbar = host.closest('.pane-editor')?.querySelector('.ed-toolbar');
function refreshToolbar() {
if (!toolbar)
return;
const sel = getSel();
const inEditor = !!sel && !!sel.anchorNode && editor.contains(sel.anchorNode);
toolbar.classList.toggle('disabled', !inEditor);
const block = sel && sel.anchorNode ? blockOf(sel.anchorNode) : null;
const anchorEl = sel && sel.anchorNode
? (sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement)
: null;
const inTag = (t) => !!(anchorEl && editor.contains(anchorEl) && anchorEl.closest(t));
const set = (id, on) => {
const b = toolbar.querySelector(`[data-cmd="${id}"]`);
if (b)
b.classList.toggle('active', on);
};
set('bold', inEditor && inTag('b,strong'));
set('italic', inEditor && inTag('i,em'));
set('underline', inEditor && inTag('u'));
set('strike', inEditor && inTag('s,del'));
set('code', inEditor && inTag('code'));
set('h1', !!block && block.tagName === 'H1');
set('h2', !!block && block.tagName === 'H2');
set('h3', !!block && block.tagName === 'H3');
const inUl = !!(block && block.tagName === 'LI' && block.parentElement && block.parentElement.tagName === 'UL');
const inOl = !!(block && block.tagName === 'LI' && block.parentElement && block.parentElement.tagName === 'OL');
const inTask = inUl && block.classList.contains('qn-task');
set('ul', inUl && !inTask);
set('ol', inOl);
set('task', inTask);
set('quote', !!block && block.tagName === 'BLOCKQUOTE');
}
// ----------------------------------------------------------
// Tags row
// ----------------------------------------------------------
function renderTags() {
if (!note) {
tagsEl.innerHTML = '';
return;
}
tagsEl.innerHTML = '';
for (const t of note.tags) {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `<span class="tag-name">#${(0, text_1.escapeHtml)(t)}</span><button class="tag-x" title="Remove tag">×</button>`;
chip.querySelector('.tag-x').addEventListener('click', ev => {
ev.stopPropagation();
if (!note)
return;
state_1.actions.setNoteTags(state, note.id, note.tags.filter(x => x !== t));
renderTags();
onChange();
});
// clicking the chip (not ×) filters the note list by this tag
chip.addEventListener('click', () => {
state_1.actions.setView(state, { kind: 'tag', id: t });
onChange();
});
tagsEl.appendChild(chip);
}
const input = document.createElement('input');
input.className = 'tag-input';
input.placeholder = 'add tag…';
input.spellcheck = false;
input.addEventListener('keydown', e => {
if (!note)
return;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const v = input.value.trim().replace(/^#/, '');
if (v && !note.tags.includes(v)) {
state_1.actions.setNoteTags(state, note.id, [...note.tags, v]);
renderTags();
onChange();
}
input.value = '';
}
else if (e.key === 'Backspace' && input.value === '' && note.tags.length) {
state_1.actions.setNoteTags(state, note.id, note.tags.slice(0, -1));
renderTags();
onChange();
}
});
input.addEventListener('blur', () => {
if (!note)
return;
const v = input.value.trim().replace(/^#/, '');
if (v && !note.tags.includes(v)) {
state_1.actions.setNoteTags(state, note.id, [...note.tags, v]);
onChange();
}
renderTags();
});
tagsEl.appendChild(input);
}
function syncTitleEmpty() {
titleEl.classList.toggle('qn-empty-title', titleEl.textContent.trim() === '');
}
// ----------------------------------------------------------
// In-place sanitizer: strip anything the input events let in.
// ----------------------------------------------------------
function sanitizeInPlace() {
for (const el of Array.from(editor.querySelectorAll('*'))) {
const tag = el.tagName.toLowerCase();
if (!/^(p|div|br|b|strong|i|em|u|s|del|code|h1|h2|h3|ul|ol|li|blockquote|hr|span)$/.test(tag)) {
const frag = document.createDocumentFragment();
while (el.firstChild)
frag.appendChild(el.firstChild);
el.replaceWith(frag);
}
else {
for (const attr of Array.from(el.attributes)) {
const n = attr.name.toLowerCase();
const ok = (n === 'class' && /^(qn-[\w-]+|qn-task( checked)?|qn-title( empty)?)$/.test(attr.value)) ||
n === 'data-checked' ||
(n === 'data-tag' && !attr.value.includes('"') && !attr.value.includes(' ') && attr.value.length <= 60);
if (!ok)
el.removeAttribute(attr.name);
}
}
}
}
// ----------------------------------------------------------
// Wire events
// ----------------------------------------------------------
function onEditorInput() {
if (suppressInput)
return;
// first input of a new burst: its pre-state is lastPost (the post-state
// of the previous committed change)
const now = Date.now();
if (now - lastBurst > BURST_MS) {
commitPending();
pending = lastPost || serialize();
lastBurst = now;
}
else if (pending == null) {
pending = lastPost || serialize(); // burst continuing after a structural edit
lastBurst = now;
}
sanitizeInPlace();
emitChange();
lastPost = serialize();
refreshToolbar();
}
function onTitleInput() {
if (suppressInput)
return;
const now = Date.now();
if (now - lastBurst > BURST_MS) {
commitPending();
pending = lastPost || serialize();
lastBurst = now;
}
else if (pending == null) {
pending = lastPost || serialize();
lastBurst = now;
}
if (!note)
return;
state_1.actions.updateTitle(state, note.id, titleEl.textContent || '');
syncTitleEmpty();
lastPost = serialize();
onChange();
}
function onKeyDown(e) {
if (titleEl.contains(e.target)) {
if (e.key === 'Enter') {
e.preventDefault();
editor.focus();
caretAtEnd(editor);
}
return;
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
e.preventDefault();
if (e.shiftKey)
doRedo();
else
doUndo();
return;
}
if (e.key === 'Escape') {
if (escapeInList(e))
return;
e.target.blur();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
if (enterInList(e))
return;
return;
}
if (e.key === ' ' && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (markdownKeydown(e))
return;
return;
}
}
function onTaskClick(e) {
const box = e.target.closest?.('.qn-task-box');
if (!box)
return;
e.preventDefault();
const li = box.closest('li');
if (!li)
return;
edit(() => {
li.dataset.checked = li.dataset.checked === '1' ? '0' : '1';
});
emitChange();
refreshToolbar();
}
function onSelectionChange() {
refreshToolbar();
}
editor.addEventListener('input', onEditorInput);
titleEl.addEventListener('input', onTitleInput);
editor.addEventListener('keydown', onKeyDown);
editor.addEventListener('paste', onPaste);
editor.addEventListener('click', onTaskClick);
document.addEventListener('selectionchange', onSelectionChange);
// ----------------------------------------------------------
// Public API
// ----------------------------------------------------------
function selectNote(n) {
note = n;
history = [];
redoStack = [];
pending = null;
lastBurst = 0;
suppressInput = true;
if (!n) {
titleEl.textContent = '';
editor.innerHTML = '<p><br></p>';
}
else {
titleEl.textContent = n.title;
editor.innerHTML = n.content || '<p><br></p>';
}
suppressInput = false;
syncTitleEmpty();
renderTags();
refreshToolbar();
history.push(serialize());
lastPost = serialize();
}
function syncNote(n) {
if (n && note && n.id === note.id) {
note = n;
renderTags(); // tag chips may have changed in the model
}
else {
selectNote(n);
}
}
function command(cmd) {
switch (cmd) {
case 'bold':
withSel(() => document.execCommand('bold'));
break;
case 'italic':
withSel(() => document.execCommand('italic'));
break;
case 'underline':
withSel(() => document.execCommand('underline'));
break;
case 'strike':
withSel(() => document.execCommand('strikeThrough'));
break;
case 'code':
toggleInlineCode();
break;
case 'h1':
case 'h2':
case 'h3':
case 'ul':
case 'ol':
case 'task':
case 'quote':
toggleBlock(cmd);
break;
case 'hr':
insertHr();
break;
}
}
return {
root: host,
selectNote,
refreshToolbar,
focusBody: () => { editor.focus(); caretAtEnd(editor); },
getHtml: () => editor.innerHTML,
command,
syncNote,
dispose: () => {
document.removeEventListener('selectionchange', onSelectionChange);
host.innerHTML = '';
},
};
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// UI — the three panes, search overlay, context menus,
// drag-and-drop, resize handles, and the "Saved" indicator.
// The DOM is a projection of State; render() reconciles it.
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.initUi = initUi;
exports.updateSavedEl = updateSavedEl;
exports.applyTheme = applyTheme;
const constants_1 = require("./constants");
const state_1 = require("./state");
const text_1 = require("./text");
let els = {};
let state;
let onChangeCb;
let noteRefs = new Map();
function initUi(s, refs, onChange) {
state = s;
onChangeCb = onChange;
els = {
app: document.getElementById('app'),
sidebar: refs.sidebar,
list: refs.list,
listHead: document.getElementById('list-head'),
listBody: document.getElementById('list-body'),
listFoot: document.getElementById('list-foot'),
editorHost: refs.editorHost,
overlay: refs.overlay,
saved: refs.saved,
};
buildStaticDom();
wireGlobalEvents();
return { render: () => renderAll(), openSearch };
}
// ------------------------------------------------------------------
// Static scaffolding built once (search overlay, side nav groups)
// ------------------------------------------------------------------
function buildStaticDom() {
els.sidebar.innerHTML = `
<div class="side-group">
<div class="side-label">Notebooks</div>
<div class="side-rows">
<div class="side-row view-row" data-view="all">
<svg class="ic" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="row-name">All Notes</span>
<span class="row-count"></span>
</div>
<div class="side-row view-row" data-view="trash">
<svg class="ic" viewBox="0 0 24 24"><path d="M5 7h14M9 7V5h6v2M7 7l1 12h8l1-12"/></svg>
<span class="row-name">Trash</span>
<span class="row-count"></span>
</div>
</div>
</div>
<div class="side-group grow">
<div class="side-label">
<span class="lbl-text">Notebooks</span>
<button class="icon-btn tiny" id="nb-add" title="New notebook">+</button>
</div>
<div class="side-rows" id="side-nbs"></div>
</div>
<div class="side-group">
<div class="side-label">Tags</div>
<div class="side-rows" id="side-tags-rows"></div>
</div>
<div class="side-foot" id="side-foot">
<button class="icon-btn tiny" id="theme-btn" title="Toggle theme">◐</button>
<span class="side-foot-note">Notes stay on this device</span>
</div>`;
// search overlay
els.overlay.innerHTML = `
<div class="search-panel">
<div class="search-input-row">
<svg class="ic" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="M15 15l6 6"/></svg>
<input class="search-input" id="search-input" placeholder="Search notes…" spellcheck="false" />
<kbd>esc</kbd>
</div>
<div class="search-results" id="search-results"></div>
</div>`;
const input = els.overlay.querySelector('#search-input');
input.addEventListener('input', () => renderSearch(input.value));
input.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeSearch();
}
else if (e.key === 'Enter') {
const hits = input.__hits;
if (hits && hits.length) {
state_1.actions.selectNote(state, hits[0].note.id);
if (state.view.kind === 'trash')
state.view = { kind: 'all', id: null };
closeSearch();
renderAll();
}
}
});
els.overlay.addEventListener('mousedown', e => {
if (e.target === els.overlay)
closeSearch();
});
// virtual views: All Notes + Trash
const allRow = els.sidebar.querySelector('[data-view="all"]');
const trashRowEl = els.sidebar.querySelector('[data-view="trash"]');
allRow.addEventListener('click', () => {
state_1.actions.setView(state, { kind: 'all', id: null });
renderAll();
});
trashRowEl.addEventListener('click', () => {
state_1.actions.setView(state, { kind: 'trash', id: null });
renderAll();
});
// dropping a note on "All Notes" unfles it from its notebook
allRow.addEventListener('dragover', e => {
e.preventDefault();
allRow.classList.add('drop-hint');
});
allRow.addEventListener('dragleave', () => allRow.classList.remove('drop-hint'));
allRow.addEventListener('drop', e => {
e.preventDefault();
allRow.classList.remove('drop-hint');
const id = e.dataTransfer.getData('text/qn-note');
if (id) {
state_1.actions.moveNote(state, id, null);
renderAll();
}
});
}
function closeSearch() {
els.overlay.classList.remove('open');
els.overlay.querySelector('#search-input').value = '';
}
// ------------------------------------------------------------------
// Saved indicator
// ------------------------------------------------------------------
function updateSavedEl(s) {
const el = els.saved;
if (!el)
return;
if (s.dirty) {
el.textContent = 'Saving…';
el.classList.add('show');
}
else {
el.textContent = 'Saved ' + (0, text_1.fmtDate)(s.lastSaved);
el.classList.add('show');
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 500);
}
}
// ------------------------------------------------------------------
// Sidebar rendering
// ------------------------------------------------------------------
function viewActive(kind, id) {
return state.view.kind === kind && state.view.id === id;
}
function rowEl(active, title) {
const row = document.createElement('div');
row.className = 'side-row' + (active ? ' active' : '');
row.title = title;
return row;
}
function renderSidebar() {
const appAll = els.sidebar.querySelector('[data-view="all"]');
const trashRow = els.sidebar.querySelector('[data-view="trash"]');
appAll.classList.toggle('active', viewActive('all', null));
trashRow.classList.toggle('active', viewActive('trash', null));
appAll.querySelector('.row-count').textContent =
String(state.notes.filter(n => !n.deleted).length);
trashRow.querySelector('.row-count').textContent =
String(state.notes.filter(n => n.deleted).length);
// notebooks
const nbHost = els.sidebar.querySelector('#side-nbs');
nbHost.innerHTML = '';
for (const nb of state.notebooks) {
const collapsed = !!state.collapsed[nb.id];
const count = state.notes.filter(n => !n.deleted && n.notebookId === nb.id).length;
const active = viewActive('notebook', nb.id);
const row = rowEl(active, nb.name);
row.innerHTML = `
<button class="icon-btn tiny chev" title="${collapsed ? 'Expand' : 'Collapse'}">${collapsed ? '›' : '⌄'}</button>
<svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 4v16"/></svg>
<span class="row-name">${(0, text_1.escapeHtml)(nb.name)}</span>
<span class="row-count">${count}</span>
<button class="icon-btn tiny nb-menu" title="Notebook options">⋯</button>`;
row.addEventListener('click', e => {
const t = e.target;
if (t.closest('.chev')) {
state_1.actions.toggleCollapse(state, nb.id);
renderAll();
return;
}
if (t.closest('.nb-menu')) {
openMenu(e, [
{ label: 'Rename…', fn: () => renameNotebookPrompt(nb) },
{ label: 'Delete', danger: true, fn: () => deleteNotebookPrompt(nb) },
]);
return;
}
state_1.actions.setView(state, { kind: 'notebook', id: nb.id });
renderAll();
});
row.addEventListener('dragover', e => {
if (e.dataTransfer)
e.dataTransfer.dropEffect = 'move';
row.classList.add('drop-hint');
});
row.addEventListener('dragleave', () => row.classList.remove('drop-hint'));
row.addEventListener('drop', e => {
row.classList.remove('drop-hint');
const id = e.dataTransfer.getData('text/qn-note');
if (id) {
state_1.actions.moveNote(state, id, nb.id);
renderAll();
}
});
nbHost.appendChild(row);
if (!collapsed) {
const notes = visibleNotesOfNotebook(nb.id);
const wrap = document.createElement('div');
wrap.className = 'nb-notes';
if (notes.length === 0) {
wrap.innerHTML = `<div class="nb-empty">No notes — drag one here or add below.</div>`;
}
else {
for (const n of notes) {
const i = document.createElement('div');
i.className = 'nb-note' + (state.selectedNoteId === n.id ? ' sel' : '');
i.textContent = n.title || 'Untitled';
i.addEventListener('click', () => { state_1.actions.selectNote(state, n.id); renderAll(); });
wrap.appendChild(i);
}
}
const add = document.createElement('button');
add.className = 'nb-add-note';
add.innerHTML = `+ New note`;
add.addEventListener('click', () => { state_1.actions.createNote(state, nb.id); renderAll(); });
wrap.appendChild(add);
nbHost.appendChild(wrap);
}
}
// tags
const tagHost = els.sidebar.querySelector('#side-tags-rows');
tagHost.innerHTML = '';
const tags = (0, state_1.allTags)(state);
if (!tags.length) {
tagHost.innerHTML = `<div class="nb-empty">Tags you add appear here.</div>`;
}
for (const t of tags) {
const count = state.notes.filter(n => !n.deleted && n.tags.includes(t)).length;
const row = rowEl(viewActive('tag', t), '#' + t);
row.innerHTML = `
<span class="tag-dot"></span>
<span class="row-name">${(0, text_1.escapeHtml)(t)}</span>
<span class="row-count">${count}</span>`;
row.addEventListener('click', () => {
state_1.actions.setView(state, { kind: 'tag', id: t });
renderAll();
});
tagHost.appendChild(row);
}
// collapse class
els.app.classList.toggle('side-collapsed', state.layout.sidebarCollapsed);
}
function visibleNotesOfNotebook(nbId) {
return state.notes
.filter(n => !n.deleted && n.notebookId === nbId)
.sort((a, b) => b.updatedAt - a.updatedAt);
}
// ------------------------------------------------------------------
// Note list pane
// ------------------------------------------------------------------
function viewTitle() {
switch (state.view.kind) {
case 'all': return 'All Notes';
case 'trash': return 'Trash';
case 'tag': return '#' + state.view.id;
default: return (0, state_1.getNotebook)(state, state.view.id)?.name || 'Notes';
}
}
function renderList() {
els.listHead.innerHTML = `
<div class="lh-row1">
<span class="lh-title">${(0, text_1.escapeHtml)(viewTitle())}</span>
<div class="lh-actions">
<button class="icon-btn tiny" id="sort-btn" title="Sort order">⇅</button>
<button class="icon-btn tiny" id="list-add" title="New note">+</button>
</div>
</div>
<div class="lh-row2">
<span class="lh-count">${(0, state_1.visibleNotes)(state).length} note${(0, state_1.visibleNotes)(state).length === 1 ? '' : 's'}</span>
</div>`;
els.listHead.querySelector('#sort-btn').addEventListener('click', e => {
const order = ['updated', 'created', 'alpha'];
const labels = { updated: 'Last modified', created: 'Date created', alpha: 'Alphabetical' };
openMenu(e, order.map(o => ({
label: labels[o],
active: state.sortBy === o,
fn: () => { state_1.actions.setSort(state, o); renderAll(); },
})));
});
els.listHead.querySelector('#list-add').addEventListener('click', () => {
const nb = state.view.kind === 'notebook' ? state.view.id : state.view.kind === 'tag' ? null : (state.view.kind === 'all' ? null : null);
state_1.actions.createNote(state, nb);
renderAll();
});
const notes = (0, state_1.visibleNotes)(state);
els.listBody.innerHTML = '';
noteRefs.clear();
for (const n of notes) {
const card = document.createElement('div');
card.className = 'note-card' + (state.selectedNoteId === n.id ? ' sel' : '') + (n.pinned ? ' pinned' : '');
card.draggable = true;
const title = document.createElement('div');
title.className = 'nc-title';
title.innerHTML = (n.pinned ? '<span class="pin-dot" title="Pinned"></span>' : '') +
(0, text_1.escapeHtml)(n.title || 'Untitled');
const snip = document.createElement('div');
snip.className = 'nc-snippet';
snip.textContent = (0, text_1.snippet)(n.content, constants_1.C.SNIPPET_LEN) || (state.view.kind === 'trash' ? 'Empty note' : 'No additional text');
const meta = document.createElement('div');
meta.className = 'nc-meta';
const tagHtml = n.tags.slice(0, 3).map(t => `<span class="nc-tag">${(0, text_1.escapeHtml)(t)}</span>`).join('');
meta.innerHTML = `<span class="nc-date">${(0, text_1.fmtDate)(n.updatedAt)}</span>${tagHtml}`;
card.appendChild(title);
card.appendChild(snip);
card.appendChild(meta);
noteRefs.set(n.id, { title, snip });
card.addEventListener('click', () => { state_1.actions.selectNote(state, n.id); renderAll(); });
card.addEventListener('contextmenu', e => { e.preventDefault(); openNoteMenu(e, n); });
card.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/qn-note', n.id);
e.dataTransfer.effectAllowed = 'move';
});
els.listBody.appendChild(card);
}
if (!notes.length) {
els.listBody.innerHTML = state.view.kind === 'trash'
? `<div class="list-empty"><div class="le-ic">🗑</div><div>Trash is empty.</div></div>`
: `<div class="list-empty"><div class="le-ic">✍</div><div>No notes here yet.</div><button class="btn ghost" id="empty-add">Create a note</button></div>`;
const b = els.listBody.querySelector('#empty-add');
if (b)
b.addEventListener('click', () => {
const nb = state.view.kind === 'notebook' ? state.view.id : null;
state_1.actions.createNote(state, nb);
renderAll();
});
}
els.listFoot.innerHTML = state.view.kind === 'trash'
? `<button class="btn danger-ghost" id="purge-btn">Empty Trash</button>`
: '';
const purge = els.listFoot.querySelector('#purge-btn');
if (purge)
purge.addEventListener('click', () => {
const trashed = state.notes.filter(n => n.deleted);
trashed.forEach(n => state.notes.splice(state.notes.indexOf(n), 1));
if (state.selectedNoteId) {
const still = state.notes.find(x => x.id === state.selectedNoteId);
if (!still)
state.selectedNoteId = null;
}
(0, state_1.flushSave)(state);
renderAll();
});
}
// ------------------------------------------------------------------
// Search
// ------------------------------------------------------------------
function openSearch() {
els.overlay.classList.add('open');
const input = els.overlay.querySelector('#search-input');
setTimeout(() => input.focus(), 10);
renderSearch(input.value);
}
function renderSearch(q) {
const host = els.overlay.querySelector('#search-results');
const hits = (0, state_1.searchNotes)(state, q);
els.overlay.querySelector('#search-input').__hits = hits;
if (!q.trim()) {
host.innerHTML = `<div class="sr-empty">Type to search titles and note content…</div>`;
return;
}
if (!hits.length) {
host.innerHTML = `<div class="sr-empty">No notes match “${(0, text_1.escapeHtml)(q)}”.</div>`;
return;
}
host.innerHTML = '';
for (const h of hits) {
const row = document.createElement('div');
row.className = 'sr-row';
const name = h.inTitle ? h.note.title || 'Untitled' : (h.note.title || 'Untitled');
const ctx = h.inTitle ? (h.note.title || 'Untitled') : h.context;
const html = (0, text_1.escapeHtml)(ctx).replace(new RegExp((0, text_1.escapeReg)((0, text_1.escapeHtml)(q)), 'gi'), m => `<mark>${m}</mark>`);
row.innerHTML = `
<div class="sr-name">${(0, text_1.escapeHtml)(name)}${h.inTitle ? ' <span class="sr-hit">title</span>' : ''}</div>
<div class="sr-ctx">${html}</div>`;
row.addEventListener('click', () => {
state_1.actions.selectNote(state, h.note.id);
if (state.view.kind === 'trash')
state.view = { kind: 'all', id: null };
closeSearch();
renderAll();
});
host.appendChild(row);
}
}
// ------------------------------------------------------------------
// Context menus
// ------------------------------------------------------------------
let menuEl = null;
function openMenu(e, items) {
closeMenu();
menuEl = document.createElement('div');
menuEl.className = 'menu';
for (const it of items) {
const b = document.createElement('button');
b.className = 'menu-item' + (it.danger ? ' danger' : '') + (it.active ? ' active' : '');
b.innerHTML = (it.active ? '✓ ' : '') + (0, text_1.escapeHtml)(it.label);
b.addEventListener('click', () => { closeMenu(); it.fn(); });
menuEl.appendChild(b);
}
document.body.appendChild(menuEl);
const rect = e.clientX !== undefined
? { x: e.clientX, y: e.clientY }
: { x: window.innerWidth - 200, y: window.innerHeight - 200 };
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
menuEl.style.left = Math.min(rect.x, window.innerWidth - mw - 8) + 'px';
menuEl.style.top = Math.min(rect.y, window.innerHeight - mh - 8) + 'px';
}
function closeMenu() {
if (menuEl) {
menuEl.remove();
menuEl = null;
}
}
function openNoteMenu(e, n) {
const inTrash = n.deleted;
const items = [];
if (inTrash) {
items.push({ label: 'Restore to notebook', fn: () => { state_1.actions.restoreNote(state, n.id); renderAll(); } });
items.push({ label: 'Delete forever', danger: true, fn: () => { state_1.actions.destroyNote(state, n.id); renderAll(); } });
}
else {
items.push({ label: n.pinned ? 'Unpin' : 'Pin', fn: () => { state_1.actions.pinNote(state, n.id); renderAll(); } });
items.push({ label: 'Duplicate', fn: () => { state_1.actions.duplicateNote(state, n.id); renderAll(); } });
for (const nb of state.notebooks) {
if (nb.id !== n.notebookId) {
items.push({ label: 'Move to ' + nb.name, fn: () => { state_1.actions.moveNote(state, n.id, nb.id); renderAll(); } });
}
}
items.push({ label: 'Delete', danger: true, fn: () => { state_1.actions.deleteNote(state, n.id); renderAll(); } });
}
openMenu(e, items);
}
function renameNotebookPrompt(nb) {
const name = window.prompt('Rename notebook', nb.name);
if (name !== null && name.trim()) {
state_1.actions.renameNotebook(state, nb.id, name);
renderAll();
}
}
function deleteNotebookPrompt(nb) {
const count = state.notes.filter(n => n.notebookId === nb.id && !n.deleted).length;
const msg = count
? `Delete notebook “${nb.name}”? Its ${count} note${count === 1 ? '' : 's'} will be kept in All Notes.`
: `Delete notebook “${nb.name}”?`;
if (window.confirm(msg)) {
state_1.actions.deleteNotebook(state, nb.id);
renderAll();
}
}
// ------------------------------------------------------------------
// Global wiring: theme, new notebook, resize, shortcuts, saved
// ------------------------------------------------------------------
function wireGlobalEvents() {
// theme
const themeBtn = () => document.getElementById('theme-btn');
document.getElementById('side-foot')?.addEventListener('click', e => {
if (e.target.closest('#theme-btn')) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
(0, state_1.scheduleSave)(state);
applyTheme();
}
});
document.addEventListener('click', e => {
const add = document.getElementById('nb-add');
if (add && e.target.closest('#nb-add')) {
const name = window.prompt('New notebook name');
if (name !== null && name.trim()) {
state_1.actions.createNotebook(state, name);
renderAll();
}
}
});
document.addEventListener('mousedown', e => {
// close menus unless the press lands on the menu itself
if (menuEl && !menuEl.contains(e.target))
closeMenu();
}, true);
// resize handles
const rs = document.getElementById('rs-sidebar');
const rl = document.getElementById('rs-list');
startResize(rs, () => state.layout.sidebarW, (w) => {
if (state.layout.sidebarCollapsed) {
state.layout.sidebarCollapsed = false;
(0, state_1.scheduleSave)(state);
}
state_1.actions.setLayout(state, w);
});
startResize(rl, () => state.layout.listW, (w) => {
state_1.actions.setLayout(state, undefined, w);
});
// collapse toggle button
const sideToggle = document.getElementById('side-toggle');
sideToggle.addEventListener('click', () => {
state.layout.sidebarCollapsed = !state.layout.sidebarCollapsed;
(0, state_1.scheduleSave)(state);
renderAll();
});
// keyboard shortcuts
document.addEventListener('keydown', e => {
const inField = document.activeElement?.matches?.('input,textarea,[contenteditable="true"],[contenteditable=""]');
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openSearch();
return;
}
if (inField)
return;
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
e.preventDefault();
const nb = state.view.kind === 'notebook' ? state.view.id : null;
state_1.actions.createNote(state, nb);
renderAll();
}
});
// beforeunload — flush pending save
window.addEventListener('beforeunload', () => (0, state_1.flushSave)(state));
}
function startResize(handle, getStartWidth, cb) {
if (!handle)
return;
let startX = 0, startW = 0;
const move = (e) => {
cb(startW + (e.clientX - startX));
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.body.classList.remove('resizing');
};
handle.addEventListener('mousedown', e => {
e.preventDefault();
startX = e.clientX;
startW = getStartWidth();
document.body.classList.add('resizing');
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
}
// ------------------------------------------------------------------
// Theme
// ------------------------------------------------------------------
function applyTheme() {
const t = constants_1.C.THEMES[state.theme];
const root = document.documentElement;
for (const [k, v] of Object.entries(t)) {
root.style.setProperty('--' + k.replace(/([A-Z])/g, '-$1').toLowerCase(), v);
}
root.style.setProperty('--font-ui', constants_1.C.FONT_UI);
root.style.setProperty('--font-editor', constants_1.C.FONT_EDITOR);
root.style.setProperty('--font-mono', constants_1.C.FONT_MONO);
root.dataset.theme = state.theme;
}
// ------------------------------------------------------------------
// Highlight a note's title+snippet when it matches current search?
// (List cards already show snippet; matches are shown in search
// overlay — keep the list plain for a calm surface.)
// ------------------------------------------------------------------
// ------------------------------------------------------------------
// Master render
// ------------------------------------------------------------------
function renderAll() {
renderSidebar();
renderList();
applyTheme();
// layout vars
const grid = document.getElementById('app');
grid.style.setProperty('--side-w', (state.layout.sidebarCollapsed ? 0 : state.layout.sidebarW) + 'px');
grid.style.setProperty('--list-w', state.layout.listW + 'px');
updateSavedEl(state);
onChangeCb();
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"c3dce893-a800-43a7-96c3-7a0b6d9fdf6a","tokensIn":11073626,"tokensOut":221817,"tokensTotal":11295443,"cost":0,"turns":108,"toolCalls":150,"failedToolCalls":7,"timestamp":"2026-09-09T07:45:46.635Z"} -->