// 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();
}
})();