projview 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/projview.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { readFileSync } from 'node:fs';
5
+ import { readFileSync, existsSync } from 'node:fs';
6
6
  import { startServer } from '../src/server.js';
7
7
  import { buildTree, countFiles } from '../src/walk.js';
8
8
  import { buildIndex, search } from '../src/search.js';
9
- import { resolveStoreConfig, createStore } from '../src/store.js';
9
+ import { resolveStoreConfig, createStore, isPersistent, gitOrOsUser } from '../src/store.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
  const DEMO_DIR = path.join(__dirname, '..', 'docs'); // bundled sample docs
@@ -50,6 +50,8 @@ function parseSubArgs(args) {
50
50
  else if (a === '--store-url') o.storeUrl = args[++i];
51
51
  else if (a === '--persist') o.persist = true;
52
52
  else if (a === '--demo') o.demo = true;
53
+ else if (a === '--heading') o.heading = args[++i];
54
+ else if (a === '--author') o.author = args[++i];
53
55
  else if (a.startsWith('-')) { /* ignore unknown flag in a subcommand */ }
54
56
  else o.positional.push(a);
55
57
  }
@@ -80,6 +82,77 @@ function cmdTree(args) {
80
82
  process.stdout.write(JSON.stringify({ root: path.basename(root) || root, files: countFiles(tree), tree }, null, 2) + '\n');
81
83
  }
82
84
 
