// 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
holding a plain form POSTing to // //p//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 (Goals › // Non-goals) 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 // 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(); } })();