lowkey-doc 0.0.0-stage → 1.0.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.
@@ -0,0 +1,1184 @@
1
+ // app.mjs - the page's own behaviour, inlined into the built file.
2
+ // Sidebar and routing, folding sections, search, the diagram lightbox, and
3
+ // the margin comments. Diagram rendering itself lives in diagrams.mjs.
4
+
5
+ export const APP_JS = String.raw`
6
+ (function(){
7
+ var CFG = window.__LOWKEY_DOC__ || {};
8
+ var NS = CFG.ns || 'lowkey-doc';
9
+ var DATA = JSON.parse(document.getElementById('doc-data').textContent);
10
+ var docs = DATA.docs, byId = {};
11
+ docs.forEach(function(d){ byId[d.id] = d; });
12
+
13
+ var layout = document.getElementById('layout');
14
+ var nav = document.getElementById('nav');
15
+ var main = document.getElementById('main');
16
+ var bar = document.getElementById('topbar');
17
+ var barTitle = document.getElementById('topbar-title');
18
+ var search = document.getElementById('search');
19
+ var cache = {};
20
+ var current = null;
21
+ var heads = [];
22
+
23
+ // a file:// origin can refuse storage; the dashboard must still work
24
+ function keep(k, v){ try { localStorage.setItem(NS + ':' + k, v); } catch (e) {} }
25
+ function recall(k){ try { return localStorage.getItem(NS + ':' + k); } catch (e) { return null; } }
26
+
27
+ /* ---------------- sidebar ---------------- */
28
+
29
+ function el(tag, cls, text){
30
+ var n = document.createElement(tag);
31
+ if (cls) n.className = cls;
32
+ if (text != null) n.textContent = text;
33
+ return n;
34
+ }
35
+
36
+ var CARET = '<svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M6 4l4 4-4 4"/></svg>';
37
+
38
+ function buildNav(){
39
+ var group = null;
40
+ docs.forEach(function(d){
41
+ if (d.group !== group){
42
+ group = d.group;
43
+ nav.appendChild(el('div', 'nav-group', group));
44
+ }
45
+ var item = el('div', 'item');
46
+ item.dataset.doc = d.id;
47
+ item.title = d.file;
48
+ item.appendChild(el('span', 'num', d.badge));
49
+ item.appendChild(el('span', 'item-title', d.title));
50
+ if (d.sections.length) item.insertAdjacentHTML('beforeend', CARET);
51
+ item.addEventListener('click', function(e){
52
+ if (e.target.closest('.caret') && current === d.id){ item.classList.toggle('open'); return; }
53
+ go(d.id);
54
+ });
55
+ nav.appendChild(item);
56
+
57
+ var subs = el('div', 'subs');
58
+ subs.dataset.subs = d.id;
59
+ d.sections.forEach(function(s){
60
+ var a = el('a', 'sub');
61
+ a.href = '#/' + d.id + '/' + s.id;
62
+ a.title = (s.num ? s.num + ' ' : '') + s.text;
63
+ if (s.num) a.appendChild(el('span', 'sub-n', s.num));
64
+ a.appendChild(el('span', 'sub-t', s.text));
65
+ subs.appendChild(a);
66
+ });
67
+ nav.appendChild(subs);
68
+ });
69
+ }
70
+
71
+ /* ---------------- routing ---------------- */
72
+
73
+ function parseHash(){
74
+ var m = location.hash.match(/^#\/([^/]+)(?:\/(.+))?$/);
75
+ return m ? { id: decodeURIComponent(m[1]), anchor: m[2] && decodeURIComponent(m[2]) } : null;
76
+ }
77
+
78
+ function go(id, anchor){
79
+ location.hash = '#/' + id + (anchor ? '/' + anchor : '');
80
+ }
81
+
82
+ function route(){
83
+ var r = parseHash();
84
+ var id = r && byId[r.id] ? r.id : (recall('last') || docs[0].id);
85
+ if (!byId[id]) id = docs[0].id;
86
+ show(id, r && r.anchor);
87
+ }
88
+
89
+ function show(id, anchor){
90
+ var d = byId[id];
91
+ if (current !== id){
92
+ current = id;
93
+ keep('last', id);
94
+
95
+ main.textContent = '';
96
+ main.appendChild(bodyFor(d));
97
+
98
+ nav.querySelectorAll('.item').forEach(function(n){
99
+ var on = n.dataset.doc === id;
100
+ n.classList.toggle('active', on);
101
+ n.classList.toggle('open', on);
102
+ });
103
+ heads = [].slice.call(main.querySelectorAll('h1[id],h2[id]'));
104
+ barTitle.textContent = d.title;
105
+ document.title = d.title + ' — ' + CFG.title;
106
+ restoreFolds(main);
107
+ if (window.__diagrams) window.__diagrams.drawAll(main);
108
+ renderComments();
109
+ watch();
110
+ }
111
+ if (anchor && reveal(anchor, false)) return;
112
+ if (!anchor) window.scrollTo(0, 0);
113
+ requestAnimationFrame(sync);
114
+ }
115
+
116
+ // step to the neighbouring document, in sidebar order
117
+ function navBtn(d, dir){
118
+ var a = el('a', 'docnav-btn ' + dir);
119
+ a.href = '#/' + d.id;
120
+ a.title = d.file;
121
+ a.appendChild(el('span', 'docnav-label', dir === 'prev' ? '← Previous' : 'Next →'));
122
+ a.appendChild(el('span', 'docnav-title', d.title));
123
+ return a;
124
+ }
125
+
126
+ function bodyFor(d){
127
+ if (cache[d.id]) return cache[d.id];
128
+ var wrap = document.createDocumentFragment();
129
+
130
+ var head = el('header', 'head');
131
+ head.appendChild(el('h1', null, d.title));
132
+ if (d.subtitle){
133
+ var p = el('p');
134
+ p.innerHTML = d.subtitle;
135
+ head.appendChild(p);
136
+ }
137
+ wrap.appendChild(head);
138
+
139
+ var body = el('article', 'doc');
140
+ body.innerHTML = d.html;
141
+ wrap.appendChild(body);
142
+
143
+ var i = docs.indexOf(d);
144
+ var prev = i > 0 ? docs[i - 1] : null;
145
+ var next = i >= 0 && i < docs.length - 1 ? docs[i + 1] : null;
146
+ if (prev || next){
147
+ var footer = el('nav', 'docnav');
148
+ if (prev) footer.appendChild(navBtn(prev, 'prev'));
149
+ if (next) footer.appendChild(navBtn(next, 'next'));
150
+ wrap.appendChild(footer);
151
+ }
152
+
153
+ var holder = el('div', 'body-wrap');
154
+ holder.appendChild(wrap);
155
+ holder.appendChild(el('div', 'cmt-layer'));
156
+ cache[d.id] = holder;
157
+ return holder;
158
+ }
159
+
160
+ /* ---------------- folding sections ---------------- */
161
+ // Which sections are folded is remembered per document, so a long reference
162
+ // page stays folded down to the parts you actually read.
163
+
164
+ var shut = {};
165
+ try { shut = JSON.parse(recall('folded') || '{}') || {}; } catch (e) { shut = {}; }
166
+
167
+ function secKey(sec){ return current + '#' + sec.dataset.sec; }
168
+
169
+ function fold(sec, on, persist){
170
+ sec.classList.toggle('shut', on);
171
+ var tog = sec.querySelector(':scope > .sec-head > .sec-tog');
172
+ if (tog) tog.setAttribute('aria-label', on ? 'Unfold section' : 'Fold section');
173
+ if (persist){
174
+ if (on) shut[secKey(sec)] = 1; else delete shut[secKey(sec)];
175
+ keep('folded', JSON.stringify(shut));
176
+ }
177
+ schedulePlace();
178
+ }
179
+
180
+ function restoreFolds(scope){
181
+ [].slice.call(scope.querySelectorAll('.sec')).forEach(function(sec){
182
+ if (shut[current + '#' + sec.dataset.sec]) fold(sec, true, false);
183
+ });
184
+ }
185
+
186
+ // a target inside folded sections is useless - open every ancestor first
187
+ function unfoldTo(node){
188
+ for (var p = node; p && p !== main; p = p.parentNode){
189
+ if (p.classList && p.classList.contains('shut')) fold(p, false, true);
190
+ }
191
+ }
192
+
193
+ var revealed = null; // the heading last jumped to, for sync() at the foot of the page
194
+ function reveal(id, smooth){
195
+ var t = document.getElementById(id);
196
+ if (!t) return false;
197
+ revealed = id;
198
+ unfoldTo(t);
199
+ t.scrollIntoView(smooth ? { behavior: 'smooth' } : undefined);
200
+ requestAnimationFrame(sync); // at the foot of the page nothing scrolls, so no scroll event would
201
+ return true;
202
+ }
203
+
204
+ /* ---------------- lightbox ---------------- */
205
+
206
+ var box = document.getElementById('lightbox');
207
+ var stage = document.getElementById('lightbox-stage');
208
+ var zoomLabel = document.getElementById('zoom-label');
209
+ var scale = 1, tx = 0, ty = 0, dragging = false, sx = 0, sy = 0, moved = false;
210
+
211
+ function apply(){
212
+ stage.style.transform = 'translate(-50%,-50%) translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
213
+ zoomLabel.textContent = Math.round(scale * 100) + '%';
214
+ }
215
+ function zoom(mult){ scale = Math.min(8, Math.max(.2, scale * mult)); apply(); }
216
+
217
+ // The diagram shown full size is a copy; it remembers the one on the page, so
218
+ // a change made from the copy (an entity opened) can be made to the original.
219
+ function stageCopy(svg){
220
+ stage.innerHTML = '';
221
+ var clone = svg.cloneNode(true);
222
+ clone.removeAttribute('style');
223
+ clone._source = svg;
224
+
225
+ // size from the viewBox: the rendered rect depends on the column it came from
226
+ var vb = (clone.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number);
227
+ var w = vb[2] || svg.getBoundingClientRect().width || 1;
228
+ var h = vb[3] || svg.getBoundingClientRect().height || 1;
229
+ clone.setAttribute('width', w);
230
+ clone.setAttribute('height', h);
231
+ stage.appendChild(clone);
232
+ return { svg: clone, w: w, h: h };
233
+ }
234
+
235
+ function openBox(svg){
236
+ var copy = stageCopy(svg);
237
+ box.classList.add('on');
238
+
239
+ // fit the whole diagram, but never so small it stops being readable:
240
+ // tall flowcharts open at a legible scale and are panned instead
241
+ var fitW = (innerWidth - 120) / copy.w, fitH = (innerHeight - 140) / copy.h;
242
+ scale = Math.min(2, Math.max(Math.min(fitW, fitH), Math.min(.75, fitW)));
243
+ tx = 0; ty = 0; apply();
244
+ }
245
+ function closeBox(){ box.classList.remove('on'); stage.innerHTML = ''; }
246
+ window.__lightbox = openBox;
247
+ // a redrawn diagram replaces the one on view, at the same zoom and pan...
248
+ openBox.swap = function(svg){
249
+ if (!box.classList.contains('on')) return null;
250
+ var copy = stageCopy(svg);
251
+ apply();
252
+ return copy.svg;
253
+ };
254
+ // ...which the caller then nudges, to keep what the reader was looking at in place
255
+ openBox.nudge = function(dx, dy){ tx += dx; ty += dy; apply(); };
256
+
257
+ // Dragging pans, but a plain click inside the diagram is the reader talking
258
+ // to the diagram (pinning an arrow), so only start a drag on empty space.
259
+ box.addEventListener('pointerdown', function(e){
260
+ if (e.target.closest('.lightbox-bar')) return;
261
+ if (e.target.closest('.dg-node,.dg-edge,.dg-label')) return;
262
+ dragging = true; moved = false; sx = e.clientX - tx; sy = e.clientY - ty;
263
+ box.classList.add('drag'); box.setPointerCapture(e.pointerId);
264
+ });
265
+ box.addEventListener('pointermove', function(e){
266
+ if (!dragging) return;
267
+ moved = true;
268
+ tx = e.clientX - sx; ty = e.clientY - sy; apply();
269
+ });
270
+ box.addEventListener('pointerup', function(){ dragging = false; box.classList.remove('drag'); });
271
+ box.addEventListener('wheel', function(e){ e.preventDefault(); zoom(e.deltaY < 0 ? 1.042 : 1 / 1.042); }, { passive: false });
272
+ document.getElementById('zoom-in').addEventListener('click', function(){ zoom(1.25); });
273
+ document.getElementById('zoom-out').addEventListener('click', function(){ zoom(1 / 1.25); });
274
+ document.getElementById('zoom-close').addEventListener('click', closeBox);
275
+
276
+ /* ---------------- events ---------------- */
277
+
278
+ document.addEventListener('click', function(e){
279
+ // A click on the diagram's own furniture opens it full size. A click on a
280
+ // node or an arrow belongs to the diagram - it pins that part - so it is
281
+ // left alone here.
282
+ var fig = e.target.closest('.diagram');
283
+ if (fig && !e.target.closest('svg.dg') && !e.target.closest('.diagram-head')){
284
+ var svg = fig.querySelector('svg');
285
+ if (svg) openBox(svg);
286
+ return;
287
+ }
288
+ var a = e.target.closest('a[href^="#"]');
289
+ if (a){
290
+ var href = a.getAttribute('href');
291
+ if (href.indexOf('#/') === 0) return;
292
+ e.preventDefault();
293
+ if (reveal(href.slice(1), true)) history.replaceState(null, '', '#/' + current + '/' + href.slice(1));
294
+ return;
295
+ }
296
+ // anywhere on a heading folds its section - unless the click was really the
297
+ // end of a drag that selected some of it
298
+ var head = e.target.closest('.sec-head');
299
+ if (!head || !head.parentNode.classList.contains('sec')) return;
300
+ var s = window.getSelection();
301
+ if (s && !s.isCollapsed && String(s).trim()) return;
302
+ var sec = head.parentNode;
303
+ fold(sec, !sec.classList.contains('shut'), true);
304
+ });
305
+
306
+ document.getElementById('toggle').addEventListener('click', function(){
307
+ var on = layout.classList.toggle('collapsed');
308
+ keep('collapsed', on ? '1' : '');
309
+ schedulePlace();
310
+ });
311
+
312
+ if (search) search.addEventListener('input', function(){
313
+ var q = search.value.trim().toLowerCase();
314
+ nav.querySelectorAll('.item').forEach(function(item){
315
+ var d = byId[item.dataset.doc];
316
+ var subs = nav.querySelector('[data-subs="' + d.id + '"]');
317
+ if (!q){
318
+ item.style.display = '';
319
+ item.classList.toggle('open', d.id === current);
320
+ subs.querySelectorAll('.sub').forEach(function(s){ s.style.display = ''; });
321
+ return;
322
+ }
323
+ var hitDoc = (d.title + ' ' + d.file).toLowerCase().indexOf(q) >= 0;
324
+ var hitSub = 0;
325
+ subs.querySelectorAll('.sub').forEach(function(s){
326
+ var on = s.textContent.toLowerCase().indexOf(q) >= 0;
327
+ s.style.display = (hitDoc || on) ? '' : 'none';
328
+ if (on) hitSub++;
329
+ });
330
+ item.style.display = (hitDoc || hitSub) ? '' : 'none';
331
+ item.classList.toggle('open', !!hitSub);
332
+ });
333
+ nav.querySelectorAll('.nav-group').forEach(function(g){ g.style.display = q ? 'none' : ''; });
334
+ });
335
+
336
+ document.addEventListener('keydown', function(e){
337
+ if (e.key === 'Escape'){
338
+ if (box.classList.contains('on')) return closeBox();
339
+ if (search && document.activeElement === search){
340
+ search.value = ''; search.dispatchEvent(new Event('input')); search.blur();
341
+ }
342
+ return;
343
+ }
344
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
345
+ if (e.key === '/' && search){ e.preventDefault(); search.focus(); }
346
+ if (e.key === '[') document.getElementById('toggle').click();
347
+ });
348
+
349
+ function sync(){
350
+ var head = main.querySelector('.head');
351
+ bar.classList.toggle('show', head ? head.getBoundingClientRect().bottom < 8 : false);
352
+ var y = scrollY + 120, at = null;
353
+ for (var i = 0; i < heads.length; i++){
354
+ if (!heads[i].offsetParent) continue; // inside a folded section
355
+ if (heads[i].offsetTop <= y) at = heads[i].id; else break;
356
+ }
357
+ // The last sections of a document can be too short to scroll up to the
358
+ // top bar. At the foot of the page the one jumped to is current if it is
359
+ // in view, and otherwise the last one is.
360
+ if (scrollY > 0 && scrollY + innerHeight >= document.documentElement.scrollHeight - 2){
361
+ var r = revealed && document.getElementById(revealed);
362
+ var box = r && r.offsetParent && r.getBoundingClientRect();
363
+ var seen = box && box.bottom > 0 && box.top < innerHeight;
364
+ var shown = heads.filter(function(h){ return h.offsetParent; });
365
+ if (seen) at = revealed;
366
+ else if (shown.length) at = shown[shown.length - 1].id;
367
+ }
368
+ var subs = nav.querySelector('[data-subs="' + current + '"]');
369
+ if (subs) subs.querySelectorAll('.sub').forEach(function(s){
370
+ s.classList.toggle('current', !!at && s.getAttribute('href') === '#/' + current + '/' + at);
371
+ });
372
+ }
373
+
374
+ var ticking = false;
375
+ addEventListener('scroll', function(){
376
+ if (ticking) return;
377
+ ticking = true;
378
+ requestAnimationFrame(function(){ ticking = false; sync(); });
379
+ }, { passive: true });
380
+
381
+ addEventListener('hashchange', route);
382
+
383
+ /* ---------------- comments ---------------- */
384
+ // Selected text is stored as a quote plus a little context either side, so a
385
+ // comment can find its home again after the document is edited and re-rendered.
386
+
387
+ var CMT_ON = CFG.comments !== false;
388
+ var CARD_MAX = 264, CARD_MIN = 176, CARD_GAP = 10, EDGE = 12;
389
+ var comments = readComments();
390
+ var cmtOn = CMT_ON && recall('cmt-off') !== '1';
391
+ var pending = null, warned = false, ro = null, placeTick = false;
392
+ var addBtn = document.getElementById('cmt-add');
393
+ var pop = document.getElementById('cmt-pop');
394
+ var toastEl = document.getElementById('cmt-toast'), toastT = 0;
395
+
396
+ function readComments(){
397
+ try {
398
+ var raw = recall('comments');
399
+ if (!raw) return [];
400
+ var o = JSON.parse(raw);
401
+ var list = Array.isArray(o) ? o : (o && o.comments) || [];
402
+ return list.filter(function(c){ return c && c.id && c.doc && c.quote && c.body; });
403
+ } catch (e) { return []; }
404
+ }
405
+
406
+ function writeComments(){
407
+ keep('comments', JSON.stringify({ version: 1, comments: comments }));
408
+ if (!warned && comments.length && recall('comments') === null){
409
+ warned = true;
410
+ toast('This browser refuses to store anything — comments made here will be gone when the page closes.');
411
+ }
412
+ paintCount();
413
+ }
414
+
415
+ function paintCount(){
416
+ var n = document.getElementById('cmt-n');
417
+ if (n) n.textContent = comments.length;
418
+ }
419
+
420
+ function toast(msg){
421
+ if (!toastEl) return;
422
+ toastEl.textContent = msg;
423
+ toastEl.hidden = false;
424
+ clearTimeout(toastT);
425
+ toastT = setTimeout(function(){ toastEl.hidden = true; }, 4200);
426
+ }
427
+
428
+ function uid(){ return 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
429
+ function forDoc(id){ return comments.filter(function(c){ return c.doc === id; }); }
430
+ function layerEl(){ return main.querySelector('.cmt-layer'); }
431
+
432
+ function when(ts){
433
+ if (!ts) return '';
434
+ var d = new Date(ts);
435
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' +
436
+ d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
437
+ }
438
+
439
+ /* --- text addressing --- */
440
+
441
+ function flatten(root){
442
+ var walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
443
+ acceptNode: function(n){
444
+ if (!n.nodeValue) return NodeFilter.FILTER_REJECT;
445
+ for (var p = n.parentNode; p && p !== root; p = p.parentNode){
446
+ var tag = (p.nodeName || '').toUpperCase();
447
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'SVG') return NodeFilter.FILTER_REJECT;
448
+ if (p.classList && p.classList.contains('diagram-src')) return NodeFilter.FILTER_REJECT;
449
+ }
450
+ return NodeFilter.FILTER_ACCEPT;
451
+ }
452
+ });
453
+ var nodes = [], starts = [], text = '', n;
454
+ while ((n = walk.nextNode())){ nodes.push(n); starts.push(text.length); text += n.nodeValue; }
455
+ return { nodes: nodes, starts: starts, text: text };
456
+ }
457
+
458
+ function posOf(flat, node, off){
459
+ if (node.nodeType !== 3){
460
+ var kid = node.childNodes[off] || node.lastChild;
461
+ while (kid && kid.nodeType !== 3) kid = kid.firstChild || kid.nextSibling;
462
+ if (!kid) return -1;
463
+ node = kid; off = 0;
464
+ }
465
+ var i = flat.nodes.indexOf(node);
466
+ return i < 0 ? -1 : flat.starts[i] + off;
467
+ }
468
+
469
+ function sectionAt(node){
470
+ var body = main.querySelector('.doc');
471
+ var here = node.nodeType === 3 ? node.parentNode : node;
472
+ var hs = [].slice.call(body.querySelectorAll('h1[id],h2[id],h3[id]'));
473
+ var found = '';
474
+ for (var i = 0; i < hs.length; i++){
475
+ if (hs[i].compareDocumentPosition(here) & Node.DOCUMENT_POSITION_FOLLOWING) found = hs[i].id;
476
+ }
477
+ return found;
478
+ }
479
+
480
+ function capture(){
481
+ var s = window.getSelection();
482
+ if (!s || s.isCollapsed || !s.rangeCount) return null;
483
+ var body = main.querySelector('.doc');
484
+ if (!body) return null;
485
+ var r = s.getRangeAt(0);
486
+ if (!body.contains(r.startContainer) || !body.contains(r.endContainer)) return null;
487
+ var host = r.startContainer.nodeType === 3 ? r.startContainer.parentNode : r.startContainer;
488
+ if (host.closest && host.closest('.diagram')) return null;
489
+ var flat = flatten(body);
490
+ var a = posOf(flat, r.startContainer, r.startOffset);
491
+ var b = posOf(flat, r.endContainer, r.endOffset);
492
+ if (a < 0 || b < 0 || b <= a) return null;
493
+ var quote = flat.text.slice(a, b).trim();
494
+ if (quote.length < 2 || quote.length > 600) return null;
495
+ a = flat.text.indexOf(quote, Math.max(0, a - 8));
496
+ if (a < 0) return null;
497
+ b = a + quote.length;
498
+ return {
499
+ doc: current, quote: quote,
500
+ prefix: flat.text.slice(Math.max(0, a - 48), a),
501
+ suffix: flat.text.slice(b, b + 48),
502
+ section: sectionAt(r.startContainer),
503
+ rect: r.getBoundingClientRect()
504
+ };
505
+ }
506
+
507
+ // quote plus context first, then progressively looser fallbacks
508
+ function locate(flat, c){
509
+ var t = flat.text, at;
510
+ if (c.prefix){
511
+ at = t.indexOf(c.prefix + c.quote + (c.suffix || ''));
512
+ if (at < 0) at = t.indexOf(c.prefix + c.quote);
513
+ if (at >= 0) return [at + c.prefix.length, at + c.prefix.length + c.quote.length];
514
+ }
515
+ if (c.suffix){
516
+ at = t.indexOf(c.quote + c.suffix);
517
+ if (at >= 0) return [at, at + c.quote.length];
518
+ }
519
+ at = t.indexOf(c.quote);
520
+ return at >= 0 ? [at, at + c.quote.length] : null;
521
+ }
522
+
523
+ function paintRange(flat, from, to, id){
524
+ var made = [];
525
+ for (var i = flat.nodes.length - 1; i >= 0; i--){
526
+ var node = flat.nodes[i], s = flat.starts[i], e = s + node.nodeValue.length;
527
+ if (e <= from || s >= to) continue;
528
+ var a = Math.max(from, s) - s, b = Math.min(to, e) - s;
529
+ if (b < node.nodeValue.length) node.splitText(b);
530
+ if (a > 0) node = node.splitText(a);
531
+ var m = document.createElement('mark');
532
+ m.className = 'cmt-hl';
533
+ m.dataset.cmt = id;
534
+ node.parentNode.insertBefore(m, node);
535
+ m.appendChild(node);
536
+ made.unshift(m);
537
+ }
538
+ return made;
539
+ }
540
+
541
+ /* --- rendering --- */
542
+
543
+ function renderComments(){
544
+ if (!CMT_ON) return;
545
+ var body = main.querySelector('.doc'), layer = layerEl();
546
+ if (!body || !layer) return;
547
+
548
+ [].slice.call(body.querySelectorAll('mark.cmt-hl')).forEach(function(m){
549
+ var p = m.parentNode;
550
+ while (m.firstChild) p.insertBefore(m.firstChild, m);
551
+ p.removeChild(m);
552
+ });
553
+ body.normalize();
554
+ layer.textContent = '';
555
+ layer.hidden = !cmtOn;
556
+ if (!cmtOn) return;
557
+
558
+ var list = forDoc(current);
559
+ if (!list.length) return;
560
+
561
+ var flat = flatten(body);
562
+ var found = [], lost = [];
563
+ list.forEach(function(c){
564
+ var at = locate(flat, c);
565
+ if (at) found.push({ c: c, from: at[0] }); else lost.push(c);
566
+ });
567
+ found.sort(function(x, y){ return y.from - x.from; });
568
+ found.forEach(function(f){
569
+ var at = locate(flat, f.c);
570
+ f.marks = at ? paintRange(flat, at[0], at[1], f.c.id) : [];
571
+ });
572
+ found.sort(function(x, y){ return x.from - y.from; });
573
+
574
+ var n = 0;
575
+ lost.forEach(function(c){ layer.appendChild(cardFor(c, null, ++n)); });
576
+ found.forEach(function(f){ layer.appendChild(cardFor(f.c, f.marks[0], ++n)); });
577
+ place();
578
+ }
579
+
580
+ function cardFor(c, mark, n){
581
+ var card = el('div', 'cmt-card');
582
+ card.dataset.cmt = c.id;
583
+ card._mark = mark || null;
584
+ card.appendChild(el('span', 'cmt-face', String(n)));
585
+
586
+ var full = el('div', 'cmt-full');
587
+ if (!mark) full.appendChild(el('div', 'cmt-lost', 'text not found'));
588
+ full.appendChild(el('div', 'cmt-quote', c.quote));
589
+ full.appendChild(el('div', 'cmt-text', c.body));
590
+ var foot = el('div', 'cmt-foot');
591
+ foot.appendChild(el('span', 'cmt-when', when(c.updated || c.created)));
592
+ var edit = el('button', 'cmt-act', 'Edit');
593
+ var del = el('button', 'cmt-act', 'Delete');
594
+ foot.appendChild(edit);
595
+ foot.appendChild(del);
596
+ full.appendChild(foot);
597
+ card.appendChild(full);
598
+
599
+ edit.addEventListener('click', function(e){
600
+ e.stopPropagation();
601
+ openEdit(c, (card._mark || card).getBoundingClientRect());
602
+ });
603
+ del.addEventListener('click', function(e){ e.stopPropagation(); armDelete(del, c); });
604
+ card.addEventListener('click', function(){
605
+ focusCmt(c.id, true);
606
+ if (layerEl().classList.contains('dots')) openRead(c, (card._mark || card).getBoundingClientRect());
607
+ });
608
+ return card;
609
+ }
610
+
611
+ // the gutter with more room wins; too tight for a readable card and it becomes a dot
612
+ function place(){
613
+ var wrap = main.querySelector('.body-wrap'), body = main.querySelector('.doc');
614
+ var layer = layerEl();
615
+ if (!wrap || !body || !layer || !cmtOn) return;
616
+ var cards = [].slice.call(layer.children);
617
+ if (!cards.length) return;
618
+ cards.forEach(function(card){ card.hidden = !!(card._mark && !card._mark.offsetParent); });
619
+ cards = cards.filter(function(card){ return !card.hidden; });
620
+ if (!cards.length) return;
621
+
622
+ var wr = wrap.getBoundingClientRect(), dr = body.getBoundingClientRect();
623
+ var roomL = dr.left - wr.left, roomR = wr.right - dr.right;
624
+ var side = roomR >= roomL ? 'right' : 'left';
625
+ var room = Math.max(roomL, roomR) - EDGE * 2;
626
+ var dots = room < CARD_MIN;
627
+ var w = dots ? 22 : Math.floor(Math.min(CARD_MAX, room));
628
+ layer.classList.toggle('dots', dots);
629
+ layer.style.setProperty('--cmt-w', w + 'px');
630
+
631
+ var x = side === 'right' ? (dr.right - wr.left + EDGE) : (dr.left - wr.left - EDGE - w);
632
+ var floor = -1e9;
633
+ cards.forEach(function(card){
634
+ var top = card._mark ? (card._mark.getBoundingClientRect().top - wr.top) : 0;
635
+ if (top < floor) top = floor;
636
+ card.style.left = Math.round(x) + 'px';
637
+ card.style.top = Math.round(top) + 'px';
638
+ floor = top + card.offsetHeight + CARD_GAP;
639
+ });
640
+ }
641
+
642
+ function schedulePlace(){
643
+ if (placeTick) return;
644
+ placeTick = true;
645
+ requestAnimationFrame(function(){ placeTick = false; place(); });
646
+ }
647
+
648
+ function watch(){
649
+ if (!ro) return;
650
+ ro.disconnect();
651
+ var wrap = main.querySelector('.body-wrap');
652
+ if (wrap) ro.observe(wrap);
653
+ }
654
+
655
+ function focusCmt(id, scroll){
656
+ main.querySelectorAll('.cmt-card').forEach(function(c){ c.classList.toggle('on', c.dataset.cmt === id); });
657
+ main.querySelectorAll('mark.cmt-hl').forEach(function(m){ m.classList.toggle('on', m.dataset.cmt === id); });
658
+ if (!scroll) return;
659
+ var m = main.querySelector('mark.cmt-hl[data-cmt="' + id + '"]');
660
+ if (!m) return;
661
+ unfoldTo(m);
662
+ var r = m.getBoundingClientRect();
663
+ if (r.top < 70 || r.bottom > innerHeight - 20) m.scrollIntoView({ block: 'center' });
664
+ }
665
+
666
+ // a highlight and its card are one comment seen twice: hovering either lights
667
+ // both, so the link between the margin and the sentence is never a guess
668
+ var hotId = null;
669
+ function hot(id){
670
+ if (id === hotId) return;
671
+ hotId = id;
672
+ main.querySelectorAll('.cmt-card').forEach(function(c){ c.classList.toggle('hot', !!id && c.dataset.cmt === id); });
673
+ main.querySelectorAll('mark.cmt-hl').forEach(function(m){ m.classList.toggle('hot', !!id && m.dataset.cmt === id); });
674
+ }
675
+
676
+ /* --- the floating button and popover --- */
677
+
678
+ function showAdd(rect){
679
+ addBtn.hidden = false;
680
+ var w = addBtn.offsetWidth;
681
+ var left = Math.min(Math.max(8, rect.left), innerWidth - w - 8);
682
+ var top = rect.bottom + 8;
683
+ if (top > innerHeight - 40) top = Math.max(8, rect.top - 34);
684
+ addBtn.style.left = Math.round(left) + 'px';
685
+ addBtn.style.top = Math.round(top) + 'px';
686
+ }
687
+ function hideAdd(){ if (addBtn) addBtn.hidden = true; }
688
+
689
+ function placePop(rect){
690
+ pop.hidden = false;
691
+ var w = pop.offsetWidth, h = pop.offsetHeight;
692
+ var left = rect.left, top = rect.bottom + 8;
693
+ if (top + h > innerHeight - 10) top = Math.max(10, rect.top - h - 8);
694
+ if (left + w > innerWidth - 10) left = innerWidth - w - 10;
695
+ pop.style.left = Math.round(Math.max(10, left)) + 'px';
696
+ pop.style.top = Math.round(top) + 'px';
697
+ }
698
+ function closePop(){ if (pop){ pop.hidden = true; pop.textContent = ''; } }
699
+
700
+ function editor(c, cap, rect){
701
+ pop.textContent = '';
702
+ pop.appendChild(el('div', 'cmt-quote', (c || cap).quote));
703
+ var ta = document.createElement('textarea');
704
+ ta.placeholder = 'Add a comment';
705
+ ta.value = c ? c.body : '';
706
+ pop.appendChild(ta);
707
+ var row = el('div', 'cmt-row');
708
+ row.appendChild(el('span', 'cmt-spacer'));
709
+ var cancel = el('button', 'cmt-btn', 'Cancel');
710
+ var save = el('button', 'cmt-btn go', c ? 'Save' : 'Comment');
711
+ row.appendChild(cancel);
712
+ row.appendChild(save);
713
+ pop.appendChild(row);
714
+ placePop(rect);
715
+ ta.focus();
716
+ ta.setSelectionRange(ta.value.length, ta.value.length);
717
+
718
+ cancel.addEventListener('click', closePop);
719
+ save.addEventListener('click', function(){
720
+ var text = ta.value.trim();
721
+ if (!text) { ta.focus(); return; }
722
+ if (c){
723
+ c.body = text;
724
+ c.updated = Date.now();
725
+ } else {
726
+ comments.push({ id: uid(), doc: cap.doc, quote: cap.quote, prefix: cap.prefix,
727
+ suffix: cap.suffix, section: cap.section, body: text,
728
+ created: Date.now(), updated: Date.now() });
729
+ }
730
+ writeComments();
731
+ closePop();
732
+ hideAdd();
733
+ var s = window.getSelection();
734
+ if (s) s.removeAllRanges();
735
+ renderComments();
736
+ });
737
+ ta.addEventListener('keydown', function(e){
738
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save.click();
739
+ });
740
+ }
741
+
742
+ function openEdit(c, rect){ editor(c, null, rect); }
743
+
744
+ function openRead(c, rect){
745
+ pop.textContent = '';
746
+ pop.appendChild(el('div', 'cmt-quote', c.quote));
747
+ pop.appendChild(el('div', 'cmt-text', c.body));
748
+ var foot = el('div', 'cmt-foot');
749
+ foot.appendChild(el('span', 'cmt-when', when(c.updated || c.created)));
750
+ var edit = el('button', 'cmt-act', 'Edit');
751
+ var del = el('button', 'cmt-act', 'Delete');
752
+ foot.appendChild(edit);
753
+ foot.appendChild(del);
754
+ pop.appendChild(foot);
755
+ placePop(rect);
756
+ edit.addEventListener('click', function(){ openEdit(c, rect); });
757
+ del.addEventListener('click', function(){ armDelete(del, c); });
758
+ }
759
+
760
+ // two taps rather than a blocking confirm dialog
761
+ function armDelete(btn, c){
762
+ if (btn.dataset.armed){
763
+ comments = comments.filter(function(x){ return x.id !== c.id; });
764
+ writeComments();
765
+ closePop();
766
+ renderComments();
767
+ return;
768
+ }
769
+ btn.dataset.armed = '1';
770
+ btn.textContent = 'Sure?';
771
+ setTimeout(function(){
772
+ if (!btn.isConnected) return;
773
+ delete btn.dataset.armed;
774
+ btn.textContent = 'Delete';
775
+ }, 3000);
776
+ }
777
+
778
+ /* --- wiring --- */
779
+
780
+ function near(e, s){ var t = e.target; return t && t.closest ? t.closest(s) : null; }
781
+
782
+ if (CMT_ON){
783
+ document.addEventListener('mouseup', function(e){
784
+ if (near(e, '#cmt-add') || near(e, '.cmt-pop') || near(e, '.cmt-card')) return;
785
+ setTimeout(function(){
786
+ if (!cmtOn) return;
787
+ var cap = capture();
788
+ if (!cap){ hideAdd(); return; }
789
+ pending = cap;
790
+ showAdd(cap.rect);
791
+ }, 0);
792
+ });
793
+
794
+ document.addEventListener('mousedown', function(e){
795
+ if (near(e, '#cmt-add') || near(e, '.cmt-pop')) return;
796
+ hideAdd();
797
+ closePop();
798
+ });
799
+
800
+ addBtn.addEventListener('click', function(){
801
+ if (!pending) return;
802
+ hideAdd();
803
+ editor(null, pending, pending.rect);
804
+ });
805
+
806
+ document.addEventListener('mouseover', function(e){
807
+ var t = near(e, 'mark.cmt-hl') || near(e, '.cmt-card');
808
+ hot(t ? t.dataset.cmt : null);
809
+ });
810
+
811
+ document.addEventListener('click', function(e){
812
+ var m = near(e, 'mark.cmt-hl');
813
+ if (!m) return;
814
+ var c = comments.filter(function(x){ return x.id === m.dataset.cmt; })[0];
815
+ if (!c) return;
816
+ focusCmt(c.id, false);
817
+ if (layerEl().classList.contains('dots')) openRead(c, m.getBoundingClientRect());
818
+ });
819
+
820
+ document.addEventListener('keydown', function(e){
821
+ if (e.key !== 'Escape' || pop.hidden) return;
822
+ closePop();
823
+ hideAdd();
824
+ e.stopImmediatePropagation();
825
+ }, true);
826
+
827
+ document.getElementById('cmt-tog').addEventListener('click', function(){
828
+ cmtOn = !cmtOn;
829
+ keep('cmt-off', cmtOn ? '' : '1');
830
+ this.setAttribute('aria-checked', cmtOn);
831
+ closePop();
832
+ hideAdd();
833
+ renderComments();
834
+ });
835
+ document.getElementById('cmt-tog').setAttribute('aria-checked', cmtOn);
836
+ paintCount();
837
+ }
838
+
839
+ addEventListener('resize', schedulePlace);
840
+ if (typeof ResizeObserver !== 'undefined') ro = new ResizeObserver(schedulePlace);
841
+
842
+ /* ---------------- live update ---------------- */
843
+ // Only the page served by lowkey-doc --watch calls this; one opened from disk
844
+ // never does. New documents replace old ones one by one: the sidebar is
845
+ // rebuilt only if something it shows moved, and the document being read is
846
+ // patched rather than opened again - same place on the page, same folds, same
847
+ // comments, and a diagram whose source did not change is carried over as
848
+ // drawn instead of being laid out again. What did change is marked.
849
+
850
+ var BLOCKS = 'p,li,h1,h2,h3,h4,h5,h6,pre,table,blockquote,figure,img,hr';
851
+ var MARK_SHARE = .5; // past half the page changed, marking all of it says nothing
852
+ var TOP_EDGE = 64; // below the sticky top bar
853
+ var hold = null;
854
+
855
+ function applyUpdate(next){
856
+ var old = byId, before = docs;
857
+ var navOf = function(list){
858
+ return JSON.stringify(list.map(function(d){ return [d.id, d.title, d.badge, d.group, d.file, d.sections]; }));
859
+ };
860
+ var navMoved = navOf(before) !== navOf(next);
861
+
862
+ docs = next;
863
+ byId = {};
864
+ docs.forEach(function(d){ byId[d.id] = d; });
865
+ var changed = docs.filter(function(d){
866
+ return !old[d.id] || JSON.stringify(old[d.id]) !== JSON.stringify(d);
867
+ }).map(function(d){ return d.id; });
868
+ var removed = before.filter(function(d){ return !byId[d.id]; }).map(function(d){ return d.id; });
869
+
870
+ // other documents are built again when next opened; when the order or a
871
+ // title moved, their previous / next links are stale too
872
+ Object.keys(cache).forEach(function(id){
873
+ if (id !== current && (navMoved || !byId[id] || changed.indexOf(id) >= 0)) delete cache[id];
874
+ });
875
+ if (navMoved) rebuildNav();
876
+
877
+ var r = { changed: changed, removed: removed, current: current, count: 0, first: null };
878
+ if (!byId[current]){
879
+ // a rename is one document gone and one arrived: follow it
880
+ var added = changed.filter(function(id){ return !old[id]; });
881
+ var to = removed.length === 1 && added.length === 1 ? added[0] : docs[0].id;
882
+ delete cache[current];
883
+ current = null;
884
+ history.replaceState(null, '', '#/' + to);
885
+ show(to);
886
+ r.current = to;
887
+ r.moved = true;
888
+ return r;
889
+ }
890
+ if (navMoved || changed.indexOf(current) >= 0) patch(old[current], byId[current], r);
891
+ return r;
892
+ }
893
+
894
+ function rebuildNav(){
895
+ var top = nav.scrollTop, open = {};
896
+ nav.querySelectorAll('.item.open').forEach(function(n){ open[n.dataset.doc] = 1; });
897
+ nav.textContent = '';
898
+ buildNav();
899
+ nav.querySelectorAll('.item').forEach(function(n){
900
+ n.classList.toggle('active', n.dataset.doc === current);
901
+ n.classList.toggle('open', !!open[n.dataset.doc] || n.dataset.doc === current);
902
+ });
903
+ if (search && search.value) search.dispatchEvent(new Event('input'));
904
+ nav.scrollTop = top;
905
+ }
906
+
907
+ function patch(was, d, r){
908
+ var from = main.querySelector('.body-wrap');
909
+ var at = anchorPoint();
910
+ delete cache[d.id];
911
+ var to = bodyFor(d);
912
+ var marks = changedBlocks(was, d, to);
913
+ carryDiagrams(from, to);
914
+
915
+ main.textContent = '';
916
+ main.appendChild(to);
917
+ heads = [].slice.call(main.querySelectorAll('h1[id],h2[id]'));
918
+ barTitle.textContent = d.title;
919
+ document.title = d.title + ' — ' + CFG.title;
920
+ restoreFolds(main);
921
+ if (window.__diagrams) window.__diagrams.drawAll(main);
922
+ renderComments();
923
+ watch();
924
+ keepPlace(at);
925
+
926
+ r.count = marks.length;
927
+ r.first = marks[0] || null;
928
+ if (marks.length <= Math.max(3, MARK_SHARE * to.querySelectorAll(BLOCKS).length)){
929
+ marks.forEach(function(m){ m.classList.add('dc-changed'); });
930
+ }
931
+ requestAnimationFrame(sync);
932
+ }
933
+
934
+ // Blocks of the new copy that were not in the old one, compared as markup
935
+ // before anything (diagrams, comments) is drawn into either. Only the
936
+ // innermost is kept: an edited list item marks the item, not the list.
937
+ function changedBlocks(was, d, holder){
938
+ var out = [];
939
+ var head = holder.querySelector('.head');
940
+ if (was.title !== d.title) out.push(head.querySelector('h1'));
941
+ if (was.subtitle !== d.subtitle && head.querySelector('p')) out.push(head.querySelector('p'));
942
+
943
+ var body = holder.querySelector('.doc');
944
+ var t = document.createElement('template');
945
+ t.innerHTML = was.html;
946
+ var left = {};
947
+ [].slice.call(t.content.querySelectorAll(BLOCKS)).forEach(function(n){
948
+ left[n.outerHTML] = (left[n.outerHTML] || 0) + 1;
949
+ });
950
+ var hits = [].slice.call(body.querySelectorAll(BLOCKS)).filter(function(n){
951
+ if (!left[n.outerHTML]) return true;
952
+ left[n.outerHTML]--;
953
+ return false;
954
+ }).filter(function(n){
955
+ // a diagram's source is hidden; the diagram itself is what changed
956
+ return n.tagName === 'FIGURE' || !n.closest('figure');
957
+ });
958
+ var outer = new Set();
959
+ hits.forEach(function(n){
960
+ for (var p = n.parentNode; p && p !== body; p = p.parentNode) outer.add(p);
961
+ });
962
+ return out.concat(hits.filter(function(n){ return !outer.has(n); }));
963
+ }
964
+
965
+ function srcOf(fig){
966
+ var s = fig.querySelector('.diagram-src');
967
+ return s ? s.textContent : '';
968
+ }
969
+
970
+ function carryDiagrams(from, to){
971
+ if (!from) return;
972
+ var olds = [].slice.call(from.querySelectorAll('figure[data-diagram]'));
973
+ var pool = {}, used = new Set(), fresh = [];
974
+ olds.forEach(function(f){ (pool[srcOf(f)] = pool[srcOf(f)] || []).push(f); });
975
+ [].slice.call(to.querySelectorAll('figure[data-diagram]')).forEach(function(f){
976
+ var same = pool[srcOf(f)];
977
+ if (!same || !same.length) return fresh.push(f);
978
+ var o = same.shift();
979
+ used.add(o);
980
+ f.parentNode.replaceChild(o, f);
981
+ });
982
+
983
+ // Edited diagrams, when they line up one for one with the ones they
984
+ // replace: the old drawing stays up, faded, until the new one is ready -
985
+ // no collapse and regrow under the reader - and a layout switched, or an
986
+ // entity opened, from the page stays that way.
987
+ var stale = olds.filter(function(f){ return !used.has(f); });
988
+ if (stale.length !== fresh.length) return;
989
+ fresh.forEach(function(f, i){
990
+ var o = stale[i];
991
+ if (o.dataset.engine) f.dataset.engine = o.dataset.engine;
992
+ if (o._open) f._open = o._open;
993
+ var out = f.querySelector('.diagram-out'), prev = o.querySelector('.diagram-out');
994
+ if (!out || !prev || !prev.firstChild) return;
995
+ while (prev.firstChild) out.appendChild(prev.firstChild);
996
+ f.classList.add('dc-redraw');
997
+ if (typeof MutationObserver === 'undefined') return;
998
+ new MutationObserver(function(_, mo){
999
+ f.classList.remove('dc-redraw');
1000
+ mo.disconnect();
1001
+ }).observe(out, { childList: true });
1002
+ });
1003
+ }
1004
+
1005
+ // What the reader is looking at: the first visible block under the top bar,
1006
+ // remembered by its text so the same block can be found in the new copy,
1007
+ // plus the heading of its section in case that block is the one edited.
1008
+ function blockKey(n){
1009
+ return n.tagName + '\n' + (n.tagName === 'FIGURE' ? srcOf(n) : n.textContent);
1010
+ }
1011
+
1012
+ function anchorPoint(){
1013
+ var body = main.querySelector('.doc');
1014
+ if (!body || scrollY < 4) return null;
1015
+ var list = [].slice.call(body.querySelectorAll(BLOCKS));
1016
+ for (var i = 0; i < list.length; i++){
1017
+ if (!list[i].offsetParent || list[i].getBoundingClientRect().bottom <= TOP_EDGE) continue;
1018
+ var pick = list[i];
1019
+ for (var j = i + 1; j < list.length && pick.contains(list[j]); j++){
1020
+ if (list[j].offsetParent && list[j].getBoundingClientRect().bottom > TOP_EDGE) pick = list[j];
1021
+ }
1022
+ var key = blockKey(pick), nth = 0;
1023
+ for (var k = 0; list[k] !== pick; k++) if (list[k].tagName === pick.tagName && blockKey(list[k]) === key) nth++;
1024
+ var sec = pick.closest('.sec');
1025
+ var h = sec && document.getElementById(sec.dataset.sec);
1026
+ return {
1027
+ key: key, tag: pick.tagName, nth: nth, top: pick.getBoundingClientRect().top,
1028
+ head: h ? h.id : null, headTop: h ? h.getBoundingClientRect().top : 0
1029
+ };
1030
+ }
1031
+ return null;
1032
+ }
1033
+
1034
+ function findAnchor(at){
1035
+ var list = main.querySelectorAll('.doc ' + BLOCKS.split(',').join(',.doc '));
1036
+ for (var i = 0, n = 0; i < list.length; i++){
1037
+ if (list[i].tagName !== at.tag || blockKey(list[i]) !== at.key) continue;
1038
+ if (n++ !== at.nth) continue;
1039
+ if (list[i].offsetParent) return { el: list[i], top: at.top };
1040
+ break;
1041
+ }
1042
+ var h = at.head && document.getElementById(at.head);
1043
+ return h && main.contains(h) && h.offsetParent ? { el: h, top: at.headTop } : null;
1044
+ }
1045
+
1046
+ // Put that block back where it was, and keep it there while edited diagrams
1047
+ // finish drawing and change the height of what is above it - until the reader
1048
+ // scrolls, clicks or types, which means they have moved on.
1049
+ function keepPlace(at){
1050
+ if (hold) hold.stop();
1051
+ if (!at) return;
1052
+ var target = findAnchor(at);
1053
+ if (!target) return;
1054
+ var until = Date.now() + 4000, stopped = false;
1055
+ var kinds = ['wheel', 'touchstart', 'keydown', 'mousedown'];
1056
+ var stop = function(){
1057
+ stopped = true;
1058
+ kinds.forEach(function(k){ removeEventListener(k, stop, true); });
1059
+ hold = null;
1060
+ };
1061
+ kinds.forEach(function(k){ addEventListener(k, stop, true); });
1062
+ hold = { stop: stop };
1063
+ (function align(){
1064
+ if (stopped) return;
1065
+ if (Date.now() > until || !target.el.isConnected) return stop();
1066
+ var drift = target.el.getBoundingClientRect().top - target.top;
1067
+ if (Math.abs(drift) >= 1) window.scrollBy(0, drift);
1068
+ requestAnimationFrame(align);
1069
+ })();
1070
+ }
1071
+
1072
+ function bring(n){
1073
+ if (!n || !n.isConnected) return;
1074
+ if (hold) hold.stop();
1075
+ unfoldTo(n);
1076
+ n.scrollIntoView({ behavior: 'smooth', block: 'center' });
1077
+ n.classList.remove('dc-changed');
1078
+ void n.offsetWidth;
1079
+ n.classList.add('dc-changed');
1080
+ }
1081
+
1082
+ /* ---------------- settings ----------------
1083
+ The panel changes the page as the reader moves a control, so the effect is
1084
+ judged on the documents themselves. Keeping a change is not the page's to
1085
+ do - a page opened from its file cannot write the project's config - so it
1086
+ hands the settings to whoever can: lowkey-doc --watch sets app.save. */
1087
+
1088
+ var setBox = document.getElementById('settings');
1089
+ var setBtn = document.getElementById('settings-btn');
1090
+ var setVeil = document.getElementById('settings-veil');
1091
+ var setMarginIn = document.getElementById('set-margin');
1092
+ var setMarginVal = document.getElementById('set-margin-val');
1093
+ var setMarginAuto = document.getElementById('set-margin-auto');
1094
+ var setStatus = document.getElementById('settings-status');
1095
+ var settings = { margin: CFG.margin == null ? null : CFG.margin };
1096
+ var saveTimer = 0, saved = JSON.stringify(settings);
1097
+
1098
+ function applyMargin(px){
1099
+ var root = document.documentElement;
1100
+ root.classList.toggle('fixed-margin', px != null);
1101
+ if (px == null) root.style.removeProperty('--margin');
1102
+ else root.style.setProperty('--margin', px + 'px');
1103
+ schedulePlace();
1104
+ }
1105
+
1106
+ // the room there is now, from the edge of the reading area to the text
1107
+ function marginNow(){
1108
+ var col = main.querySelector('.doc') || main.querySelector('.head') || main.querySelector('.empty');
1109
+ if (!col) return Number(setMarginIn.min);
1110
+ var pad = parseFloat(getComputedStyle(col).paddingLeft) || 0;
1111
+ return Math.round(col.getBoundingClientRect().left - main.getBoundingClientRect().left + pad);
1112
+ }
1113
+
1114
+ function showSettings(){
1115
+ var auto = settings.margin == null;
1116
+ setMarginIn.value = auto ? marginNow() : settings.margin;
1117
+ setMarginVal.textContent = auto ? 'Automatic · ' + setMarginIn.value + ' px' : settings.margin + ' px';
1118
+ setMarginAuto.disabled = auto;
1119
+ }
1120
+
1121
+ function status(html, bad){
1122
+ setStatus.className = 'settings-foot' + (bad ? ' bad' : '');
1123
+ setStatus.innerHTML = html;
1124
+ }
1125
+ function idleStatus(){
1126
+ status(app.save ? 'Changes are saved to the project, in <code>docs.config.json</code>.'
1127
+ : 'Preview only: a page opened from its file cannot change the project. ' +
1128
+ 'Run <code>lowkey-doc --watch</code> to save changes.');
1129
+ }
1130
+
1131
+ function changed(){
1132
+ applyMargin(settings.margin);
1133
+ showSettings();
1134
+ if (!app.save) return;
1135
+ clearTimeout(saveTimer);
1136
+ status('Saving…');
1137
+ // a slider sends a value for every step; the project is written once it rests
1138
+ saveTimer = setTimeout(function(){
1139
+ var sent = JSON.stringify(settings);
1140
+ if (sent === saved) return idleStatus();
1141
+ app.save(JSON.parse(sent)).then(function(){
1142
+ saved = sent;
1143
+ status('Saved to <code>docs.config.json</code>.');
1144
+ }, function(err){
1145
+ status('Not saved: ' + escText(err && err.message || err), true);
1146
+ });
1147
+ }, 450);
1148
+ }
1149
+ function escText(s){ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
1150
+
1151
+ function openSettings(){
1152
+ showSettings();
1153
+ idleStatus();
1154
+ setBox.hidden = setVeil.hidden = false;
1155
+ setBtn.setAttribute('aria-expanded', 'true');
1156
+ setMarginIn.focus();
1157
+ }
1158
+ function closeSettings(){ setBox.hidden = setVeil.hidden = true; setBtn.setAttribute('aria-expanded', 'false'); }
1159
+
1160
+ setBtn.addEventListener('click', function(){ if (setBox.hidden) openSettings(); else closeSettings(); });
1161
+ document.getElementById('settings-close').addEventListener('click', closeSettings);
1162
+ setMarginIn.addEventListener('input', function(){ settings.margin = Number(setMarginIn.value); changed(); });
1163
+ setMarginAuto.addEventListener('click', function(){ settings.margin = null; changed(); });
1164
+ setVeil.addEventListener('mousedown', closeSettings);
1165
+ // Esc closes the panel before it does anything else on the page
1166
+ document.addEventListener('keydown', function(e){
1167
+ if (e.key !== 'Escape' || setBox.hidden) return;
1168
+ e.stopImmediatePropagation();
1169
+ closeSettings();
1170
+ setBtn.focus();
1171
+ }, true);
1172
+
1173
+ var app = window.__lowkey_doc = {
1174
+ save: null,
1175
+ apply: applyUpdate,
1176
+ bring: bring,
1177
+ doc: function(id){ return byId[id]; }
1178
+ };
1179
+
1180
+ if (recall('collapsed')) layout.classList.add('collapsed');
1181
+ buildNav();
1182
+ route();
1183
+ })();
1184
+ `;