~bigbes/sr-ht-spec

ref: bd5b615ab1fe6b50a20af8a9ebc2aa1180915823 sr-ht-spec/web/static/diff.js -rw-r--r-- 24.7 KiB
bd5b615a — Eugene Blikh docs: drop a stray closing tag from ci.md 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
// diff.js — line selection for the unified prose diff.
//
// PROGRESSIVE ENHANCEMENT, NOT AN APPLICATION. Everything the review page must
// do, it does server-side or in CSS: folding is a checkbox, commenting is a
// per-block <details class="ph-compose"> holding a plain form POSTing to
// /<space>/p/<id>/comment. This file adds one thing on top — picking the lines
// you are talking about — and if it fails to load, nothing above is lost.
//
// SELECTION IS BY LINE, ANCHORING IS BY BLOCK. Lines are what the cursor lands
// on; a line number, though, is worthless the moment the document is edited
// again, so a comment is stored against its block's content hash (see
// core.CommentAnchor). The two-level scheme is why the selection is CLAMPED to
// a single block: a range spanning two blocks would have to be anchored to one
// of them, and picking one would be a guess presented as a fact. The composer
// keeps saying which block it will anchor to, and this file only prefixes the
// lines you actually chose onto that sentence.
//
// Markup contract: table.ph-diff with tr.ph-row rows, td.ph-n gutter cells,
// data-anchor on every commentable row, and a tr.ph-notes[data-anchor] per
// block holding that block's threads and composer.
//
// THE TABLE IS MARKED `ph-js` ONCE IT IS WIRED, and the stylesheet keys one
// rule off that class: a notes row holding nothing but a shut composer stops
// being drawn (see "the composer, once JavaScript is in" in scss/main.scss).
// The server has to render that composer under EVERY block, because with this
// file absent it is the only way to comment; with this file present it is
// sixteen identical "Comment on this block" rows cutting the numbered listing
// into pieces — the same "identical chrome on every block" that got the earlier
// block-card UI rejected. So the enhancement is: pick the lines, and the
// composer for their block opens by itself. Both paths below do that.
//
// ONE COMPOSER AT A TIME, AND TYPED TEXT IS NEVER HIDDEN. Those two rules
// settle every "should this close now" question in this file. The first is what
// keeps the hiding rule above worth having: opening a composer per selection
// and never closing the last one walks the page back to a comment box under
// every block within a dozen clicks, which is the state the rule exists to
// prevent. The second outranks the first wherever they meet — an empty composer
// is chrome and may be closed, folded away or otherwise tidied up, but a
// composer holding a half-written critique is the reviewer's own work, and
// neither this file nor the stylesheet may take it off the screen. A stale
// empty box is noise; a discarded critique is lost work, and the reviewer has
// no way of knowing it is still in the DOM. `ph-draft`, below, is how the
// stylesheet is told which of the two it is looking at.

