When visiting an individual post revision page, this script adds buttons that allow switching to a view showing how the revision differed from the previous one, just like on a full post history page.
Test cases:
- https://stackoverflow.com/revisions/76506701/2, https://stackoverflow.com/revisions/76506506/2 (and inspiration).
- title-only edit: https://stackoverflow.com/revisions/76909851/4
- tag-only edit: https://retrocomputing.stackexchange.com/revisions/27531/6
- both-only edit: https://stackoverflow.com/revisions/40649044/2
- edits that change the mark-up, but rendered text: https://meta.stackexchange.com/revisions/328422/5, https://meta.stackexchange.com/revisions/393030/5.
Pretty hacky; some scraping involved. May break as the site is updated. Consider this script an elaborate feature request (viz).
// ==UserScript==
// @name Stack Exchange: revision diff view
// @grant none
// @run-at document-start
// @match https://*.stackexchange.com/*
// @match https://*.superuser.com/*
// @match https://*.stackoverflow.com/*
// @match https://*.mathoverflow.net/*
// @match https://*.serverfault.com/*
// @match https://*.askubuntu.com/*
// @match https://stackapps.com/*
// @exclude https://chat.stackexchange.com/*
// @exclude https://api.stackexchange.com/*
// @exclude https://data.stackexchange.com/*
// @exclude https://openid.stackexchange.com/*
// @exclude https://area51.stackexchange.com/*
// @exclude https://stackexchange.com/*
// @exclude https://contests.stackoverflow.com/*
// @exclude /^https?:\/\/winterbash\d{4,}\.stackexchange\.com\//
// ==/UserScript==
// we scrape the DOM instead of using api.stackexchange.com so that
// this works even for deleted revisions which the user has the privilege
// of viewing
const getRevisionUuid = async (postId, revNum) => {
const fetchDOM = (url) => new Promise((ok, ko) => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'document';
xhr.onload = () => {
ok(xhr.response);
};
xhr.onerror = (ev) => {
ko(ev);
};
xhr.onabort = (ev) => {
ko(ev);
};
xhr.open('GET', url);
xhr.send(null);
});
const REV_SELECTOR = 'button.js-revision-expander';
const getRevNum = (node) =>
node.querySelector('.fs-headline1').textContent;
const scanButton = (revisionsDom) => {
for (const node of revisionsDom.querySelectorAll(REV_SELECTOR)) {
if (getRevNum(node) === revNum)
return node.dataset.guid;
}
return null;
};
const revsPage1 = await fetchDOM(new URL(`/posts/${postId}/revisions`, location));
let m;
if (m = scanButton(revsPage1))
return m;
const revsPage1Revs = revsPage1.querySelectorAll(REV_SELECTOR);
const revsPerPage = revsPage1Revs.length;
// the revision count can also be read from the viewed revision's page
// (#content > .subheader:first-child + * > .fs-headline1),
// but it's only in locale-dependent text and requires the page to be fully loaded
// and we need to know the number of revisions per history page anyway
const revLast = getRevNum(revsPage1Revs[0]);
const page = 1 + Math.floor((revLast - revNum) / revsPerPage);
const revsPageN = await fetchDOM(
new URL(`/posts/${postId}/revisions?page=${page}`, location));
if (m = scanButton(revsPageN))
return m;
}
const contentLoaded = new Promise((ok, ko) => {
switch (document.readyState) {
case 'complete':
case 'interactive':
ok();
return;
default:
document.addEventListener('DOMContentLoaded', () => ok(), { once: true });
}
});
const loadScript = url => new Promise((ok, ko) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
ok();
};
script.onerror = (ev) => {
ko(ev);
};
document.head.appendChild(script);
script.remove();
});
const html = (() => {
const html = function html(pieces, ...values) {
if (new.target)
return Reflect.construct(String, arguments, new.target);
return new html(
String.raw({ raw: pieces }, ...values.map(
s => s instanceof html
? s
: `${s}`.replaceAll(/[<>&"]/ug, c => `&#${c.charCodeAt(0)};`))));
}
html.prototype = Object.create(String.prototype);
return html;
})();
(async () => {
const rxPathnameRev = /^\/revisions\/(\d+)\/(\d+)$/u;
let m;
if (!(m = rxPathnameRev.exec(location.pathname)))
return;
const [, postId, revNum] = m;
// do not load if revision number is 1
if (revNum === '1')
return;
(async () => {
try {
// should suffice as the Stack Exchange scripts are synchronous
await contentLoaded;
await loadScript(`//cdn.sstatic.net/Js/revisions.${unsafeWindow.StackExchange.options.locale}.js`);
unsafeWindow.styleCode ??= unsafeWindow.Function('');
unsafeWindow.StackExchange.revisions.init();
} catch (e) {
console.warn(`[RDV] revisions.${unsafeWindow.StackExchange.options.locale}.js not loaded:`, e);
}
})();
const revUuid = await getRevisionUuid(postId, revNum);
if (!revUuid) {
console.error(`[RDV] revision ${revNum} of post ${postId} not found`);
return;
}
console.info(`[RDV] revision ${revNum} of post ${postId} identified as ${revUuid}`)
const resp = await fetch(new URL(`/revisions/${postId}/${revUuid}/diff?full=false`, location));
const frag = document.createRange().createContextualFragment(await resp.text());
const fragDiffs = frag.querySelector('.diffs');
const fragTabs = (() => {
const fragTabs = frag.querySelector('.js-diff-choices');
if (!fragTabs) {
// probably a (tag-and/or-title)-only edit
fragDiffs.insertAdjacentHTML('beforebegin', html`
<div class="mb24">
<div class="js-diff-choices s-btn-group"
role="tablist"
data-controller="s-navigation-tablist"
aria-label="Comparison modes">
<button
type="button" role="tab"
class="s-btn s-btn__muted s-btn__outlined s-btn__xs action"
id="tab-${revUuid}-Mini"
aria-controls="panel-${revUuid}-Mini"
tabindex="0" title="Show the edits of the title and/or tag list">
Title and/or tag diff
</button>
</div>
</div>
`);
fragDiffs.insertAdjacentHTML('afterbegin', html`
<div
id="panel-${revUuid}-Mini" role="tabpanel"
aria-labelledby="tab-${revUuid}-Mini"
class="js-diff-panel mb16 d-none">
</div>
`);
const container = fragDiffs.firstElementChild;
while (container.nextSibling)
container.appendChild(container.nextSibling);
return fragDiffs.previousElementSibling.querySelector('.js-diff-choices');
}
return fragTabs;
})();
for (const node of fragTabs.querySelectorAll(':scope > [role="tab"]')) {
node.classList.remove('is-selected');
node.removeAttribute('aria-selected');
}
for (const node of fragDiffs.querySelectorAll(':scope > [role="tabpanel"]')) {
node.classList.add('d-none');
}
fragTabs.insertAdjacentHTML('afterbegin', html`
<button
id="tab-${revUuid}-Contents"
role="tab"
class="s-btn s-btn__muted s-btn__outlined s-btn__xs action is-selected"
tabindex="-1"
title="Show just this revision's contents, no diff against the previous revision"
aria-controls="panel-${revUuid}-Contents"
aria-selected="true">Contents only</button>
`);
fragDiffs.insertAdjacentHTML('afterbegin', html`
<div
id="panel-${revUuid}-Contents"
role="tabpanel"
class="js-diff-panel mb16"
aria-labelledby="tab-${revUuid}-Contents"></div>
`);
const baseDest = fragDiffs.firstElementChild;
await contentLoaded;
let anchor = document.querySelector(`
#content > h1.fs-title,
#content > .s-prose
`).previousSibling;
const limit = document.querySelector('#content > .jc-end');
for (;;) {
const node = anchor;
if (node === limit)
break;
anchor = anchor.nextSibling;
baseDest.appendChild(node);
}
document.querySelector('#content').insertBefore(
frag,
limit
);
})();