// meld.js — draws what a conflicting landing's three panes cannot draw
// for themselves: the bands joining each marked block in ours or theirs
// to its place in the result, the overview strip down the right edge,
// and stepping from one conflict to the next. The panes are complete
// without it; the server already marked every block (meld.go).
//
// Where the reader may resolve (#343) it also keeps their answers: an
// arrow takes a side's lines into the result, "both" keeps the two one
// after the other, "neither" keeps what both started from, "edit" takes
// typed lines, and a file that is not text is answered whole. Commit
// sends the answers to landing.resolve; quarry rebuilds each file from
// the merge's own regions, so nothing here has to hold a whole file.
(function () {
"use strict";
var colour = { cf: "var(--meld-cf)", chg: "var(--meld-chg)", ins: "var(--meld-ins)" };
function kindOf(el) {
if (el.classList.contains("cf")) { return "cf"; }
if (el.classList.contains("chg")) { return "chg"; }
if (el.classList.contains("ins")) { return "ins"; }
return "";
}
function mount(m, resolving) {
var file = m.closest(".meld-file");
var pane = function (p) { return m.querySelector('[data-pane="' + p + '"]'); };
var seg = function (p, id) { return pane(p).querySelector('.seg[data-id="' + id + '"]'); };
var conflicts = Array.prototype.slice.call(pane("M").querySelectorAll(".seg.cf"));
var cur = -1;
function bands() {
["L", "R"].forEach(function (side) {
var g = m.querySelector('[data-gut="' + side + '"]'), svg = g.querySelector("svg");
g.querySelectorAll(".gbtn").forEach(function (b) { b.remove(); });
if (getComputedStyle(g).display === "none") { svg.innerHTML = ""; return; }
var gr = g.getBoundingClientRect(), w = gr.width, h = w / 2, out = "";
pane(side).querySelectorAll(".seg.cf, .seg.chg, .seg.ins").forEach(function (a) {
var mid = seg("M", a.dataset.id);
if (!mid) { return; }
var ar = a.getBoundingClientRect(), mr = mid.getBoundingClientRect();
var x0 = side === "L" ? 0 : w, x1 = side === "L" ? w : 0;
var ya = ar.top - gr.top, yb = Math.max(ar.bottom - gr.top, ya + 1);
var ma = mr.top - gr.top, mb = Math.max(mr.bottom - gr.top, ma + 1);
var c = colour[kindOf(a)];
out += '<path d="M' + x0 + "," + ya + " C" + h + "," + ya + " " + h + "," + ma + " " + x1 + "," + ma +
" L" + x1 + "," + mb + " C" + h + "," + mb + " " + h + "," + yb + " " + x0 + "," + yb +
' Z" fill="' + c + '" fill-opacity=".28" stroke="' + c + '" stroke-opacity=".7"/>';
// Every block that differs from the result gets an arrow, as in
// Meld: on a conflict it settles it, elsewhere it puts this
// side's lines back over the merge's.
if (resolving && a.dataset.region) {
var b = document.createElement("button");
b.type = "button";
b.className = "gbtn";
b.textContent = side === "L" ? "→" : "←";
b.title = "Take " + (side === "L" ? "ours" : "theirs") + " here";
b.setAttribute("aria-label", b.title);
b.dataset.act = side;
b.dataset.region = a.dataset.region;
b.style.top = (ya + 2) + "px";
b.style[side === "L" ? "left" : "right"] = "2px";
g.appendChild(b);
}
});
svg.innerHTML = out;
});
overview();
}
// The strip maps the whole result: each marked block at its place,
// the current conflict outlined; a mark jumps there.
function overview() {
var o = m.querySelector(".ovr");
if (!o || getComputedStyle(o).display === "none") { return; }
o.innerHTML = "";
var inner = pane("M").querySelector(".inner"), ir = inner.getBoundingClientRect();
var head = pane("M").querySelector(".phead").getBoundingClientRect().height;
var span = o.getBoundingClientRect().height - head;
if (ir.height <= 0 || span <= 0) { return; }
pane("M").querySelectorAll(".seg.cf, .seg.chg, .seg.ins").forEach(function (s) {
var r = s.getBoundingClientRect(), i = document.createElement("i");
i.style.top = (head + (r.top - ir.top) / ir.height * span) + "px";
i.style.height = Math.max(3, r.height / ir.height * span) + "px";
i.style.background = colour[kindOf(s)];
if (conflicts[cur] === s) { i.className = "cur"; }
i.title = kindOf(s) === "cf" ? "conflict" : "change";
i.addEventListener("click", function () {
var k = conflicts.indexOf(s);
if (k >= 0) { go(k); } else { s.scrollIntoView({ block: "center", behavior: "smooth" }); }
});
o.appendChild(i);
});
}
function go(k) {
if (!conflicts.length) { return; }
cur = (k + conflicts.length) % conflicts.length;
conflicts.forEach(function (c, i) { c.classList.toggle("cur", i === cur); });
var pos = file.querySelector("[data-meld-pos]");
if (pos) { pos.textContent = "conflict " + (cur + 1) + " of " + conflicts.length; }
conflicts[cur].scrollIntoView({ block: "center", behavior: "smooth" });
overview();
}
file.querySelectorAll("[data-meld-step]").forEach(function (b) {
b.addEventListener("click", function () { go(cur < 0 ? (b.dataset.meldStep > 0 ? 0 : -1) : cur + Number(b.dataset.meldStep)); });
});
// A long line scrolls its own pane sideways; the conflict label
// stays as wide as the pane it sits in.
function fit() {
var w = pane("M").clientWidth;
pane("M").querySelectorAll(".tagline").forEach(function (t) { t.style.width = w + "px"; });
bands();
}
["L", "M", "R"].forEach(function (p) { pane(p).addEventListener("scroll", bands, { passive: true }); });
window.addEventListener("resize", fit);
if (document.fonts && document.fonts.ready) { document.fonts.ready.then(fit); }
m.meldRedraw = fit;
fit();
}
// ---- resolving (#343) ----------------------------------------------
function resolver(view) {
var answers = {};
var key = view.dataset.draft;
var files = Array.prototype.slice.call(view.querySelectorAll("[data-hook=meld-file]"));
var form = view.querySelector("[data-hook=meld-commit]");
// What the result showed before any answer: put back on Reset or
// Change, and "neither" takes its lines.
// Keyed by file and region, not by element: drawing an answer
// replaces the result's segment.
var orig = {};
files.forEach(function (f) {
f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (s) {
orig[f.dataset.path + "\u0000" + s.dataset.region] = s.cloneNode(true);
});
});
function origOf(path, region) { return orig[path + "\u0000" + region]; }
// Each side's marks as the server drew them, put back when an
// answer is withdrawn.
var sideCls = {};
files.forEach(function (f) {
["L", "R"].forEach(function (p) {
f.querySelectorAll('[data-pane="' + p + '"] .seg[data-region]').forEach(function (s) {
sideCls[f.dataset.path + "\u0000" + s.dataset.region + "\u0000" + p] = s.className;
});
});
});
function pathOf(el) { return el.closest("[data-hook=meld-file]").dataset.path; }
function fileOf(path) {
return files.filter(function (f) { return f.dataset.path === path; })[0];
}
function segOf(f, pane, region) {
return f.querySelector('[data-pane="' + pane + '"] .seg[data-region="' + region + '"]');
}
function lineNodes(s) { return s ? Array.prototype.slice.call(s.querySelectorAll(":scope > .l")) : []; }
function texts(nodes) { return nodes.map(function (l) { return l.querySelector(".t").textContent; }); }
function plain(lines) {
return lines.map(function (t) {
var l = document.createElement("div"), n = document.createElement("span"), x = document.createElement("span");
l.className = "l";
n.className = "n";
x.className = "t";
x.textContent = t;
l.appendChild(n);
l.appendChild(x);
return l;
});
}
var said = { L: "ours", R: "theirs", LR: "both · ours first", RL: "both · theirs first", N: "neither", E: "your edit" };
// answer records one choice; the lines travel with it, because
// landing.resolve takes lines, not choices.
function answer(path, region, act, typed) {
var f = fileOf(path), L = segOf(f, "L", region), R = segOf(f, "R", region);
var lines;
switch (act) {
case "L": lines = texts(lineNodes(L)); break;
case "R": lines = texts(lineNodes(R)); break;
case "LR": lines = texts(lineNodes(L)).concat(texts(lineNodes(R))); break;
case "RL": lines = texts(lineNodes(R)).concat(texts(lineNodes(L))); break;
case "N": lines = texts(lineNodes(origOf(path, region))); break;
case "E": lines = typed; break;
default: return;
}
answers[path] = answers[path] || { take: "regions", regions: {} };
answers[path].regions[region] = { act: act, lines: lines };
changed();
}
function drawRegion(f, region) {
var m = segOf(f, "M", region), a = (answers[f.dataset.path] || { regions: {} }).regions[region];
var fresh = origOf(f.dataset.path, region).cloneNode(true);
if (a) {
var L = segOf(f, "L", region), R = segOf(f, "R", region), nodes;
switch (a.act) {
case "L": nodes = lineNodes(L); break;
case "R": nodes = lineNodes(R); break;
case "LR": nodes = lineNodes(L).concat(lineNodes(R)); break;
case "RL": nodes = lineNodes(R).concat(lineNodes(L)); break;
case "N": nodes = lineNodes(origOf(f.dataset.path, region)); break;
default: nodes = null;
}
lineNodes(fresh).forEach(function (l) { l.remove(); });
(nodes ? nodes.map(function (n) { return n.cloneNode(true); }) : plain(a.lines)).forEach(function (l) { fresh.appendChild(l); });
fresh.classList.remove("cf", "chg", "ins", "gap", "cur");
fresh.classList.add("done");
var tag = fresh.querySelector(".tagline");
if (!tag) {
tag = document.createElement("div");
tag.className = "tagline";
fresh.insertBefore(tag, fresh.firstChild);
}
tag.innerHTML = "";
var t = document.createElement("span");
t.textContent = "took " + said[a.act];
var undo = document.createElement("button");
undo.type = "button";
undo.className = "both";
undo.dataset.act = "U";
undo.textContent = "change";
tag.appendChild(t);
tag.appendChild(undo);
}
m.replaceWith(fresh);
// As in Meld, a side is marked where it differs from the result:
// once a region is answered, by how it differs from the answer.
var mine = texts(lineNodes(fresh)).join("\n");
["L", "R"].forEach(function (p) {
var side = segOf(f, p, region);
if (!side) { return; }
if (!a) {
side.className = sideCls[f.dataset.path + "\u0000" + region + "\u0000" + p];
return;
}
var lines = texts(lineNodes(side));
side.className = "seg";
if (lines.join("\n") !== mine || lines.length !== lineNodes(fresh).length) {
side.classList.add(!lines.length || !lineNodes(fresh).length ? "ins" : "chg");
if (!lines.length) { side.classList.add("gap"); }
}
});
}
// Each pane numbers its own lines; the result renumbers as answers
// change its length, counting a fold as the lines it stands for.
function renumber(f) {
var n = 1;
f.querySelectorAll('[data-pane="M"] .seg').forEach(function (s) {
if (s.dataset.fold) { n += Number(s.dataset.fold); return; }
lineNodes(s).forEach(function (l) { l.querySelector(".n").textContent = n++; });
});
}
function drawPhone(f) {
f.querySelectorAll("[data-hook=phone-conflict]").forEach(function (pc) {
var region = pc.dataset.region, a = (answers[f.dataset.path] || { regions: {} }).regions[region];
var took = pc.querySelector("[data-took]"), acts = pc.querySelector("[data-acts]");
pc.classList.toggle("pdone", !!a);
pc.querySelectorAll(".pb, .pbase").forEach(function (b) { b.hidden = !!a; });
acts.hidden = !!a;
took.hidden = !a;
took.innerHTML = "";
if (!a) { return; }
// What it took, in place in the result: three lines either side,
// numbered in the result file.
var m = segOf(f, "M", region), all = Array.prototype.slice.call(f.querySelectorAll('[data-pane="M"] .l'));
var mine = lineNodes(m), first = all.indexOf(mine[0]), last = all.indexOf(mine[mine.length - 1]);
if (first < 0) {
var prev = m.previousElementSibling;
while (prev && !lineNodes(prev).length) { prev = prev.previousElementSibling; }
first = last = prev ? all.indexOf(lineNodes(prev).slice(-1)[0]) + 1 : 0;
last = first - 1;
}
var head = document.createElement("div");
head.className = "pbl";
head.textContent = "took " + said[a.act];
var body = document.createElement("div");
body.className = "pbc chroma";
all.slice(Math.max(0, first - 3), first).forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pctx"); body.appendChild(c); });
mine.forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pconf"); body.appendChild(c); });
if (!mine.length) { body.appendChild(plain(["(nothing here now)"])[0]); }
all.slice(last + 1, last + 4).forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pctx"); body.appendChild(c); });
var change = document.createElement("button");
change.type = "button";
change.className = "btn-quiet";
change.dataset.act = "U";
change.textContent = "Change";
took.appendChild(head);
took.appendChild(body);
took.appendChild(change);
});
}
function drawWhole(f) {
var w = f.querySelector("[data-whole-file]");
if (!w) { return; }
var a = answers[f.dataset.path];
w.querySelector("[data-whole-took]").textContent = a ? "took " + (a.take === "delete" ? "delete" : a.take) : "";
w.querySelectorAll("[data-whole]").forEach(function (b) { b.setAttribute("aria-pressed", String(!!a && a.take === b.dataset.whole)); });
}
function open() {
var left = 0;
files.forEach(function (f) {
if (f.querySelector("[data-whole-file]")) {
if (!answers[f.dataset.path]) { left++; }
return;
}
f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) {
var o = origOf(f.dataset.path, m.dataset.region) || m;
if (o.classList.contains("cf") && !(answers[f.dataset.path] && answers[f.dataset.path].regions[m.dataset.region])) { left++; }
});
});
return left;
}
function changed() {
files.forEach(function (f) {
if (f.querySelector("[data-whole-file]")) { drawWhole(f); return; }
f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) { drawRegion(f, m.dataset.region); });
renumber(f);
drawPhone(f);
var meld = f.querySelector("[data-meld]");
if (meld && meld.meldRedraw) { meld.meldRedraw(); }
});
var left = open();
if (form) {
form.querySelector("[data-meld-left]").textContent = left ? left + " left to answer" : "every conflict answered";
form.querySelector("[data-meld-submit]").disabled = left > 0;
}
try { localStorage.setItem(key, JSON.stringify(answers)); } catch (e) { /* a draft is a convenience */ }
}
function edit(path, region, host) {
var f = fileOf(path), a = (answers[path] || { regions: {} }).regions[region];
var start = a ? a.lines : texts(lineNodes(segOf(f, "L", region))).concat(texts(lineNodes(segOf(f, "R", region))));
var box = document.createElement("div");
box.className = "medit";
var ta = document.createElement("textarea");
ta.className = "ped";
ta.rows = Math.max(3, start.length + 1);
ta.spellcheck = false;
ta.value = start.join("\n");
var use = document.createElement("button");
use.type = "button";
use.className = "btn-primary";
use.textContent = "Use this";
var cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn-quiet";
cancel.textContent = "Cancel";
box.appendChild(ta);
box.appendChild(cancel);
box.appendChild(use);
host.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () { box.remove(); });
use.addEventListener("click", function () {
box.remove();
answer(path, region, "E", ta.value === "" ? [] : ta.value.split("\n"));
});
}
view.addEventListener("click", function (e) {
var b = e.target.closest("button");
if (!b || !view.contains(b)) { return; }
var path = b.closest("[data-hook=meld-file]") ? pathOf(b) : null;
if (b.dataset.whole) {
answers[path] = { take: b.dataset.whole };
return changed();
}
if (b.dataset.takeAll) {
var f = fileOf(path);
f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) {
var have = answers[path] && answers[path].regions[m.dataset.region];
if (origOf(path, m.dataset.region).classList.contains("cf") && !have) { answer(path, m.dataset.region, b.dataset.takeAll); }
});
return;
}
if (b.hasAttribute("data-meld-reset")) {
answers = {};
return changed();
}
var act = b.dataset.act;
if (!act) { return; }
var region = b.dataset.region || (b.closest("[data-region]") || {}).dataset.region;
if (region === undefined) { return; }
if (act === "U") {
delete answers[path].regions[region];
return changed();
}
if (act === "E") {
var host = b.closest("[data-hook=phone-conflict]") || b.closest(".seg");
return edit(path, region, host);
}
answer(path, region, act);
});
if (form) {
form.addEventListener("submit", function (e) {
if (open() > 0) { e.preventDefault(); return; }
var out = [];
files.forEach(function (f) {
var a = answers[f.dataset.path];
if (f.querySelector("[data-whole-file]")) {
out.push({ path: f.dataset.path, take: a.take });
return;
}
var regions = [];
Object.keys((a && a.regions) || {}).forEach(function (k) {
regions.push({ index: Number(k), lines: a.regions[k].lines });
});
out.push({ path: f.dataset.path, take: "regions", regions: regions });
});
form.querySelector('[name="answers"]').value = JSON.stringify({
branchTip: view.dataset.branchTip, targetTip: view.dataset.targetTip, files: out });
try { localStorage.removeItem(key); } catch (err) { /* nothing kept */ }
});
}
// A draft survives a reload, for this landing and these two tips only:
// once either moves the regions are different ones, and the old draft
// is dropped rather than applied to them.
try {
var stem = key.split("/").slice(0, 2).join("/") + "/";
for (var i = localStorage.length - 1; i >= 0; i--) {
var k = localStorage.key(i);
if (k && k.indexOf(stem) === 0 && k !== key) { localStorage.removeItem(k); }
}
var kept = JSON.parse(localStorage.getItem(key) || "null");
if (kept && typeof kept === "object") { answers = kept; }
} catch (e) { /* start from nothing */ }
changed();
}
document.querySelectorAll("[data-meld]").forEach(function (m) {
mount(m, !!m.closest("[data-resolve]"));
});
document.querySelectorAll("[data-resolve]").forEach(resolver);
})();