(function () {
  "use strict";

  var EN_DASH = "–";

  // Every controller on the page, so a selection in one document's table can
  // clear the selection in another's: the location hash is global, and two
  // highlighted ranges would claim to be one.
  var controllers = [];

  // The composer this file has opened, page-wide rather than per table. The
  // selection is page-wide already — a mousedown in one document clears the
  // other's — so the composer that selection opened has to travel with it, or
  // picking a line in the second document leaves a comment box standing under
  // the first.
  var opened = null;

  // Text the reviewer has typed and not yet posted. This is the one question
  // asked before anything here closes a composer or lets the stylesheet stop
  // drawing one.
  function hasDraft(compose) {
    var body = compose ? compose.querySelector("textarea") : null;
    return !!(body && body.value.trim() !== "");
  }

  // `ph-draft` is how the stylesheet learns that a composer is holding words:
  // a notes row carrying it stays drawn even with the composer shut, and stays
  // drawn when the fold around it is closed again (see "the composer, once
  // JavaScript is in" in scss/main.scss).
  //
  // CSS could very nearly read the textarea itself, with
  // `:has(textarea:not(:placeholder-shown))`, and then it could never fall out
  // of step with the DOM. It is not used because the placeholder lives in
  // threads.html: the day someone edits that attribute away, the stylesheet
  // starts hiding drafts and nothing in either file says why. A class this file
  // sets is a contract between the two files that both of them name.
  function markDraft(compose) {
    compose.classList.toggle("ph-draft", hasDraft(compose));
  }

  // Shut a composer — unless it is holding a draft, in which case it stays open
  // and stays the reviewer's to dispose of. Returns whether it actually went,
  // so callers can keep `opened` honest.
  function dismissComposer(compose) {
    if (!compose || hasDraft(compose)) {
      return false;
    }
    compose.open = false;
    return true;
  }

  // Open one composer and, in the same move, put the last one away. Every
  // opening goes through here, so "one composer at a time" holds without any
  // caller having to remember it. A previous composer with a draft in it does
  // not go: two boxes are on the page then, but one of them has the reviewer's
  // words in it, which makes it content in the same way a posted thread is.
  function showComposer(compose) {
    if (opened && opened !== compose) {
      dismissComposer(opened);
    }
    opened = compose;
    compose.open = true;
  }

  // A gutter cell holds either one number ("12") or, for a region-fallback row,
  // a range ("12–15"). Reading every integer out of it covers both without the
  // caller having to know which kind of row it is looking at.
  function cellNumbers(cell) {
    if (!cell) {
      return [];
    }
    var found = cell.textContent.match(/\d+/g);
    return found ? found.map(Number) : [];
  }

  function rowNumbers(row, track) {
    return cellNumbers(row.querySelector(track === "D" ? ".ph-n-old" : ".ph-n-new"));
  }

  // The extent of a selection, preferring the new side. A row that exists only
  // on the old side (a deletion) has no new number at all, so a delete-only
  // selection is reported on the old track and marked "D" — never renumbered
  // onto the new side, which would point at a line the reviewer did not pick.
  function extent(rows) {
    var news = [];
    var olds = [];
    rows.forEach(function (row) {
      news = news.concat(rowNumbers(row, "L"));
      olds = olds.concat(rowNumbers(row, "D"));
    });
    var used = news.length ? news : olds;
    if (!used.length) {
      return null;
    }
    return {
      track: news.length ? "L" : "D",
      lo: Math.min.apply(null, used),
      hi: Math.max.apply(null, used)
    };
  }

  function hashFor(ext) {
    var one = ext.track + ext.lo;
    return "#" + (ext.hi > ext.lo ? one + "-" + ext.track + ext.hi : one);
  }

  function labelFor(ext) {
    var what = ext.track === "D" ? "Removed line" : "Line";
    if (ext.hi > ext.lo) {
      return what + "s " + ext.lo + EN_DASH + ext.hi;
    }
    return what + " " + ext.lo;
  }

  function parseHash(hash) {
    var m = /^#([LD])(\d+)(?:-[LD]?(\d+))?$/.exec(hash);
    if (!m) {
      return null;
    }
    var lo = Number(m[2]);
    var hi = m[3] === undefined ? lo : Number(m[3]);
    return { track: m[1], lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
  }

  // replaceState, not location.hash: assigning the hash would push a history
  // entry per drag and make the back button undo highlights instead of leaving
  // the page. The URL still round-trips, which is the link-sharing path.
  function writeHash(hash) {
    if (!window.history || !window.history.replaceState) {
      return;
    }
    window.history.replaceState(null, "", hash || window.location.pathname + window.location.search);
  }

  // One line above the diff saying the gutter is draggable.
  //
  // It is the price of hiding the per-block composers: a control that is only
  // reachable by an interaction nobody has been told about is not reachable.
  // ONCE PER DIFF, never per block — a hint repeated under every block would be
  // the very thing it replaced. It is written by this file rather than by the
  // template because with JavaScript off it would be false: there is no
  // selection then, and the composers are all still on the page.
  //
  // The wording follows what the reader can actually do: without a composer
  // (they are not the proposal's owner) selecting lines only writes the hash,
  // which is the link-sharing path, and promising them a comment box would be a
  // lie.
  function addHint(table, canComment) {
    var hint = document.createElement("p");
    hint.className = "ph-hint";
    hint.textContent = canComment
      ? "Drag across the line numbers to comment on those lines — or tab into the diff, walk it with ↑ ↓, and press Enter."
      : "Drag across the line numbers to select them — or tab into the diff and walk it with ↑ ↓. The address bar keeps the range.";
    table.parentNode.insertBefore(hint, table);
  }

  function setup(table) {
    var rows = Array.prototype.slice.call(table.querySelectorAll("tr.ph-row"));
    var notes = Array.prototype.slice.call(table.querySelectorAll("tr.ph-notes"));
    var order = new Map();
    rows.forEach(function (row, i) {
      order.set(row, i);
    });

    var selected = [];
    var origin = null; // the row the current selection was started from
    var dragging = false;

    function commentable(row) {
      return !!(row && row.dataset && row.dataset.anchor);
    }

    // Rows that carry no anchor (a move-out marker, say) are not part of any
    // block, so there is nothing a comment on them could attach to.
    var pickable = rows.filter(commentable);

    function composerFor(anchor) {
      var row = null;
      notes.forEach(function (n) {
        if (n.dataset.anchor === anchor) {
          row = n;
        }
      });
      return row ? row.querySelector("details.ph-compose") : null;
    }

    // The range is prefixed as its own node rather than by rewriting the note's
    // text, because the server-rendered note contains markup (<code>Goals ›
    // Non-goals</code>) that a textContent assignment would flatten. That is
    // also why putting it back is a removal of the two nodes this file added
    // and not a restore from a saved copy of the note's text: writing such a
    // copy back would have to go through textContent, which would cost the
    // <code> element the note is restored to.
    function noteOf(compose) {
      return compose ? compose.querySelector(".ph-anchor-note") : null;
    }

    function prefixNote(compose, text) {
      var note = noteOf(compose);
      if (!note) {
        return;
      }
      var mark = note.querySelector("strong.ph-sel-range");
      if (!mark) {
        mark = document.createElement("strong");
        mark.className = "ph-sel-range";
        note.insertBefore(document.createTextNode(" · "), note.firstChild);
        note.insertBefore(mark, note.firstChild);
      }
      mark.textContent = text;
    }

    function restoreNotes() {
      table.querySelectorAll(".ph-anchor-note strong.ph-sel-range").forEach(function (mark) {
        // A composer holding a draft keeps its range: the reviewer is writing
        // about those lines right now, and the sentence saying which lines they
        // are is as much part of the unposted comment as the text is. Every
        // other note goes back to the server's wording.
        var compose = mark.closest("details.ph-compose");
        if (compose && hasDraft(compose)) {
          return;
        }
        var sep = mark.nextSibling;
        if (sep && sep.nodeType === Node.TEXT_NODE) {
          sep.parentNode.removeChild(sep);
        }
        mark.parentNode.removeChild(mark);
      });
    }

    // The selection is over: the highlight, the range on the note and the
    // composer that selection opened all go. The composer is part of it because
    // it was opened by the selection and by nothing else — leaving it behind is
    // how the page ends up with a box under every block again.
    function clear() {
      selected.forEach(function (row) {
        row.classList.remove("ph-sel");
      });
      selected = [];
      origin = null;
      if (dismissComposer(opened)) {
        opened = null;
      }
      restoreNotes();
    }

    // Mark the run from `a` to `b`, dropping every row that belongs to another
    // block. This is the clamp, and it is deliberately silent: the reviewer's
    // drag simply stops adding rows at the block boundary.
    function mark(a, b) {
      var anchor = a.dataset.anchor;
      var i = order.get(a);
      var j = order.get(b);
      if (i > j) {
        var t = i;
        i = j;
        j = t;
      }
      selected.forEach(function (row) {
        row.classList.remove("ph-sel");
      });
      selected = [];
      for (var k = i; k <= j; k++) {
        if (rows[k].dataset.anchor !== anchor) {
          continue;
        }
        rows[k].classList.add("ph-sel");
        selected.push(rows[k]);
      }
    }

    // Called once the selection has settled (mouse up, Enter, shift-click).
    // Opening the composer is the whole point of selecting: the reviewer picked
    // lines in order to say something about them.
    function settle(openComposer) {
      if (!selected.length) {
        return;
      }
      var ext = extent(selected);
      // Some rows carry no number on either track — the "paragraph moved here
      // (was line 43)" marker is one, and it belongs to a block, so it is
      // selectable and does open that block's composer. It just has no line
      // range to name. The hash is then LEFT ALONE rather than emptied:
      // clearing the address is a thing the reviewer asks for with Escape, and
      // a row with nothing to say about line numbers must not silently throw
      // away a hash it did not write — which may be a range someone shared, or
      // may not be this file's at all.
      if (ext) {
        writeHash(hashFor(ext));
      }
      var compose = composerFor(selected[0].dataset.anchor);
      if (!compose) {
        return; // no composer: a reader without write access, link-sharing only
      }
      if (ext) {
        prefixNote(compose, labelFor(ext));
      }
      if (!openComposer) {
        return;
      }
      showComposer(compose);
      compose.scrollIntoView({ block: "nearest" });
      var body = compose.querySelector("textarea");
      if (body) {
        body.focus();
      }
    }

    // Roving tabindex: one stop for the whole table rather than one per line.
    // Tab reaches the diff, the arrows walk it, Enter comments on the line you
    // stopped at — a gutter with a thousand tab stops in it is not accessible,
    // it is a trap.
    pickable.forEach(function (row, i) {
      row.tabIndex = i === 0 ? 0 : -1;
    });

    function focusRow(row) {
      if (!row) {
        return;
      }
      pickable.forEach(function (other) {
        other.tabIndex = other === row ? 0 : -1;
      });
      row.focus();
    }

    function step(row, delta) {
      var i = pickable.indexOf(row);
      if (i < 0) {
        return null;
      }
      return pickable[i + delta] || null;
    }

    // A folded row cannot be scrolled to or read, so anything that points at
    // one opens its fold first. The fold is a checkbox, so "open it" is exactly
    // that — no separate JS state to keep in step with the CSS.
    //
    // Nothing here ever CLOSES a fold or refuses to let one close, including
    // when the reviewer has a composer open inside it. Refusing was considered
    // — a fold that shuts over an open composer used to take a half-written
    // comment off the screen with it — and lost: a control that ignores the
    // click it was given is worse than one whose effect can be seen, and
    // policing the checkbox would put the fold's state in two places, which is
    // exactly what this function avoids. The composer survives because a draft
    // keeps its row drawn through a closed fold (see the `ph-draft` rule in
    // scss/main.scss); an empty one is chrome and folds away with the lines,
    // still open, and comes back with them.
    function reveal(row) {
      if (!row.classList.contains("ph-folded")) {
        return;
      }
      var body = row.closest("tbody.ph-fold");
      var cb = body ? body.querySelector(".ph-fold-cb") : null;
      if (cb) {
        cb.checked = true;
      }
    }

    table.addEventListener("mousedown", function (ev) {
      var cell = ev.target.closest ? ev.target.closest("td.ph-n") : null;
      if (!cell || ev.button !== 0) {
        return;
      }
      var row = cell.closest("tr.ph-row");
      if (!commentable(row)) {
        return;
      }
      // Suppress the browser's own text selection: dragging down the gutter
      // would otherwise sweep the prose beside it into a native selection.
      ev.preventDefault();
      controllers.forEach(function (c) {
        if (c.table !== table) {
          c.clear();
        }
      });
      if (ev.shiftKey && origin) {
        mark(origin, row);
      } else {
        restoreNotes();
        origin = row;
        mark(row, row);
      }
      dragging = true;
      focusRow(row);
    });

    table.addEventListener("mousemove", function (ev) {
      if (!dragging || !origin) {
        return;
      }
      var row = ev.target.closest ? ev.target.closest("tr.ph-row") : null;
      if (commentable(row)) {
        mark(origin, row);
      }
    });

    // Every keystroke in a composer, so `ph-draft` is true the moment there is
    // something to lose rather than at the next selection. `input` bubbles, so
    // one listener per table covers every composer in it.
    table.addEventListener("input", function (ev) {
      var compose = ev.target.closest ? ev.target.closest("details.ph-compose") : null;
      if (compose) {
        markDraft(compose);
      }
    });

    // Closing a composer from its own summary. The stylesheet stops drawing a
    // notes row whose composer is shut and which has no threads, so that click
    // takes the summary away with it: the control deletes the control. Read as
    // a deliberate cancel it becomes honest instead — the selection, the range
    // on the note and the hash go at the same moment, so the row leaving is
    // plainly the consequence of the click, and re-selecting the lines brings
    // it all back. The alternative was to keep the row drawn while its block is
    // selected; it was dropped because it puts a shut "Comment on this block"
    // summary back on the page, which is the chrome the hiding rule exists to
    // remove, and because it leaves the reviewer with a cancel that cancels
    // nothing.
    //
    // The default action has to go: `details` toggles itself AFTER the click is
    // dispatched, so shutting it here and letting the browser proceed would
    // toggle it straight back open.
    //
    // A composer with a draft in it is not touched — it collapses natively,
    // keeps its text, and `ph-draft` keeps its row (and so its summary) drawn,
    // which is the only reason collapsing it is not the same trap.
    table.addEventListener("click", function (ev) {
      var summary = ev.target.closest ? ev.target.closest("summary") : null;
      var compose = summary ? summary.parentNode : null;
      if (!compose || !compose.classList.contains("ph-compose") || !compose.open) {
        return; // opening one, or a reply form's summary: not this
      }
      if (compose !== opened || hasDraft(compose)) {
        return; // not the one the selection opened, or it is holding words
      }
      ev.preventDefault();
      compose.open = false;
      opened = null;
      clear();
      writeHash("");
    });

    // On the document, not the table: a drag that ends past the last row still
    // has to end, or the next mouse move keeps painting.
    document.addEventListener("mouseup", function () {
      if (!dragging) {
        return;
      }
      dragging = false;
      settle(true);
    });

    table.addEventListener("keydown", function (ev) {
      var row = ev.target.closest ? ev.target.closest("tr.ph-row") : null;
      if (!commentable(row)) {
        return;
      }
      var next = null;
      if (ev.key === "ArrowDown") {
        next = step(row, 1);
      } else if (ev.key === "ArrowUp") {
        next = step(row, -1);
      } else if (ev.key === "Enter" || ev.key === " ") {
        ev.preventDefault();
        // Enter on a row that is already part of the selection means "comment
        // on what I picked", not "start again from here" — otherwise a range
        // built with Shift+Arrow collapses to one line the moment the reviewer
        // asks to comment on it.
        if (selected.indexOf(row) < 0) {
          restoreNotes();
          origin = row;
          mark(row, row);
        }
        settle(true);
        return;
      } else {
        return;
      }
      if (!next) {
        return;
      }
      ev.preventDefault();
      reveal(next);
      focusRow(next);
      if (ev.shiftKey) {
        // Shift+Arrow with nothing selected yet starts the selection at the row
        // the reviewer was standing on, the way a text cursor does. Without
        // this the keyboard path has no way in: it can only extend a selection
        // that the mouse made.
        if (!origin) {
          origin = row;
        }
        mark(origin, next);
        settle(false);
      }
    });

    function restoreFromHash() {
      var want = parseHash(window.location.hash);
      if (!want) {
        return false;
      }
      var hit = [];
      var anchor = null;
      rows.forEach(function (row) {
        if (!commentable(row)) {
          return;
        }
        var covered = rowNumbers(row, want.track).some(function (n) {
          return n >= want.lo && n <= want.hi;
        });
        if (!covered) {
          return;
        }
        if (anchor === null) {
          anchor = row.dataset.anchor;
        }
        // The clamp applies to a restored selection too: a hash naming lines
        // that straddle two blocks is not a selection this UI could have made.
        if (row.dataset.anchor === anchor) {
          hit.push(row);
        }
      });
      if (!hit.length) {
        return false;
      }
      hit.forEach(reveal);
      origin = hit[0];
      mark(hit[0], hit[hit.length - 1]);
      // The range is put on the composer's note, but the composer is left shut:
      // arriving from a link is reading, not yet writing.
      settle(false);
      hit[0].scrollIntoView({ block: "center" });
      focusRow(hit[0]);
      return true;
    }

    // `ph-draft` is derived from the textarea, so it is read off the textarea
    // here rather than assumed to start false. A browser may bring back what
    // was typed into a form when the page is reloaded, and it does that without
    // firing an input event — Chrome 150 measurably does not restore this
    // particular form, but the class is supposed to be the answer to "is there
    // text in there", and one pass over sixteen elements is cheaper than
    // depending on which browsers restore what.
    table.querySelectorAll("details.ph-compose").forEach(markDraft);

    // Last, once every listener above is attached and nothing has thrown: the
    // table declares itself enhanced. This is what lets the stylesheet stop
    // drawing the per-block composers, so it must not be set on a table whose
    // selection never got wired — that table's readers would be left with no
    // way to comment at all. A table with no pickable row (a diff that is one
    // uncommentable move marker, say) gets neither the class nor the hint,
    // because there is nothing there to select.
    if (pickable.length) {
      table.classList.add("ph-js");
      addHint(table, !!table.querySelector("details.ph-compose"));
    }

    var controller = { table: table, clear: clear, restoreFromHash: restoreFromHash };
    controllers.push(controller);
    return controller;
  }

  function init() {
    document.querySelectorAll("table.ph-diff").forEach(setup);

    // Escape drops the selection: the highlight goes, the note goes back to
    // what the server wrote, the composer the selection opened shuts unless it
    // is holding a draft, and the URL stops claiming a range.
    document.addEventListener("keydown", function (ev) {
      if (ev.key !== "Escape") {
        return;
      }
      controllers.forEach(function (c) {
        c.clear();
      });
      writeHash("");
    });

    controllers.some(function (c) {
      return c.restoreFromHash();
    });
    window.addEventListener("hashchange", function () {
      controllers.forEach(function (c) {
        c.clear();
      });
      controllers.some(function (c) {
        return c.restoreFromHash();
      });
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();