85
+ const print = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
86
+
87
+ // Resolve the comment author: explicit flag > git user.name > OS username.
88
+ // Agents pass --author (e.g. "claude") so AI-authored comments are distinguishable.
89
+ const resolveAuthor = (explicit) => explicit || gitOrOsUser();
90
+
91
+ // projview comment <add|resolve|reopen|rm> ... (writes ride the store)
92
+ async function cmdComment(args) {
93
+ const sub = args[0];
94
+ const o = parseSubArgs(args.slice(1));
95
+ const cfg = resolveStoreConfig(o);
96
+ const root = o.demo ? DEMO_DIR : process.cwd();
97
+ const store = createStore(cfg, root);
98
+ try {
99
+ if (sub === 'add') {
100
+ const [file, body] = o.positional;
101
+ if (!file || !body) {
102
+ console.error('usage: projview comment add <file> "<body>" [--heading <id>] [--author <name>] [--persist | --store-url <url>]');
103
+ process.exit(1);
104
+ }
105
+ if (!isPersistent(cfg)) {
106
+ console.error('⚠ no persistent store - this comment will NOT be saved on exit. Re-run with --persist or --store-url <url>.');
107
+ }
108
+ if (!existsSync(path.resolve(root, file))) {
109
+ console.error(`note: "${file}" not found under ${root} - saving the comment anyway.`);
110
+ }
111
+ const anchor = o.heading ? { type: 'heading', id: o.heading } : { type: 'file' };
112
+ print(await store.addComment({ file, body, anchor, author: resolveAuthor(o.author) }));
113
+ return;
114
+ }
115
+ if (sub === 'resolve' || sub === 'reopen') {
116
+ const id = o.positional[0];
117
+ if (!id) { console.error(`usage: projview comment ${sub} <id> [--persist | --store-url <url>]`); process.exit(1); }
118
+ const c = await store.updateComment(id, { resolved: sub === 'resolve' });
119
+ if (!c) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
120
+ print(c);
121
+ return;
122
+ }
123
+ if (sub === 'rm' || sub === 'delete') {
124
+ const id = o.positional[0];
125
+ if (!id) { console.error('usage: projview comment rm <id> [--persist | --store-url <url>]'); process.exit(1); }
126
+ if (!(await store.deleteComment(id))) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
127
+ print({ deleted: id });
128
+ return;
129
+ }
130
+ console.error('usage: projview comment <add|resolve|reopen|rm> ...');
131
+ process.exit(1);
132
+ } finally {
133
+ await store.close();
134
+ }
135
+ }
136
+
137
+ // projview comments [file] -> list comments (all, or for one file) as JSON
138
+ async function cmdComments(args) {
139
+ const o = parseSubArgs(args);
140
+ const root = o.demo ? DEMO_DIR : process.cwd();
141
+ const store = createStore(resolveStoreConfig(o), root);
142
+ const list = await store.listComments(o.positional[0]);
143
+ await store.close();
144
+ print(list);
145
+ }
146
+
147
+ // projview usage [path] -> aggregated usage stats (views / searches) as JSON
148
+ async function cmdUsage(args) {
149
+ const o = parseSubArgs(args);
150
+ const store = createStore(resolveStoreConfig(o), subRoot(o, 0));
151
+ const stats = await store.usageStats();
152
+ await store.close();
153
+ print(stats);
154
+ }
155
+
83
156
  function openBrowser(url) {
84
157
  const cmd = process.platform === 'darwin' ? 'open'
85
158
  : process.platform === 'win32' ? 'cmd' : 'xdg-open';
@@ -92,9 +165,13 @@ function openBrowser(url) {
92
165
  const HELP = `projview - ephemeral previewer for markdown + mermaid
93
166
 
94
167
  usage:
95
- projview [path] [options] start the viewer (default)
96
- projview search <query> [path] print ranked search results as JSON
97
- projview tree [path] print the file tree as JSON
168
+ projview [path] [options] start the viewer (default)
169
+ projview search <query> [path] print ranked search results as JSON
170
+ projview tree [path] print the file tree as JSON
171
+ projview comment add <file> "<text>" leave a comment (--heading <id> for a section)
172
+ projview comments [file] list comments as JSON
173
+ projview comment resolve|reopen|rm <id> resolve, reopen, or delete a comment
174
+ projview usage [path] print usage stats (views / searches) as JSON
98
175
 
99
176
  (subcommands print to stdout and exit - no server. --demo works with them too,
100
177
  e.g. projview search "websockets" --demo)
@@ -109,11 +186,14 @@ options:
109
186
  --persist keep the index in ~/.projview/cache and reuse it next run
110
187
  --store <kind> store backend: memory | superlite | lite (default: memory)
111
188
  --store-url <url> custom store: sqlite://<file> or postgres://<...> (or set DATABASE_URL)
189
+ --heading <id> (comment add) anchor the comment to a heading/section id
190
+ --author <name> (comment add) author to record (default: git/OS user)
112
191
  -v, --version print the version and exit
113
192
  -h, --help show this help
114
193
 
115
194
  ephemeral by default - nothing is persisted unless you opt in with --persist /
116
- --store / --store-url. ctrl-c removes any ephemeral store.`;
195
+ --store / --store-url. ctrl-c removes any ephemeral store. Comments + usage need a
196
+ persistent store to survive (you'll be warned when adding a comment without one).`;
117
197
 
118
198
  async function main() {
119
199
  const argv = process.argv.slice(2);
@@ -121,6 +201,9 @@ async function main() {
121
201
  // machine subcommands -> JSON to stdout, then exit (no server)
122
202
  if (argv[0] === 'search') return cmdSearch(argv.slice(1));
123
203
  if (argv[0] === 'tree') return cmdTree(argv.slice(1));
204
+ if (argv[0] === 'comment') return cmdComment(argv.slice(1));
205
+ if (argv[0] === 'comments') return cmdComments(argv.slice(1));
206
+ if (argv[0] === 'usage') return cmdUsage(argv.slice(1));
124
207
 
125
208
  const opts = parseArgs(argv);
126
209
  if (opts.help) { console.log(HELP); return; }
@@ -131,13 +214,14 @@ async function main() {
131
214
  }
132
215
 
133
216
  const storeConfig = resolveStoreConfig(opts);
134
- const { port, close, storeInfo } = await startServer(opts.root, { port: opts.port, host: opts.host, store: storeConfig });
217
+ const { port, close, storeInfo, writable } = await startServer(opts.root, { port: opts.port, host: opts.host, store: storeConfig });
135
218
  const url = `http://${opts.host}:${port}`;
136
219
 
137
220
  console.log(`\n projview -> ${url}`);
138
221
  console.log(` indexing -> ${opts.root}`);
139
222
  console.log(` store -> ${storeInfo.describe}`);
140
223
  if (storeInfo.warm) console.log(` warm -> reused cache, reindexed ${storeInfo.reindexed}/${storeInfo.total} changed`);
224
+ if (!writable) console.log(` comments -> read-only (exposed on ${opts.host})`);
141
225
  console.log(` (ctrl-c to stop)\n`);
142
226
 
143
227
  if (opts.open) openBrowser(url);
@@ -11,7 +11,7 @@ previewer for markdown + mermaid files.
11
11
 
12
12
  | need | version |
13
13
  | --- | --- |
14
- | Node.js | **18+** |
14
+ | Node.js | **22+** (the `lite` sqlite store uses the built-in `node:sqlite`) |
15
15
  | npm | ships with Node |
16
16
 
17
17
  No build step. By default indexing is in-memory and nothing is written to disk -
@@ -67,7 +67,7 @@ npm install
67
67
  npm run demo # preview the bundled docs/ (alias for --demo)
68
68
  npm start # index the current directory
69
69
  node bin/projview.js ./docs # or call the CLI directly
70
- npm test # run the test suite (node:test, 29 tests)
70
+ npm test # run the test suite (node:test, 45 tests)
71
71
  ```
72
72
 
73
73
  See the [project README](README.md) for the architecture and the
@@ -82,10 +82,15 @@ See the [project README](README.md) for the architecture and the
82
82
  | `projview [path]` | start the viewer (default) |
83
83
  | `projview search <query> [path]` | print ranked search results as JSON, then exit |
84
84
  | `projview tree [path]` | print the file tree as JSON, then exit |
85
+ | `projview comment add <file> "<body>"` | leave a comment (`--heading <id>` for a section) |
86
+ | `projview comments [file]` | list comments as JSON |
87
+ | `projview comment resolve\|reopen\|rm <id>` | resolve, reopen, or delete a comment |
88
+ | `projview usage [path]` | print usage stats (views / searches) as JSON |
85
89
 
86
- The `search` and `tree` subcommands are projview's **machine interface** - JSON on
87
- stdout, no server, no browser - built for agents and scripts. See
88
- [ai-tests/search.md](ai-tests/search.md) for a worked test.
90
+ The subcommands are projview's **machine interface** - JSON on stdout, no server,
91
+ no browser - built for agents and scripts. `search`/`tree` are read-only;
92
+ `comment` writes ride a persistent store (you're warned if you comment without
93
+ one). See [ai-tests/search.md](ai-tests/search.md) for a worked test.
89
94
 
90
95
  **Options**
91
96
 
@@ -98,6 +103,8 @@ stdout, no server, no browser - built for agents and scripts. See
98
103
  | `--persist` | keep the index in `~/.projview/cache` and reuse it next run |
99
104
  | `--store <kind>` | store backend: `memory` \| `superlite` \| `lite` (default: `memory`; `lite` when `--persist`) |
100
105
  | `--store-url <url>` | use a custom store: `sqlite://<file>` or `postgres://<...>` |
106
+ | `--heading <id>` | (`comment add`) anchor the comment to a heading/section id |
107
+ | `--author <name>` | (`comment add`) author to record (default: git/OS user) |
101
108
  | `-v`, `--version` | print the version and exit |
102
109
  | `-h`, `--help` | show help |
103
110
 
@@ -113,8 +120,9 @@ stdout, no server, no browser - built for agents and scripts. See
113
120
 
114
121
  ## persistence & stores
115
122
 
116
- By default projview is **ephemeral** - the index lives in memory and nothing is
117
- written to disk. Opt in to persistence when you want a faster warm start or a
123
+ By default projview is **ephemeral** - the index (plus any comments and usage
124
+ events) lives in memory and nothing is written to disk. Opt in to persistence
125
+ when you want a faster warm start, comments that survive a restart, or a
118
126
  queryable store an AI can read:
119
127
 
120
128
  | backend | what | how |
@@ -5,7 +5,8 @@ and scroll-spy (the active section should track as you scroll).
5
5
 
6
6
  ## overview
7
7
 
8
- projview exposes a tiny HTTP surface. Everything is read-only and in-memory.
8
+ projview exposes a tiny HTTP surface. It never writes your files - the only
9
+ state it records is its own usage events (views / searches) into the store.
9
10
 
10
11
  ## endpoints
11
12
 
@@ -37,6 +38,35 @@ line-numbered snippets. See [the AI search test](../ai-tests/search.md).
37
38
  `projview tree [path]` returns `{ root, files, tree[] }` - the same structure
38
39
  `/api/tree` serves.
39
40
 
41
+ ### projview comment
42
+
43
+ Leave / manage comments (they ride a persistent store - use `--persist` or
44
+ `--store-url`, else you'll be warned the comment won't survive exit):
45
+
46
+ | command | meaning |
47
+ | --- | --- |
48
+ | `projview comment add <file> "<body>"` | add a comment (whole file) |
49
+ | `projview comment add <file> "<body>" --heading <id>` | anchor it to a section |
50
+ | `projview comment resolve\|reopen <id>` | toggle resolved |
51
+ | `projview comment rm <id>` | delete it |
52
+
53
+ `--author <name>` overrides the recorded author (default: git/OS user). Agents
54
+ pass e.g. `--author claude` so AI-authored comments are distinguishable. A
55
+ comment is `{ id, file, anchor, body, author, createdAt, resolved }`; the
56
+ `anchor` is `{type:'file'}`, `{type:'heading', id}`, or a `{type:'text', exact,
57
+ prefix, suffix, section}` quote selector (text anchors come from the viewer).
58
+
59
+ ### projview comments
60
+
61
+ `projview comments [file]` lists comments as JSON - all of them, or just one
62
+ file's.
63
+
64
+ ### projview usage
65
+
66
+ `projview usage [path]` returns `{ total, byType, topFiles[], recentSearches[] }`.
67
+ The viewer records a `view` per file opened and a `search` per query, so usage
68
+ accumulates over a session (persisted only with a persistent store).
69
+
40
70
  ## the walker
41
71
 
42
72
  ### ignored directories
@@ -87,8 +117,9 @@ A chokidar watcher broadcasts change/tree events over SSE.
87
117
 
88
118
  ## stores (persistence)
89
119
 
90
- The index is in-memory by default (ephemeral). Opt into a pluggable store with
91
- `--persist` / `--store` / `--store-url`:
120
+ The index - plus **comments** and **usage events** - is in-memory by default
121
+ (ephemeral). Opt into a pluggable store with `--persist` / `--store` /
122
+ `--store-url` so comments and usage survive a restart:
92
123
 
93
124
  ### backends
94
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projview",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ephemeral local previewer for markdown + mermaid files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,400 @@
1
+ /* comments: a right-side drawer + inline anchoring.
2
+ *
3
+ * Anchors come in three flavours (see src/store.js): whole-file, a heading
4
+ * (section), or a text quote. Text quotes are re-anchored on every render via a
5
+ * {exact, prefix, suffix} selector - if the text moved we re-find it, if it
6
+ * vanished the comment is flagged "orphaned" (never lost). Highlights paint with
7
+ * the CSS Custom Highlight API (no DOM mutation), so the TOC / search / mermaid
8
+ * are untouched. Writes are hidden when the server says it isn't writable. */
9
+
10
+ import { $, esc } from './dom.js';
11
+ import { state } from './store.js';
12
+ import { toast } from './toast.js';
13
+
14
+ const contentEl = $('content');
15
+ const viewerEl = $('viewer');
16
+ const HL = typeof CSS !== 'undefined' && CSS.highlights; // Custom Highlight API?
17
+
18
+ let current = []; // comments for the current file
19
+ const ranges = new Map(); // id -> resolved Range (text anchors only)
20
+ let activeId = null;
21
+ let pending = null; // { anchor, quote } while composing a new comment
22
+
23
+ /* ---------------- scaffold (built once) ---------------- */
24
+
25
+ const cre = (tag, cls, html) => {
26
+ const e = document.createElement(tag);
27
+ if (cls) e.className = cls;
28
+ if (html != null) e.innerHTML = html;
29
+ return e;
30
+ };
31
+
32
+ const fab = cre('button', 'cmt-fab', '💬<span class="cmt-badge" hidden>0</span>');
33
+ fab.title = 'comments';
34
+ fab.hidden = true;
35
+
36
+ const drawer = cre('aside', 'cmt-drawer');
37
+ drawer.hidden = true;
38
+ drawer.innerHTML =
39
+ `<header class="cmt-drawer-head">
40
+ <div><strong>comments</strong> <span class="cmt-file"></span></div>
41
+ <div class="cmt-head-btns">
42
+ <button class="cmt-add-file" title="note on the whole file">+ note</button>
43
+ <button class="cmt-close" title="close">✕</button>
44
+ </div>
45
+ </header>
46
+ <div class="cmt-composer-slot"></div>
47
+ <div class="cmt-list"></div>`;
48
+
49
+ const chip = cre('button', 'cmt-selchip', '💬 comment');
50
+ chip.hidden = true;
51
+
52
+ document.body.append(fab, drawer, chip);
53
+
54
+ const listEl = drawer.querySelector('.cmt-list');
55
+ const composerSlot = drawer.querySelector('.cmt-composer-slot');
56
+ const fileEl = drawer.querySelector('.cmt-file');
57
+ const addFileBtn = drawer.querySelector('.cmt-add-file');
58
+
59
+ fab.addEventListener('click', () => toggleDrawer());
60
+ drawer.querySelector('.cmt-close').addEventListener('click', () => toggleDrawer(false));
61
+ addFileBtn.addEventListener('click', () => startCompose({ type: 'file' }, null));
62
+
63
+ /* ---------------- public entry points ---------------- */
64
+
65
+ // Called by the viewer after a file renders: load + anchor + paint + list.
66
+ export async function onFileRendered(path) {
67
+ pending = null;
68
+ activeId = null;
69
+ if (!path) { fab.hidden = true; clearPaint(); return; }
70
+ await loadComments(path);
71
+ fileEl.textContent = path.split('/').pop();
72
+ reanchor();
73
+ renderComposer();
74
+ renderList();
75
+ addHeadingAffordances();
76
+ fab.hidden = false;
77
+ }
78
+
79
+ // Called on an SSE 'comments' event: refresh data + markers without touching the
80
+ // document render or an open composer.
81
+ export async function refreshComments() {
82
+ if (!state.currentPath) return;
83
+ await loadComments(state.currentPath);
84
+ reanchor();
85
+ renderList();
86
+ }
87
+
88
+ /* ---------------- data ---------------- */
89
+
90
+ async function loadComments(path) {
91
+ try {
92
+ const res = await fetch(`/api/comments?file=${encodeURIComponent(path)}`);
93
+ current = res.ok ? await res.json() : [];
94
+ } catch { current = []; }
95
+ updateBadge();
96
+ }
97
+
98
+ async function api(method, url, body) {
99
+ const res = await fetch(url, {
100
+ method,
101
+ headers: body ? { 'content-type': 'application/json' } : undefined,
102
+ body: body ? JSON.stringify(body) : undefined
103
+ });
104
+ if (!res.ok) { toast(res.status === 403 ? 'comments are read-only here' : 'comment failed'); return null; }
105
+ return res.json();
106
+ }
107
+
108
+ async function submitCompose(body) {
109
+ if (!body.trim() || !pending) return;
110
+ const created = await api('POST', '/api/comments', { file: state.currentPath, body: body.trim(), anchor: pending.anchor });
111
+ if (!created) return;
112
+ pending = null;
113
+ await refreshComments();
114
+ renderComposer();
115
+ openComment(created.id);
116
+ }
117
+
118
+ async function setResolved(id, resolved) {
119
+ const c = await api('PATCH', `/api/comments/${encodeURIComponent(id)}`, { resolved });
120
+ if (c) await refreshComments();
121
+ }
122
+
123
+ async function remove(id) {
124
+ const r = await api('DELETE', `/api/comments/${encodeURIComponent(id)}`);
125
+ if (r) { if (activeId === id) activeId = null; await refreshComments(); }
126
+ }
127
+
128
+ /* ---------------- re-anchoring engine ---------------- */
129
+
130
+ // Flat text of the content + a map from string offset -> text node.
131
+ function segments() {
132
+ const segs = [];
133
+ let text = '';
134
+ const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
135
+ let node;
136
+ while ((node = walker.nextNode())) {
137
+ segs.push({ node, start: text.length });
138
+ text += node.nodeValue;
139
+ }
140
+ return { text, segs };
141
+ }
142
+
143
+ function pointAt(segs, offset) {
144
+ for (const s of segs) {
145
+ if (offset <= s.start + s.node.nodeValue.length) return { node: s.node, offset: Math.max(0, offset - s.start) };
146
+ }
147
+ const last = segs[segs.length - 1];
148
+ return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
149
+ }
150
+
151
+ const commonPrefix = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; };
152
+ const commonSuffix = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[a.length - 1 - i] === b[b.length - 1 - i]) i++; return i; };
153
+
154
+ // Resolve a {exact, prefix, suffix} quote selector to a Range, disambiguating
155
+ // repeats by surrounding context. Returns null if `exact` isn't present.
156
+ function resolveText(anchor, ctx) {
157
+ const { text } = ctx;
158
+ const exact = anchor.exact || '';
159
+ if (!exact) return null;
160
+ let best = -1, bestScore = -1;
161
+ let idx = text.indexOf(exact);
162
+ while (idx !== -1) {
163
+ const before = text.slice(Math.max(0, idx - (anchor.prefix?.length || 0)), idx);
164
+ const after = text.slice(idx + exact.length, idx + exact.length + (anchor.suffix?.length || 0));
165
+ const score = commonSuffix(before, anchor.prefix || '') + commonPrefix(after, anchor.suffix || '');
166
+ if (score > bestScore) { bestScore = score; best = idx; }
167
+ idx = text.indexOf(exact, idx + 1);
168
+ }
169
+ if (best === -1) return null;
170
+ const a = pointAt(ctx.segs, best);
171
+ const b = pointAt(ctx.segs, best + exact.length);
172
+ if (!a || !b) return null;
173
+ const range = document.createRange();
174
+ try { range.setStart(a.node, a.offset); range.setEnd(b.node, b.offset); } catch { return null; }
175
+ return range;
176
+ }
177
+
178
+ function reanchor() {
179
+ ranges.clear();
180
+ const ctx = segments();
181
+ for (const c of current) {
182
+ c._orphan = false;
183
+ if (c.anchor?.type === 'text') {
184
+ const r = resolveText(c.anchor, ctx);
185
+ if (r) ranges.set(c.id, r); else c._orphan = true;
186
+ }
187
+ }
188
+ paint();
189
+ }
190
+
191
+ function paint() {
192
+ if (!HL) return;
193
+ CSS.highlights.set('pv-comment', new Highlight(...[...ranges.values()]));
194
+ const act = activeId && ranges.get(activeId);
195
+ CSS.highlights.set('pv-comment-active', act ? new Highlight(act) : new Highlight());
196
+ }
197
+ function clearPaint() { if (HL) { CSS.highlights.delete('pv-comment'); CSS.highlights.delete('pv-comment-active'); } ranges.clear(); }
198
+
199
+ /* ---------------- inline affordances ---------------- */
200
+
201
+ // Click inside the content: if it lands on a highlighted quote, open that comment.
202
+ contentEl.addEventListener('click', (e) => {
203
+ if (!ranges.size || !document.caretRangeFromPoint) return;
204
+ const caret = document.caretRangeFromPoint(e.clientX, e.clientY);
205
+ if (!caret) return;
206
+ for (const [id, r] of ranges) {
207
+ try { if (r.isPointInRange(caret.startContainer, caret.startOffset)) { openComment(id); return; } } catch { /* different root */ }
208
+ }
209
+ });
210
+
211
+ // A hover "💬" at the end of every heading -> comment on that section.
212
+ function addHeadingAffordances() {
213
+ if (!state.writable) return;
214
+ for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
215
+ if (!h.id || h.querySelector('.cmt-head-add')) continue;
216
+ const label = h.textContent;
217
+ const b = cre('button', 'cmt-head-add'); // text comes from CSS (keeps h.textContent clean)
218
+ b.title = 'comment on this section';
219
+ b.addEventListener('click', (ev) => { ev.stopPropagation(); startCompose({ type: 'heading', id: h.id }, label); });
220
+ h.appendChild(b);
221
+ }
222
+ }
223
+
224
+ // Text selection -> a floating "comment" chip near the selection.
225
+ viewerEl.addEventListener('mouseup', () => {
226
+ if (!state.writable) { chip.hidden = true; return; }
227
+ const sel = window.getSelection();
228
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) { chip.hidden = true; return; }
229
+ const range = sel.getRangeAt(0);
230
+ if (!contentEl.contains(range.commonAncestorContainer)) { chip.hidden = true; return; }
231
+ const rect = range.getBoundingClientRect();
232
+ chip.style.top = `${Math.max(8, rect.top - 40)}px`;
233
+ chip.style.left = `${rect.left}px`;
234
+ chip.hidden = false;
235
+ });
236
+ chip.addEventListener('mousedown', (e) => e.preventDefault()); // don't clear the selection
237
+ chip.addEventListener('click', () => {
238
+ const anchor = captureTextAnchor();
239
+ chip.hidden = true;
240
+ if (anchor) startCompose(anchor, anchor.exact);
241
+ else toast('select some text first');
242
+ });
243
+ document.addEventListener('mousedown', (e) => { if (e.target !== chip && !chip.hidden) chip.hidden = true; });
244
+
245
+ function captureTextAnchor() {
246
+ const sel = window.getSelection();
247
+ if (!sel || sel.isCollapsed) return null;
248
+ const range = sel.getRangeAt(0);
249
+ if (!contentEl.contains(range.commonAncestorContainer)) return null;
250
+ const exact = sel.toString();
251
+ const flat = contentEl.textContent;
252
+ const pre = document.createRange();
253
+ pre.setStart(contentEl, 0);
254
+ pre.setEnd(range.startContainer, range.startOffset);
255
+ const startOff = pre.toString().length;
256
+ const endOff = startOff + exact.length;
257
+ return {
258
+ type: 'text',
259
+ exact,
260
+ prefix: flat.slice(Math.max(0, startOff - 32), startOff),
261
+ suffix: flat.slice(endOff, endOff + 32),
262
+ section: nearestHeadingId(range)
263
+ };
264
+ }
265
+
266
+ function nearestHeadingId(range) {
267
+ const top = range.getBoundingClientRect().top;
268
+ let id = null;
269
+ for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
270
+ if (!h.id) continue;
271
+ if (h.getBoundingClientRect().top <= top) id = h.id; else break;
272
+ }
273
+ return id;
274
+ }
275
+
276
+ /* ---------------- composer + list ---------------- */
277
+
278
+ function startCompose(anchor, quote) {
279
+ if (!state.writable) { toast('comments are read-only here'); return; }
280
+ pending = { anchor, quote };
281
+ toggleDrawer(true);
282
+ renderComposer();
283
+ const ta = composerSlot.querySelector('textarea');
284
+ if (ta) ta.focus();
285
+ }
286
+
287
+ function renderComposer() {
288
+ composerSlot.replaceChildren();
289
+ if (!pending || !state.writable) return;
290
+ const box = cre('div', 'cmt-composer');
291
+ box.append(cre('div', 'cmt-anchor-chip', anchorLabel(pending.anchor, pending.quote)));
292
+ const ta = cre('textarea');
293
+ ta.placeholder = 'add a comment…';
294
+ ta.rows = 3;
295
+ const actions = cre('div', 'cmt-composer-actions');
296
+ const save = cre('button', 'cmt-btn primary', 'comment');
297
+ const cancel = cre('button', 'cmt-btn', 'cancel');
298
+ save.addEventListener('click', () => submitCompose(ta.value));
299
+ cancel.addEventListener('click', () => { pending = null; renderComposer(); });
300
+ ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitCompose(ta.value); });
301
+ actions.append(save, cancel);
302
+ box.append(ta, actions);
303
+ composerSlot.append(box);
304
+ }
305
+
306
+ function renderList() {
307
+ listEl.replaceChildren();
308
+ if (!current.length) {
309
+ listEl.append(cre('div', 'cmt-empty', '💭<br>no comments yet' + (state.writable ? '<br><small>select text, hover a heading, or “+ note”</small>' : '')));
310
+ return;
311
+ }
312
+ for (const c of current) {
313
+ const card = cre('div', 'cmt-card' + (c.resolved ? ' resolved' : '') + (c._orphan ? ' orphan' : '') + (c.id === activeId ? ' active' : ''));
314
+ card.dataset.id = c.id;
315
+ const meta = cre('div', 'cmt-meta',
316
+ `<span class="cmt-author">${esc(c.author || 'anon')}</span><span class="cmt-time">${ago(c.createdAt)}</span>`);
317
+ const chipEl = cre('div', 'cmt-anchor-chip' + (c._orphan ? ' orphan' : ''), anchorLabel(c.anchor) + (c._orphan ? ' · not found' : ''));
318
+ const bodyEl = cre('div', 'cmt-body', esc(c.body).replace(/\n/g, '<br>'));
319
+ card.append(meta, chipEl, bodyEl);
320
+ if (state.writable) {
321
+ const actions = cre('div', 'cmt-actions');
322
+ const res = cre('button', 'cmt-link', c.resolved ? 'reopen' : 'resolve');
323
+ const del = cre('button', 'cmt-link danger', 'delete');
324
+ res.addEventListener('click', (e) => { e.stopPropagation(); setResolved(c.id, !c.resolved); });
325
+ del.addEventListener('click', (e) => { e.stopPropagation(); remove(c.id); });
326
+ actions.append(res, del);
327
+ card.append(actions);
328
+ }
329
+ card.addEventListener('click', () => openComment(c.id));
330
+ listEl.append(card);
331
+ }
332
+ }
333
+
334
+ /* ---------------- navigation ---------------- */
335
+
336
+ function openComment(id) {
337
+ activeId = id;
338
+ toggleDrawer(true);
339
+ paint();
340
+ renderList();
341
+ const card = listEl.querySelector(`.cmt-card[data-id="${CSS.escape(id)}"]`);
342
+ if (card) card.scrollIntoView({ block: 'nearest' });
343
+ const c = current.find((x) => x.id === id);
344
+ if (c) jumpToAnchor(c);
345
+ }
346
+
347
+ function jumpToAnchor(c) {
348
+ const a = c.anchor || {};
349
+ if (a.type === 'text') {
350
+ const r = ranges.get(c.id);
351
+ if (r) { scrollRangeIntoView(r); return; }
352
+ if (a.section) { document.getElementById(a.section)?.scrollIntoView({ block: 'start' }); return; }
353
+ toast('original text not found - comment is orphaned');
354
+ } else if (a.type === 'heading') {
355
+ const el = document.getElementById(a.id);
356
+ if (el) el.scrollIntoView({ block: 'start' }); else toast('section no longer exists');
357
+ } else {
358
+ viewerEl.scrollTop = 0;
359
+ }
360
+ }
361
+
362
+ function scrollRangeIntoView(range) {
363
+ const r = range.getBoundingClientRect();
364
+ const v = viewerEl.getBoundingClientRect();
365
+ if (r.height || r.width) viewerEl.scrollTop += (r.top - v.top) - 120;
366
+ }
367
+
368
+ /* ---------------- bits ---------------- */
369
+
370
+ function toggleDrawer(force) {
371
+ const open = force === undefined ? drawer.hidden : force;
372
+ drawer.hidden = !open;
373
+ fab.classList.toggle('open', open);
374
+ }
375
+
376
+ function updateBadge() {
377
+ const open = current.filter((c) => !c.resolved).length;
378
+ const badge = fab.querySelector('.cmt-badge');
379
+ badge.textContent = String(open);
380
+ badge.hidden = open === 0;
381
+ }
382
+
383
+ function anchorLabel(a, quoteOverride) {
384
+ if (!a) return 'file';
385
+ if (a.type === 'file') return '📄 whole file';
386
+ if (a.type === 'heading') {
387
+ const el = document.getElementById(a.id);
388
+ return '§ ' + (quoteOverride || (el ? el.textContent : a.id));
389
+ }
390
+ const q = (quoteOverride || a.exact || '').replace(/\s+/g, ' ').trim();
391
+ return '“' + (q.length > 60 ? q.slice(0, 60) + '…' : q) + '”';
392
+ }
393
+
394
+ function ago(ts) {
395
+ const s = Math.max(0, (Date.now() - ts) / 1000);
396
+ if (s < 60) return 'just now';
397
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
398
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
399
+ return `${Math.floor(s / 86400)}d ago`;
400
+ }