margins 0.1.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/public/app.js ADDED
@@ -0,0 +1,1222 @@
1
+ /* global marked, DOMPurify, MarginsLinks */
2
+ 'use strict';
3
+
4
+ /**
5
+ * The margins page. One folder, a tree of its files, and whichever one is
6
+ * open -- rendered, or being edited.
7
+ *
8
+ * One rule runs through all of it: the only HTML that reaches the page is
9
+ * what DOMPurify returns from rendering markdown, and it is inserted as a DOM
10
+ * fragment, never re-parsed from a string. File names, search results,
11
+ * backlinks, commit-message-like text -- everything that comes from the
12
+ * folder -- goes in as text nodes. A hostile file can make the page show
13
+ * something odd; it cannot make it run anything. The server's
14
+ * Content-Security-Policy is the second lock on the same door.
15
+ */
16
+
17
+ // --- pure helpers (lifted out and tested in test/client.test.js) -----------
18
+
19
+ /** A path split into segments and encoded, for putting in a URL. */
20
+ function encodePath(path) {
21
+ return String(path).split('/').map(encodeURIComponent).join('/');
22
+ }
23
+
24
+ /** The in-page address of a file, and optionally a heading in it. */
25
+ function hrefFor(path, fragment) {
26
+ return '#/' + encodePath(path) + (fragment ? '#' + encodeURIComponent(fragment) : '');
27
+ }
28
+
29
+ /** Where an image in the folder is served from. */
30
+ function rawUrl(path) {
31
+ return 'raw/' + encodePath(path);
32
+ }
33
+
34
+ /**
35
+ * The file and heading an address names: "#/notes/a%20b.md#setup" ->
36
+ * {path: "notes/a b.md", fragment: "setup"}. Null path when the address
37
+ * names nothing.
38
+ */
39
+ function parseRoute(hash) {
40
+ if (typeof hash !== 'string' || hash.indexOf('#/') !== 0) return { path: null, fragment: null };
41
+ var rest = hash.slice(2);
42
+ var split = rest.indexOf('#');
43
+ var encoded = split === -1 ? rest : rest.slice(0, split);
44
+ var decode = function (text) {
45
+ try { return decodeURIComponent(text); } catch (error) { return text; }
46
+ };
47
+ var path = encoded.split('/').map(decode).filter(function (part) { return part !== ''; }).join('/');
48
+ var fragment = split === -1 ? null : decode(rest.slice(split + 1)) || null;
49
+ return { path: path, fragment: fragment };
50
+ }
51
+
52
+ /**
53
+ * How well a file name matches what was typed into "open a file", or null
54
+ * if it does not match at all.
55
+ *
56
+ * Every typed character must appear, in order. Beyond that, what people
57
+ * mean when they type "gset" is "guide/setup.md": characters that land at
58
+ * the start of a word, right after the previous match, or in the file's own
59
+ * name rather than its folder all count for more.
60
+ */
61
+ function fuzzyScore(query, candidate) {
62
+ var q = String(query).toLowerCase().replace(/\s+/g, '');
63
+ var c = String(candidate);
64
+ var lower = c.toLowerCase();
65
+ if (!q) return 0;
66
+
67
+ var nameStart = lower.lastIndexOf('/') + 1;
68
+ var score = 0;
69
+ var from = 0;
70
+ var previous = -2;
71
+
72
+ for (var i = 0; i < q.length; i++) {
73
+ var at = lower.indexOf(q[i], from);
74
+ if (at === -1) return null;
75
+
76
+ score += 1;
77
+ if (at === previous + 1) score += 5;
78
+ var before = at === 0 ? '/' : c[at - 1];
79
+ if ('/-_. '.indexOf(before) !== -1 || (c[at] !== lower[at] && before === before.toLowerCase())) score += 8;
80
+ if (at >= nameStart) score += 3;
81
+
82
+ previous = at;
83
+ from = at + 1;
84
+ }
85
+
86
+ // A tie goes to the shorter path: "setup.md" over "archive/old/setup.md".
87
+ return score - c.length * 0.01;
88
+ }
89
+
90
+ /** The best matches for a query among paths, best first. */
91
+ function rankFiles(query, paths, limit) {
92
+ var scored = [];
93
+ for (var i = 0; i < paths.length; i++) {
94
+ var score = fuzzyScore(query, paths[i]);
95
+ if (score !== null) scored.push({ path: paths[i], score: score });
96
+ }
97
+ scored.sort(function (a, b) { return b.score - a.score || (a.path < b.path ? -1 : 1); });
98
+ return scored.slice(0, limit || 50).map(function (entry) { return entry.path; });
99
+ }
100
+
101
+ /**
102
+ * Which action a key press means, or null. Pure, so the rules -- in
103
+ * particular that typing in a field is never a shortcut -- can be tested.
104
+ */
105
+ function shortcutFor(key, modifiers, targetTag) {
106
+ var mod = Boolean(modifiers && (modifiers.meta || modifiers.ctrl));
107
+ var shift = Boolean(modifiers && modifiers.shift);
108
+ var alt = Boolean(modifiers && modifiers.alt);
109
+ var k = String(key).toLowerCase();
110
+
111
+ if (mod && !alt && !shift && k === 'p') return 'quickOpen';
112
+ if (mod && !alt && shift && k === 'f') return 'search';
113
+ if (mod && !alt && !shift && k === 's') return 'save';
114
+ if (key === 'Escape') return 'escape';
115
+
116
+ if (mod || alt) return null;
117
+ if (targetTag === 'INPUT' || targetTag === 'TEXTAREA' || targetTag === 'SELECT') return null;
118
+
119
+ if (key === 'e') return 'edit';
120
+ if (key === '?') return 'help';
121
+ if (key === '[') return 'back';
122
+ if (key === ']') return 'forward';
123
+ return null;
124
+ }
125
+
126
+ /** Titles made unique within one document: "Notes", "Notes-1", "Notes-2". */
127
+ function uniqueSlug(text, used) {
128
+ var base = MarginsLinks.slugify(text) || 'section';
129
+ var slug = base;
130
+ var n = 1;
131
+ while (used.has(slug)) slug = base + '-' + n++;
132
+ used.add(slug);
133
+ return slug;
134
+ }
135
+
136
+ // --- state ---------------------------------------------------------------
137
+
138
+ var state = {
139
+ info: null,
140
+ files: [],
141
+ paths: [],
142
+ index: null,
143
+ filesTruncated: false,
144
+ treeCache: new Map(),
145
+ expanded: new Set(),
146
+ current: null,
147
+ editing: false,
148
+ dirty: false,
149
+ diskChanged: false,
150
+ lastHash: '',
151
+ poll: null
152
+ };
153
+
154
+ var $ = function (id) { return document.getElementById(id); };
155
+
156
+ // --- talking to the server ---------------------------------------------------
157
+
158
+ function api(url, options) {
159
+ return fetch(url, options).then(function (response) {
160
+ return response.json().catch(function () { return null; }).then(function (body) {
161
+ if (!response.ok) {
162
+ var error = new Error((body && body.error) || 'That did not work (' + response.status + ').');
163
+ error.status = response.status;
164
+ error.body = body;
165
+ throw error;
166
+ }
167
+ return body;
168
+ });
169
+ });
170
+ }
171
+
172
+ function sendJson(method, url, value) {
173
+ return api(url, {
174
+ method: method,
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify(value)
177
+ });
178
+ }
179
+
180
+ function refreshFiles() {
181
+ return api('api/files').then(function (result) {
182
+ state.files = result.files;
183
+ state.paths = result.files.map(function (file) { return file.path; });
184
+ state.index = MarginsLinks.buildIndex(state.paths);
185
+ state.filesTruncated = result.truncated;
186
+ var note = $('treeNote');
187
+ note.textContent = result.truncated
188
+ ? 'This folder is very large. Search and backlinks look at the first ' + result.files.length + ' files.'
189
+ : '';
190
+ note.classList.toggle('hidden', !result.truncated);
191
+ });
192
+ }
193
+
194
+ // --- rendering markdown --------------------------------------------------------
195
+
196
+ /**
197
+ * [[wikilinks]] for marked. The token only records what was written; which
198
+ * file it means is decided after sanitising, against the folder's file list,
199
+ * so the markup this produces never contains a resolved path from anywhere
200
+ * but margins itself.
201
+ */
202
+ var wikilinkExtension = {
203
+ name: 'wikilink',
204
+ level: 'inline',
205
+ start: function (src) {
206
+ var at = src.indexOf('[[');
207
+ return at === -1 ? undefined : (at > 0 && src[at - 1] === '!' ? at - 1 : at);
208
+ },
209
+ tokenizer: function (src) {
210
+ var match = /^(!?)\[\[([^[\]\n]+?)\]\]/.exec(src);
211
+ if (!match) return undefined;
212
+ return { type: 'wikilink', raw: match[0], embed: Boolean(match[1]), inner: match[2] };
213
+ },
214
+ renderer: function (token) {
215
+ var parsed = MarginsLinks.parseWikilink(token.inner);
216
+ var label = parsed.alias || (parsed.heading && !parsed.target ? parsed.heading : parsed.target) + (parsed.heading && parsed.target && !parsed.alias ? ' › ' + parsed.heading : '');
217
+ var escape = function (text) {
218
+ return String(text).replace(/[&<>"']/g, function (c) {
219
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
220
+ });
221
+ };
222
+ return '<a class="wikilink" data-wikilink="' + escape(token.inner) + '"' +
223
+ (token.embed ? ' data-embed="1"' : '') + '>' + escape(label) + '</a>';
224
+ }
225
+ };
226
+
227
+ marked.use({ gfm: true, extensions: [wikilinkExtension] });
228
+
229
+ // What sanitising allows through. HTML is allowed in markdown and READMEs
230
+ // use it -- <details>, <kbd>, <img width>, <picture> -- so it is not
231
+ // stripped wholesale; what goes is anything that runs, loads a page, submits
232
+ // or restyles: script handlers are DOMPurify's default, and the rest is here.
233
+ // `style` in particular: a file must not be able to draw a convincing fake
234
+ // "Save" dialog over the page.
235
+ var PURIFY = {
236
+ USE_PROFILES: { html: true },
237
+ FORBID_TAGS: ['style', 'form', 'button', 'textarea', 'select', 'option', 'iframe', 'frame', 'frameset',
238
+ 'object', 'embed', 'link', 'meta', 'base', 'video', 'audio', 'dialog', 'template'],
239
+ FORBID_ATTR: ['style'],
240
+ RETURN_DOM_FRAGMENT: true
241
+ };
242
+
243
+ /**
244
+ * Render markdown from `fromPath` into `target`.
245
+ *
246
+ * Parsed by marked, sanitised by DOMPurify straight into a DOM fragment --
247
+ * never back into a string, which is where sanitised HTML gets mutated into
248
+ * something else by a second parse -- and then adjusted: links and images
249
+ * pointed at the folder, headings given anchors.
250
+ */
251
+ function renderMarkdown(markdown, fromPath, target) {
252
+ var fragment = DOMPurify.sanitize(marked.parse(markdown), PURIFY);
253
+ adjustRendered(fragment, fromPath);
254
+ target.replaceChildren(fragment);
255
+ }
256
+
257
+ function adjustRendered(root, fromPath) {
258
+ root.querySelectorAll('a[data-wikilink]').forEach(function (anchor) {
259
+ var parsed = MarginsLinks.parseWikilink(anchor.getAttribute('data-wikilink'));
260
+ var path = parsed.target ? MarginsLinks.resolveWikilink(parsed.target, fromPath, state.index) : fromPath;
261
+ var embed = anchor.hasAttribute('data-embed');
262
+
263
+ if (!path) {
264
+ // Obsidian's convention: a link to a note that does not exist yet is a
265
+ // way to create it. Clicking one offers exactly that.
266
+ anchor.classList.add('wikilink-missing');
267
+ anchor.title = 'No file called ' + parsed.target + ' yet. Click to create it.';
268
+ anchor.href = '#';
269
+ anchor.setAttribute('data-create', parsed.target);
270
+ return;
271
+ }
272
+
273
+ var file = state.files.find(function (f) { return f.path === path; });
274
+ if (embed && file && file.kind === 'image') {
275
+ var img = document.createElement('img');
276
+ img.src = rawUrl(path);
277
+ img.alt = parsed.alias || parsed.target;
278
+ img.loading = 'lazy';
279
+ anchor.replaceWith(img);
280
+ return;
281
+ }
282
+ anchor.href = hrefFor(path, parsed.heading);
283
+ if (embed) anchor.classList.add('wikilink-embed');
284
+ });
285
+
286
+ root.querySelectorAll('a[href]:not([data-wikilink])').forEach(function (anchor) {
287
+ var href = anchor.getAttribute('href');
288
+ if (href.indexOf('#/') === 0) return; // already an in-page address
289
+ if (MarginsLinks.isExternal(href)) {
290
+ anchor.target = '_blank';
291
+ anchor.rel = 'noopener noreferrer';
292
+ return;
293
+ }
294
+ var resolved = MarginsLinks.resolveHref(fromPath, href);
295
+ if (!resolved) {
296
+ anchor.classList.add('link-broken');
297
+ anchor.title = 'This link points outside the folder.';
298
+ anchor.removeAttribute('href');
299
+ return;
300
+ }
301
+ anchor.href = hrefFor(resolved.path, resolved.fragment);
302
+ });
303
+
304
+ root.querySelectorAll('img[src]').forEach(function (img) {
305
+ var src = img.getAttribute('src');
306
+ img.loading = 'lazy';
307
+ if (src.indexOf('raw/') === 0 || src.indexOf('data:') === 0 || MarginsLinks.isExternal(src)) return;
308
+ var resolved = MarginsLinks.resolveHref(fromPath, src);
309
+ if (resolved) img.src = rawUrl(resolved.path);
310
+ else img.removeAttribute('src');
311
+ });
312
+
313
+ // Task lists render as checkboxes. Anything else that survived as an input
314
+ // is noise in a document; the checkboxes are shown, not editable.
315
+ root.querySelectorAll('input').forEach(function (input) {
316
+ if (input.type !== 'checkbox') input.remove();
317
+ else input.disabled = true;
318
+ });
319
+
320
+ var used = new Set();
321
+ root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (heading) {
322
+ heading.id = uniqueSlug(heading.textContent, used);
323
+ var link = document.createElement('a');
324
+ link.className = 'heading-anchor';
325
+ link.href = hrefFor(fromPath, heading.id);
326
+ link.setAttribute('aria-label', 'Link to this section');
327
+ link.textContent = '#';
328
+ heading.appendChild(link);
329
+ });
330
+
331
+ // Wide tables scroll inside the column instead of widening the page.
332
+ root.querySelectorAll('table').forEach(function (table) {
333
+ var wrap = document.createElement('div');
334
+ wrap.className = 'table-wrap';
335
+ table.replaceWith(wrap);
336
+ wrap.appendChild(table);
337
+ });
338
+ }
339
+
340
+ // --- the tree ---------------------------------------------------------------
341
+
342
+ function loadLevel(dirPath) {
343
+ if (state.treeCache.has(dirPath)) return Promise.resolve(state.treeCache.get(dirPath));
344
+ return api('api/tree?path=' + encodeURIComponent(dirPath)).then(function (result) {
345
+ state.treeCache.set(dirPath, result.entries);
346
+ return result.entries;
347
+ });
348
+ }
349
+
350
+ function saveExpanded() {
351
+ try {
352
+ localStorage.setItem('margins:expanded:' + (state.info && state.info.root), JSON.stringify(Array.from(state.expanded)));
353
+ } catch (error) { /* private window: the tree just starts collapsed */ }
354
+ }
355
+
356
+ function restoreExpanded() {
357
+ try {
358
+ var saved = JSON.parse(localStorage.getItem('margins:expanded:' + state.info.root) || '[]');
359
+ if (Array.isArray(saved)) saved.forEach(function (path) { state.expanded.add(path); });
360
+ } catch (error) { /* nothing saved, or storage blocked */ }
361
+ }
362
+
363
+ function fileIcon(kind) {
364
+ return kind === 'markdown' ? '¶' : kind === 'image' ? '▣' : '·';
365
+ }
366
+
367
+ /** Draw one folder's entries, and the folders below it that are open. */
368
+ function buildLevel(entries, depth) {
369
+ var list = document.createDocumentFragment();
370
+ entries.forEach(function (entry) {
371
+ var item = document.createElement('li');
372
+ item.setAttribute('role', 'treeitem');
373
+ var row = document.createElement(entry.type === 'dir' ? 'button' : 'a');
374
+ row.className = 'tree-row tree-' + (entry.type === 'dir' ? 'dir' : entry.kind);
375
+ row.style.setProperty('--depth', depth);
376
+ row.title = entry.path;
377
+
378
+ var icon = document.createElement('span');
379
+ icon.className = 'tree-icon';
380
+ icon.setAttribute('aria-hidden', 'true');
381
+ var name = document.createElement('span');
382
+ name.className = 'tree-name';
383
+ name.textContent = entry.name;
384
+
385
+ if (entry.type === 'dir') {
386
+ var open = state.expanded.has(entry.path);
387
+ row.type = 'button';
388
+ row.setAttribute('aria-expanded', String(open));
389
+ icon.textContent = open ? '▾' : '▸';
390
+ row.addEventListener('click', function () { toggleFolder(entry.path); });
391
+ row.append(icon, name);
392
+ item.appendChild(row);
393
+ if (open && state.treeCache.has(entry.path)) {
394
+ var sub = document.createElement('ul');
395
+ sub.setAttribute('role', 'group');
396
+ sub.appendChild(buildLevel(state.treeCache.get(entry.path), depth + 1));
397
+ item.appendChild(sub);
398
+ }
399
+ } else {
400
+ row.href = hrefFor(entry.path);
401
+ icon.textContent = fileIcon(entry.kind);
402
+ if (state.current && state.current.path === entry.path) {
403
+ row.classList.add('tree-active');
404
+ row.setAttribute('aria-current', 'page');
405
+ }
406
+ row.append(icon, name);
407
+ item.appendChild(row);
408
+ }
409
+ list.appendChild(item);
410
+ });
411
+ return list;
412
+ }
413
+
414
+ function renderTree() {
415
+ return loadLevel('').then(function (entries) {
416
+ // Every open folder's contents are needed before drawing, or an open
417
+ // folder draws empty and then jumps.
418
+ var open = Array.from(state.expanded);
419
+ return Promise.all(open.map(function (path) { return loadLevel(path).catch(function () { state.expanded.delete(path); }); }))
420
+ .then(function () {
421
+ $('tree').replaceChildren(buildLevel(entries, 0));
422
+ var active = $('tree').querySelector('.tree-active');
423
+ if (active) active.scrollIntoView({ block: 'nearest' });
424
+ });
425
+ });
426
+ }
427
+
428
+ function toggleFolder(path) {
429
+ if (state.expanded.has(path)) state.expanded.delete(path);
430
+ else state.expanded.add(path);
431
+ saveExpanded();
432
+ renderTree();
433
+ }
434
+
435
+ /** Open every folder above a file, so the file can be seen in the tree. */
436
+ function revealInTree(path) {
437
+ var parts = path.split('/');
438
+ for (var i = 1; i < parts.length; i++) state.expanded.add(parts.slice(0, i).join('/'));
439
+ saveExpanded();
440
+ return renderTree();
441
+ }
442
+
443
+ /** Forget cached folder contents and draw again -- after a new file, or on focus. */
444
+ function refreshTree() {
445
+ state.treeCache.clear();
446
+ return renderTree();
447
+ }
448
+
449
+ // --- showing a file ---------------------------------------------------------
450
+
451
+ function setVisible(id, visible) {
452
+ $(id).classList.toggle('hidden', !visible);
453
+ }
454
+
455
+ /** Where a path is: each folder above it, clickable, then its name. */
456
+ function renderBreadcrumbs(path) {
457
+ var crumbs = $('breadcrumbs');
458
+ crumbs.replaceChildren();
459
+ var root = document.createElement('a');
460
+ root.href = '#/';
461
+ root.textContent = state.info ? state.info.name : '/';
462
+ crumbs.appendChild(root);
463
+
464
+ var parts = path ? path.split('/') : [];
465
+ parts.forEach(function (part, i) {
466
+ var sep = document.createElement('span');
467
+ sep.className = 'crumb-sep';
468
+ sep.textContent = '/';
469
+ crumbs.appendChild(sep);
470
+ if (i === parts.length - 1) {
471
+ var here = document.createElement('span');
472
+ here.className = 'crumb-current';
473
+ here.textContent = part;
474
+ crumbs.appendChild(here);
475
+ } else {
476
+ var link = document.createElement('a');
477
+ link.href = hrefFor(parts.slice(0, i + 1).join('/'));
478
+ link.textContent = part;
479
+ crumbs.appendChild(link);
480
+ }
481
+ });
482
+ }
483
+
484
+ /** A short explanation in place of a file: not found, not text, too big. */
485
+ function showMessage(title, detail) {
486
+ var doc = $('document');
487
+ doc.replaceChildren();
488
+ var box = document.createElement('div');
489
+ box.className = 'empty-state';
490
+ var h = document.createElement('h2');
491
+ h.textContent = title;
492
+ box.appendChild(h);
493
+ if (detail) {
494
+ var p = document.createElement('p');
495
+ p.textContent = detail;
496
+ box.appendChild(p);
497
+ }
498
+ doc.appendChild(box);
499
+ setVisible('context', false);
500
+ }
501
+
502
+ function isEditable(file) {
503
+ return Boolean(file) && (file.kind === 'markdown' || file.kind === 'text');
504
+ }
505
+
506
+ function updateDocBar() {
507
+ var file = state.current;
508
+ setVisible('docBar', Boolean(file));
509
+ if (!file) return;
510
+ renderBreadcrumbs(file.path);
511
+ setVisible('editButton', isEditable(file) && !state.editing);
512
+ setVisible('saveButton', state.editing);
513
+ setVisible('doneButton', state.editing);
514
+ $('saveButton').disabled = !state.dirty;
515
+ $('saveState').textContent = state.editing ? (state.dirty ? 'Unsaved changes' : 'Saved') : '';
516
+ }
517
+
518
+ /** Draw the open file in the reading view. */
519
+ function renderCurrent(fragment) {
520
+ var file = state.current;
521
+ var doc = $('document');
522
+ document.title = (file.path ? file.path.split('/').pop() + ' — ' : '') + (state.info ? state.info.name : 'margins');
523
+
524
+ if (file.kind === 'markdown') {
525
+ renderMarkdown(file.content, file.path, doc);
526
+ buildOutline(doc);
527
+ loadBacklinks(file.path);
528
+ } else if (file.kind === 'text') {
529
+ doc.replaceChildren(sourceView(file.content));
530
+ setVisible('context', false);
531
+ } else if (file.kind === 'image') {
532
+ var figure = document.createElement('figure');
533
+ figure.className = 'image-view';
534
+ var img = document.createElement('img');
535
+ img.src = rawUrl(file.path);
536
+ img.alt = file.path;
537
+ figure.appendChild(img);
538
+ doc.replaceChildren(figure);
539
+ setVisible('context', false);
540
+ } else {
541
+ showMessage('This file is not text', 'margins shows markdown, text and images. Open this one with the program it belongs to.');
542
+ }
543
+
544
+ if (fragment) scrollToFragment(fragment);
545
+ else $('main').scrollTop = 0;
546
+ }
547
+
548
+ /** Plain text, with line numbers, as text nodes -- never markup. */
549
+ function sourceView(content) {
550
+ var pre = document.createElement('pre');
551
+ pre.className = 'source-view';
552
+ var lines = content.split('\n');
553
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
554
+ lines.forEach(function (line, i) {
555
+ var row = document.createElement('span');
556
+ row.className = 'source-line';
557
+ var number = document.createElement('span');
558
+ number.className = 'source-number';
559
+ number.textContent = String(i + 1);
560
+ var text = document.createElement('span');
561
+ text.className = 'source-text';
562
+ text.textContent = line + '\n';
563
+ row.append(number, text);
564
+ pre.appendChild(row);
565
+ });
566
+ return pre;
567
+ }
568
+
569
+ function scrollToFragment(fragment) {
570
+ var target = document.getElementById(fragment) || document.getElementById(MarginsLinks.slugify(fragment));
571
+ if (target) target.scrollIntoView({ block: 'start' });
572
+ }
573
+
574
+ /** "On this page": the document's h1-h3, for long files. */
575
+ function buildOutline(doc) {
576
+ var headings = Array.from(doc.querySelectorAll('h1, h2, h3'));
577
+ var outline = $('outline');
578
+ outline.replaceChildren();
579
+ headings.forEach(function (heading) {
580
+ var item = document.createElement('li');
581
+ item.className = 'outline-' + heading.tagName.toLowerCase();
582
+ var link = document.createElement('a');
583
+ link.href = hrefFor(state.current.path, heading.id);
584
+ // The heading's own text, without the "#" anchor appended to it.
585
+ link.textContent = heading.firstChild ? Array.from(heading.childNodes)
586
+ .filter(function (node) { return !(node.classList && node.classList.contains('heading-anchor')); })
587
+ .map(function (node) { return node.textContent; }).join('').trim() : '';
588
+ item.appendChild(link);
589
+ outline.appendChild(item);
590
+ });
591
+ // One heading is a title, not an outline.
592
+ setVisible('outlineSection', headings.length > 1);
593
+ refreshContextVisibility();
594
+ }
595
+
596
+ function refreshContextVisibility() {
597
+ var any = !$('outlineSection').classList.contains('hidden') || !$('backlinksSection').classList.contains('hidden');
598
+ setVisible('context', any);
599
+ }
600
+
601
+ /** What links here -- by relative path, or by [[wikilink]]. */
602
+ function loadBacklinks(path) {
603
+ setVisible('backlinksSection', false);
604
+ api('api/backlinks?path=' + encodeURIComponent(path)).then(function (result) {
605
+ if (!state.current || state.current.path !== path) return; // moved on meanwhile
606
+ var list = $('backlinks');
607
+ list.replaceChildren();
608
+ result.backlinks.forEach(function (link) {
609
+ var item = document.createElement('li');
610
+ var anchor = document.createElement('a');
611
+ anchor.href = hrefFor(link.path);
612
+ var name = document.createElement('span');
613
+ name.className = 'backlink-path';
614
+ name.textContent = link.path;
615
+ var context = document.createElement('span');
616
+ context.className = 'backlink-text';
617
+ context.textContent = link.text;
618
+ anchor.append(name, context);
619
+ item.appendChild(anchor);
620
+ list.appendChild(item);
621
+ });
622
+ $('backlinkCount').textContent = String(result.backlinks.length);
623
+ setVisible('backlinksSection', result.backlinks.length > 0);
624
+ refreshContextVisibility();
625
+ }).catch(function () { /* backlinks are extra; the file is already shown */ });
626
+ }
627
+
628
+ /** A folder: what is in it, and its README underneath -- as GitHub shows one. */
629
+ function showFolder(path) {
630
+ state.current = null;
631
+ stopEditing(true);
632
+ // Open the folder in the tree too, and drop the previous file's highlight.
633
+ if (path) state.expanded.add(path);
634
+ revealInTree(path ? path + '/' : '');
635
+ setVisible('docBar', true);
636
+ renderBreadcrumbs(path);
637
+ setVisible('editButton', false);
638
+ $('saveState').textContent = '';
639
+ document.title = (path || (state.info ? state.info.name : '')) + ' — margins';
640
+
641
+ return loadLevel(path).then(function (entries) {
642
+ var doc = $('document');
643
+ doc.replaceChildren();
644
+ var list = document.createElement('ul');
645
+ list.className = 'folder-list';
646
+ entries.forEach(function (entry) {
647
+ var item = document.createElement('li');
648
+ var link = document.createElement('a');
649
+ link.href = hrefFor(entry.path);
650
+ link.textContent = entry.name + (entry.type === 'dir' ? '/' : '');
651
+ link.className = entry.type === 'dir' ? 'folder-dir' : 'folder-file';
652
+ item.appendChild(link);
653
+ list.appendChild(item);
654
+ });
655
+ if (entries.length === 0) {
656
+ var none = document.createElement('li');
657
+ none.className = 'folder-empty';
658
+ none.textContent = 'Nothing here yet.';
659
+ list.appendChild(none);
660
+ }
661
+ doc.appendChild(list);
662
+ setVisible('context', false);
663
+
664
+ var readme = entries.find(function (entry) {
665
+ return entry.type === 'file' && /^(readme|index)\.(md|markdown|mdown|mkd|mkdn|mdx)$/i.test(entry.name);
666
+ });
667
+ if (!readme) return;
668
+ return api('api/file?path=' + encodeURIComponent(readme.path)).then(function (file) {
669
+ var article = document.createElement('div');
670
+ article.className = 'folder-readme';
671
+ renderMarkdown(file.content, file.path, article);
672
+ doc.appendChild(article);
673
+ });
674
+ }).catch(function (error) {
675
+ showMessage('Could not open this folder', error.message);
676
+ });
677
+ }
678
+
679
+ /** Open whatever an address names: a file, a folder, or nothing. */
680
+ function openRoute(route) {
681
+ var path = route.path || '';
682
+ if (path === '') return showFolder('');
683
+
684
+ return api('api/file?path=' + encodeURIComponent(path)).then(function (file) {
685
+ if (file.kind === 'folder') return showFolder(path);
686
+ stopEditing(true);
687
+ state.current = file;
688
+ state.diskChanged = false;
689
+ hideBanner();
690
+ updateDocBar();
691
+ renderCurrent(route.fragment);
692
+ revealInTree(path);
693
+ // On a phone the tree covers the page; having picked a file, it goes.
694
+ if (window.matchMedia('(max-width: 760px)').matches && !document.body.classList.contains('sidebar-hidden')) {
695
+ toggleSidebar();
696
+ }
697
+ }).catch(function (error) {
698
+ state.current = null;
699
+ updateDocBar();
700
+ setVisible('docBar', true);
701
+ renderBreadcrumbs(path);
702
+ setVisible('editButton', false);
703
+ showMessage(error.status === 404 ? 'No file here' : 'Could not open this', error.message);
704
+ });
705
+ }
706
+
707
+ // --- editing ------------------------------------------------------------------
708
+
709
+ var previewTimer = null;
710
+
711
+ function startEditing() {
712
+ if (!isEditable(state.current) || state.editing) return;
713
+ state.editing = true;
714
+ state.dirty = false;
715
+ $('source').value = state.current.content;
716
+ $('editor').classList.toggle('editor-text', state.current.kind !== 'markdown');
717
+ setVisible('viewer', false);
718
+ setVisible('editor', true);
719
+ setVisible('context', false);
720
+ updateDocBar();
721
+ renderPreview();
722
+ $('source').focus();
723
+ }
724
+
725
+ /**
726
+ * Leave the editor. `force` skips the question -- used when the file being
727
+ * shown is changing anyway and the caller has already asked.
728
+ */
729
+ function stopEditing(force) {
730
+ if (!state.editing) return true;
731
+ if (!force && state.dirty && !window.confirm('Discard your unsaved changes?')) return false;
732
+ state.editing = false;
733
+ state.dirty = false;
734
+ setVisible('editor', false);
735
+ setVisible('viewer', true);
736
+ hideBanner();
737
+ if (state.current && !force) renderCurrent(null);
738
+ updateDocBar();
739
+ return true;
740
+ }
741
+
742
+ function renderPreview() {
743
+ if (!state.editing || state.current.kind !== 'markdown') return;
744
+ renderMarkdown($('source').value, state.current.path, $('preview'));
745
+ }
746
+
747
+ function onSourceInput() {
748
+ state.dirty = $('source').value !== state.current.content;
749
+ updateDocBar();
750
+ clearTimeout(previewTimer);
751
+ previewTimer = setTimeout(renderPreview, 120);
752
+ }
753
+
754
+ /** Tab indents instead of leaving the editor; the one key a textarea gets wrong for writing. */
755
+ function onSourceKeydown(event) {
756
+ if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return;
757
+ event.preventDefault();
758
+ var area = event.target;
759
+ var start = area.selectionStart;
760
+ area.setRangeText(' ', start, area.selectionEnd, 'end');
761
+ onSourceInput();
762
+ }
763
+
764
+ /**
765
+ * Save, if the file on disk is still the one that was opened. If it is not
766
+ * -- another editor, a git checkout, an agent -- nothing is written, and the
767
+ * choice is put to the person rather than made for them.
768
+ */
769
+ function save(overVersion) {
770
+ if (!state.editing || !state.current) return;
771
+ var content = $('source').value;
772
+ $('saveState').textContent = 'Saving…';
773
+
774
+ return sendJson('PUT', 'api/file', {
775
+ path: state.current.path,
776
+ content: content,
777
+ version: overVersion || state.current.version
778
+ }).then(function (result) {
779
+ state.current.content = content;
780
+ state.current.version = result.version;
781
+ state.dirty = false;
782
+ state.diskChanged = false;
783
+ hideBanner();
784
+ updateDocBar();
785
+ }).catch(function (error) {
786
+ if (error.status === 409 && error.body && error.body.current) {
787
+ var disk = error.body.current;
788
+ showBanner(
789
+ 'This file changed on disk after you opened it, so your edit was not saved.',
790
+ 'Keep my version', function () { save(disk.version); },
791
+ 'Use the version on disk', function () { takeDiskVersion(disk); }
792
+ );
793
+ } else {
794
+ showBanner('Could not save: ' + error.message, 'Try again', function () { save(); }, 'Dismiss', hideBanner);
795
+ }
796
+ updateDocBar();
797
+ });
798
+ }
799
+
800
+ function takeDiskVersion(disk) {
801
+ state.current.content = disk.content;
802
+ state.current.version = disk.version;
803
+ $('source').value = disk.content;
804
+ state.dirty = false;
805
+ state.diskChanged = false;
806
+ hideBanner();
807
+ renderPreview();
808
+ updateDocBar();
809
+ }
810
+
811
+ function showBanner(text, primaryLabel, onPrimary, secondaryLabel, onSecondary) {
812
+ $('bannerText').textContent = text;
813
+ var primary = $('bannerPrimary');
814
+ var secondary = $('bannerSecondary');
815
+ primary.textContent = primaryLabel;
816
+ secondary.textContent = secondaryLabel;
817
+ primary.onclick = onPrimary;
818
+ secondary.onclick = onSecondary;
819
+ setVisible('banner', true);
820
+ }
821
+
822
+ function hideBanner() {
823
+ setVisible('banner', false);
824
+ }
825
+
826
+ // --- noticing changes made elsewhere ------------------------------------------
827
+
828
+ /**
829
+ * Every couple of seconds, ask whether the open file is still the version
830
+ * shown. Polling rather than a file watcher: editors that save by writing a
831
+ * new file and renaming it over the old one break most watchers, and one
832
+ * small request to a server on this machine costs nothing.
833
+ */
834
+ function checkForChanges() {
835
+ var file = state.current;
836
+ if (document.hidden || !file || !file.version) return;
837
+
838
+ api('api/version?path=' + encodeURIComponent(file.path)).then(function (result) {
839
+ if (state.current !== file || !result.version || result.version === file.version) return;
840
+
841
+ if (!state.editing || !state.dirty) {
842
+ // Nothing of the reader's to lose: show what is there now, in place.
843
+ var scroll = $('main').scrollTop;
844
+ return api('api/file?path=' + encodeURIComponent(file.path)).then(function (fresh) {
845
+ if (state.current !== file) return;
846
+ state.current = fresh;
847
+ if (state.editing) {
848
+ $('source').value = fresh.content;
849
+ renderPreview();
850
+ } else {
851
+ renderCurrent(null);
852
+ $('main').scrollTop = scroll;
853
+ }
854
+ });
855
+ }
856
+
857
+ if (state.diskChanged) return;
858
+ state.diskChanged = true;
859
+ showBanner(
860
+ 'This file changed on disk while you were editing. Your changes are still here; saving will ask before replacing it.',
861
+ 'Load the disk version', function () {
862
+ api('api/file?path=' + encodeURIComponent(file.path)).then(takeDiskVersion);
863
+ },
864
+ 'Keep editing', hideBanner
865
+ );
866
+ }).catch(function () { /* the file may be mid-save elsewhere; the next check will see */ });
867
+ }
868
+
869
+ // --- open a file by name ------------------------------------------------------
870
+
871
+ var quickSelection = 0;
872
+
873
+ function openQuickOpen() {
874
+ var dialog = $('quickOpen');
875
+ if (dialog.open) return;
876
+ $('quickOpenInput').value = '';
877
+ renderQuickOpen();
878
+ dialog.showModal();
879
+ $('quickOpenInput').focus();
880
+ }
881
+
882
+ function renderQuickOpen() {
883
+ var query = $('quickOpenInput').value;
884
+ // With nothing typed, the files people most often want: the markdown.
885
+ var paths = query ? rankFiles(query, state.paths, 50) : state.paths.filter(MarginsLinks.isMarkdownPath).slice(0, 50);
886
+ var results = $('quickOpenResults');
887
+ results.replaceChildren();
888
+ quickSelection = 0;
889
+
890
+ paths.forEach(function (path, i) {
891
+ var item = document.createElement('li');
892
+ item.setAttribute('role', 'option');
893
+ item.dataset.path = path;
894
+ var slash = path.lastIndexOf('/');
895
+ var name = document.createElement('span');
896
+ name.className = 'result-name';
897
+ name.textContent = path.slice(slash + 1);
898
+ var where = document.createElement('span');
899
+ where.className = 'result-where';
900
+ where.textContent = slash === -1 ? '' : path.slice(0, slash);
901
+ item.append(name, where);
902
+ item.addEventListener('mousedown', function (event) {
903
+ event.preventDefault();
904
+ pickQuickOpen(i);
905
+ });
906
+ results.appendChild(item);
907
+ });
908
+ if (paths.length === 0) {
909
+ var none = document.createElement('li');
910
+ none.className = 'result-none';
911
+ none.textContent = query ? 'No file matches “' + query + '”.' : 'No markdown files here.';
912
+ results.appendChild(none);
913
+ }
914
+ highlightQuick();
915
+ }
916
+
917
+ function highlightQuick() {
918
+ var items = $('quickOpenResults').querySelectorAll('li[data-path]');
919
+ items.forEach(function (item, i) {
920
+ item.classList.toggle('selected', i === quickSelection);
921
+ item.setAttribute('aria-selected', String(i === quickSelection));
922
+ });
923
+ if (items[quickSelection]) items[quickSelection].scrollIntoView({ block: 'nearest' });
924
+ }
925
+
926
+ function pickQuickOpen(index) {
927
+ var items = $('quickOpenResults').querySelectorAll('li[data-path]');
928
+ var item = items[index === undefined ? quickSelection : index];
929
+ if (!item) return;
930
+ $('quickOpen').close();
931
+ location.hash = hrefFor(item.dataset.path);
932
+ }
933
+
934
+ function onQuickOpenKeydown(event) {
935
+ var count = $('quickOpenResults').querySelectorAll('li[data-path]').length;
936
+ if (event.key === 'ArrowDown') {
937
+ event.preventDefault();
938
+ quickSelection = Math.min(count - 1, quickSelection + 1);
939
+ highlightQuick();
940
+ } else if (event.key === 'ArrowUp') {
941
+ event.preventDefault();
942
+ quickSelection = Math.max(0, quickSelection - 1);
943
+ highlightQuick();
944
+ } else if (event.key === 'Enter') {
945
+ event.preventDefault();
946
+ pickQuickOpen();
947
+ }
948
+ }
949
+
950
+ // --- search every file ----------------------------------------------------------
951
+
952
+ var searchTimer = null;
953
+ var searchSequence = 0;
954
+
955
+ function openSearch() {
956
+ var dialog = $('search');
957
+ if (dialog.open) return;
958
+ dialog.showModal();
959
+ var input = $('searchInput');
960
+ input.focus();
961
+ input.select();
962
+ }
963
+
964
+ function runSearch() {
965
+ var query = $('searchInput').value.trim();
966
+ var summary = $('searchSummary');
967
+ var results = $('searchResults');
968
+ if (query.length < 2) {
969
+ summary.textContent = query ? 'Keep typing…' : '';
970
+ results.replaceChildren();
971
+ return;
972
+ }
973
+
974
+ // Answers can arrive out of order; only the latest question's is drawn.
975
+ var sequence = ++searchSequence;
976
+ summary.textContent = 'Searching…';
977
+ api('api/search?q=' + encodeURIComponent(query)).then(function (result) {
978
+ if (sequence !== searchSequence) return;
979
+ results.replaceChildren();
980
+
981
+ var byFile = new Map();
982
+ result.results.forEach(function (hit) {
983
+ if (!byFile.has(hit.path)) byFile.set(hit.path, []);
984
+ byFile.get(hit.path).push(hit);
985
+ });
986
+
987
+ byFile.forEach(function (hits, path) {
988
+ var group = document.createElement('li');
989
+ group.className = 'search-file';
990
+ var heading = document.createElement('a');
991
+ heading.className = 'search-file-name';
992
+ heading.href = hrefFor(path);
993
+ heading.textContent = path;
994
+ group.appendChild(heading);
995
+
996
+ hits.forEach(function (hit) {
997
+ var line = document.createElement('a');
998
+ line.className = 'search-hit';
999
+ line.href = hrefFor(path);
1000
+ var number = document.createElement('span');
1001
+ number.className = 'search-line';
1002
+ number.textContent = hit.line;
1003
+ // The match marked out of text nodes: the file's own words, never
1004
+ // parsed as markup.
1005
+ var text = document.createElement('span');
1006
+ text.className = 'search-text';
1007
+ text.append(
1008
+ document.createTextNode(hit.text.slice(0, hit.start)),
1009
+ Object.assign(document.createElement('mark'), { textContent: hit.text.slice(hit.start, hit.start + hit.length) }),
1010
+ document.createTextNode(hit.text.slice(hit.start + hit.length))
1011
+ );
1012
+ line.append(number, text);
1013
+ group.appendChild(line);
1014
+ });
1015
+ results.appendChild(group);
1016
+ });
1017
+
1018
+ var files = byFile.size;
1019
+ summary.textContent = result.results.length === 0
1020
+ ? 'Nothing matches “' + query + '” in ' + result.filesSearched + ' files.'
1021
+ : result.results.length + (result.truncated ? '+' : '') + ' matches in ' + files + ' file' + (files === 1 ? '' : 's') +
1022
+ (result.truncated ? ' — showing the first ' + result.results.length + '.' : '.');
1023
+ }).catch(function (error) {
1024
+ if (sequence === searchSequence) summary.textContent = error.message;
1025
+ });
1026
+ }
1027
+
1028
+ // --- a new file ---------------------------------------------------------------
1029
+
1030
+ function openNewFile(suggested) {
1031
+ var dialog = $('newFile');
1032
+ var here = state.current ? state.current.path.split('/').slice(0, -1).join('/') : '';
1033
+ $('newFileInput').value = suggested || (here ? here + '/' : '');
1034
+ setVisible('newFileError', false);
1035
+ dialog.showModal();
1036
+ var input = $('newFileInput');
1037
+ input.focus();
1038
+ input.setSelectionRange(input.value.length, input.value.length);
1039
+ }
1040
+
1041
+ function createFile(event) {
1042
+ event.preventDefault();
1043
+ var path = $('newFileInput').value.trim().replace(/^\/+/, '');
1044
+ if (!path || /\/$/.test(path)) {
1045
+ $('newFileError').textContent = 'Give the file a name.';
1046
+ setVisible('newFileError', true);
1047
+ return;
1048
+ }
1049
+ // A name with no extension is a note: people type "ideas" and mean ideas.md.
1050
+ if (!/\.[^/]+$/.test(path)) path += '.md';
1051
+
1052
+ var title = path.split('/').pop().replace(/\.[^.]+$/, '');
1053
+ var content = MarginsLinks.isMarkdownPath(path) ? '# ' + title + '\n\n' : '';
1054
+ sendJson('POST', 'api/file', { path: path, content: content }).then(function (created) {
1055
+ $('newFile').close();
1056
+ return refreshFiles().then(refreshTree).then(function () {
1057
+ location.hash = hrefFor(created.path);
1058
+ // Open it for writing: nobody creates an empty file to read it.
1059
+ var wait = setInterval(function () {
1060
+ if (state.current && state.current.path === created.path) {
1061
+ clearInterval(wait);
1062
+ startEditing();
1063
+ var source = $('source');
1064
+ source.setSelectionRange(source.value.length, source.value.length);
1065
+ }
1066
+ }, 50);
1067
+ setTimeout(function () { clearInterval(wait); }, 3000);
1068
+ });
1069
+ }).catch(function (error) {
1070
+ $('newFileError').textContent = error.message;
1071
+ setVisible('newFileError', true);
1072
+ });
1073
+ }
1074
+
1075
+ // --- the page: keys, navigation, start ----------------------------------------
1076
+
1077
+ function closeTopDialog() {
1078
+ var open = Array.from(document.querySelectorAll('dialog[open]'));
1079
+ if (open.length === 0) return false;
1080
+ open[open.length - 1].close();
1081
+ return true;
1082
+ }
1083
+
1084
+ function onKeydown(event) {
1085
+ var action = shortcutFor(event.key, {
1086
+ meta: event.metaKey, ctrl: event.ctrlKey, shift: event.shiftKey, alt: event.altKey
1087
+ }, event.target && event.target.tagName);
1088
+ if (!action) return;
1089
+
1090
+ if (action === 'quickOpen') { event.preventDefault(); closeTopDialog(); openQuickOpen(); }
1091
+ else if (action === 'search') { event.preventDefault(); closeTopDialog(); openSearch(); }
1092
+ else if (action === 'save') { event.preventDefault(); if (state.editing) save(); }
1093
+ else if (action === 'escape') {
1094
+ // Dialogs close themselves on Escape; leaving the editor is the one
1095
+ // Escape margins handles.
1096
+ if (!document.querySelector('dialog[open]') && state.editing) stopEditing(false);
1097
+ }
1098
+ else if (document.querySelector('dialog[open]')) return;
1099
+ else if (action === 'edit') { event.preventDefault(); startEditing(); }
1100
+ else if (action === 'help') { event.preventDefault(); $('help').showModal(); }
1101
+ else if (action === 'back') history.back();
1102
+ else if (action === 'forward') history.forward();
1103
+ }
1104
+
1105
+ function onHashChange() {
1106
+ if (state.editing && state.dirty && !window.confirm('Leave this file? Your unsaved changes will be lost.')) {
1107
+ // Put the address back without adding a history entry.
1108
+ history.replaceState(null, '', state.lastHash);
1109
+ return;
1110
+ }
1111
+ state.lastHash = location.hash;
1112
+ openRoute(parseRoute(location.hash));
1113
+ }
1114
+
1115
+ /** Show ⌘ on a Mac and Ctrl elsewhere, in every shortcut hint on the page. */
1116
+ function labelShortcuts() {
1117
+ var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
1118
+ document.querySelectorAll('kbd[data-mod]').forEach(function (kbd) {
1119
+ var key = kbd.textContent.trim();
1120
+ kbd.textContent = (mac ? '⌘' : 'Ctrl+') + (kbd.hasAttribute('data-shift') ? (mac ? '⇧' : 'Shift+') : '') + key;
1121
+ });
1122
+ }
1123
+
1124
+ function toggleSidebar() {
1125
+ var hidden = document.body.classList.toggle('sidebar-hidden');
1126
+ $('sidebarToggle').setAttribute('aria-expanded', String(!hidden));
1127
+ try { localStorage.setItem('margins:sidebar-hidden', hidden ? '1' : ''); } catch (error) { /* fine */ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Hold a connection open for as long as this tab exists, so closing the last
1132
+ * tab stops the server -- as in reviewer. EventSource reconnects on its own
1133
+ * after a blip, which is what the server's grace period relies on.
1134
+ */
1135
+ function watchFromThisTab() {
1136
+ try { new EventSource('api/alive'); } catch (error) { /* no automatic stop, nothing worse */ }
1137
+ }
1138
+
1139
+ function wire() {
1140
+ labelShortcuts();
1141
+ document.addEventListener('keydown', onKeydown);
1142
+ window.addEventListener('hashchange', onHashChange);
1143
+ window.addEventListener('beforeunload', function (event) {
1144
+ if (state.editing && state.dirty) {
1145
+ event.preventDefault();
1146
+ event.returnValue = '';
1147
+ }
1148
+ });
1149
+
1150
+ var lastRefresh = 0;
1151
+ window.addEventListener('focus', function () {
1152
+ // Back from another window, where files may have been added or removed.
1153
+ if (Date.now() - lastRefresh < 3000) return;
1154
+ lastRefresh = Date.now();
1155
+ refreshFiles().then(refreshTree).catch(function () {});
1156
+ });
1157
+
1158
+ $('sidebarToggle').addEventListener('click', toggleSidebar);
1159
+ $('quickOpenButton').addEventListener('click', openQuickOpen);
1160
+ $('searchButton').addEventListener('click', openSearch);
1161
+ $('helpButton').addEventListener('click', function () { $('help').showModal(); });
1162
+ $('newFileButton').addEventListener('click', function () { openNewFile(); });
1163
+ $('editButton').addEventListener('click', startEditing);
1164
+ $('saveButton').addEventListener('click', function () { save(); });
1165
+ $('doneButton').addEventListener('click', function () { stopEditing(false); });
1166
+ $('source').addEventListener('input', onSourceInput);
1167
+ $('source').addEventListener('keydown', onSourceKeydown);
1168
+ $('quickOpenInput').addEventListener('input', renderQuickOpen);
1169
+ $('quickOpenInput').addEventListener('keydown', onQuickOpenKeydown);
1170
+ $('searchInput').addEventListener('input', function () {
1171
+ clearTimeout(searchTimer);
1172
+ searchTimer = setTimeout(runSearch, 200);
1173
+ });
1174
+ $('newFileForm').addEventListener('submit', createFile);
1175
+
1176
+ // A missing [[wikilink]] creates its note, next to the one linking to it.
1177
+ document.addEventListener('click', function (event) {
1178
+ var anchor = event.target.closest && event.target.closest('a[data-create]');
1179
+ if (!anchor) return;
1180
+ event.preventDefault();
1181
+ var here = state.current ? state.current.path.split('/').slice(0, -1).join('/') : '';
1182
+ openNewFile((here ? here + '/' : '') + anchor.getAttribute('data-create'));
1183
+ });
1184
+
1185
+ // Clicking outside a dialog's box closes it, as people expect of a palette.
1186
+ document.querySelectorAll('dialog').forEach(function (dialog) {
1187
+ dialog.addEventListener('click', function (event) {
1188
+ if (event.target === dialog) dialog.close();
1189
+ });
1190
+ });
1191
+
1192
+ try {
1193
+ if (localStorage.getItem('margins:sidebar-hidden')) toggleSidebar();
1194
+ } catch (error) { /* fine */ }
1195
+ }
1196
+
1197
+ function start() {
1198
+ wire();
1199
+ watchFromThisTab();
1200
+
1201
+ return api('api/info').then(function (info) {
1202
+ state.info = info;
1203
+ $('rootName').textContent = info.root;
1204
+ $('rootName').title = info.root;
1205
+ restoreExpanded();
1206
+ return refreshFiles();
1207
+ }).then(renderTree).then(function () {
1208
+ var route = parseRoute(location.hash);
1209
+ if (route.path === null && state.info.initial) {
1210
+ location.replace(hrefFor(state.info.initial));
1211
+ return;
1212
+ }
1213
+ state.lastHash = location.hash;
1214
+ return openRoute(route);
1215
+ }).then(function () {
1216
+ state.poll = setInterval(checkForChanges, 2000);
1217
+ }).catch(function (error) {
1218
+ showMessage('margins could not start', error.message);
1219
+ });
1220
+ }
1221
+
1222
+ if (typeof document !== 'undefined' && document.getElementById('tree')) start();