// ==UserScript==
// @name Twitter - clickable links to images and show uncropped thumbnails
// @namespace twitter_linkify
// @version 2.1.0
// @license GNU AGPLv3
// @description Supports new Twitter UI. Linkifies all image posts in Twitter Home, other blog streams and single post views. These links point to the "orig" version of the image. Thumbnail images in the stream are modified to display uncropped.
// @author marp
// @homepageURL https://gf.qytechs.cn/en/users/204542-marp
// @include https://twitter.com/
// @include https://twitter.com/*
// @include https://pbs.twimg.com/media/*
// @exclude https://twitter.com/settings
// @exclude https://twitter.com/settings/*
// @run-at document-end
// ==/UserScript==
function new_adjustSingleMargin(myNode) {
// I SHOULD remove only margin-... values - but there never seems to be anything else - so go easy way and remove ALL style values
var myStyle = myNode.getAttribute("style");
if ((myStyle !== null) && (myStyle.includes("margin"))) {
myNode.setAttribute("style", "");
}
}
function new_adjustSingleBackgroundSize(myNode) {
var myStyle = myNode.getAttribute("style");
if ((myStyle !== null) && !(myStyle.includes("contain"))) {
myNode.style.backgroundSize = "contain";
}
}
function new_createSingleImageLink(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch;
var singlelink;
singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlelink = singlematch.singleNodeValue;
if (singlelink !== null) {
// persistently remove "margin-..." styles (they "de-center" the images)
singlematch=myDoc.evaluate(".//div[@aria-label='Image']",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
new_adjustSingleMargin(singlenode);
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
new_adjustSingleMargin(mutation.target);
});
});
var config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
observer.observe(singlenode, config);
}
// persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
new_adjustSingleBackgroundSize(singlenode)
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
new_adjustSingleBackgroundSize(mutation.target);
});
});
var config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
observer.observe(singlenode, config);
}
// change the link to point to the "orig" version of the image directly
singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
var imgurl = new URL(singlenode.getAttribute("src"));
var params = new URLSearchParams(imgurl.search.substring(1));
params.set("name", "orig");
imgurl.search = "?" + params.toString();
singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
singlenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
singlenode.href = imgurl.href;
}
}
}
}
}
function new_processImages(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var singlenode=singlematch.singleNodeValue;
if (singlenode !== null) {
new_createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
} else {
// this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
new_createSingleImageLink(myDoc, el);
}
}
}
}
function new_observeArticles(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch;
var matches;
matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
new_processImages(myDoc, el);
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(addedNode) {
new_processImages(mutation.target.ownerDocument, addedNode);
});
});
});
var config = { attributes: false, childList: true, characterData: false, subtree: true };
observer.observe(el, config);
}
}
}
function old_createImageLinks(myDoc, myContext) {
//console.info("createImageLinks: ", myContext);
if (myDoc===null) myDoc= myContext;
if (myDoc===null) return;
if (myContext===null) myContext= myDoc;
var matches;
var tmpstr;
matches=myDoc.evaluate(".//div[contains(@class,'AdaptiveMedia-photoContainer')]/img",
myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
if (el) {
try {
// console.info("matched-element: ", el);
tmpstr=getCleanImageURL(el.getAttribute("src"), false);
// console.info("cleanurl: ", tmpstr);
// only need ":small" variant for stream/thumbnail display (save bandwidth and increase performance)
el.setAttribute("src", tmpstr+":small");
// create correct link to ":orig" image - best way to access is by opening in new tab via "middle-click")
insertLinkElement(myDoc, el, tmpstr+":orig", getFilename(tmpstr));
// try to scale the thumbnail image so that it displays fully and uncropped within the available space
// This does not do a real aspect ratio calc but uses a "trick" by analysing how Twitter was positioning the cropped image
tmpstr=el.getAttribute("style");
if (tmpstr !== null) {
if (tmpstr.toLowerCase().includes("top") ) {
el.setAttribute("style", "height: 100%; width: auto");
}
else if (tmpstr.includes("left")) {
el.setAttribute("style", "height: auto; width: 100%");
}
}
} catch (e) { console.warn("error: ", e); }
}
}
}
function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
var newnode;
var parentnode;
newnode = myDoc.createElement("a");
newnode.setAttribute("href", linkTarget);
newnode.setAttribute("target", "_blank");
newnode.setAttribute("download", downloadName);
parentnode = wrapElement.parentNode;
parentnode.replaceChild(newnode, wrapElement);
newnode.appendChild(wrapElement);
}
function getCleanImageURL(imageurl) {
var pos = imageurl.toLowerCase().lastIndexOf(":");
var pos2 = imageurl.toLowerCase().indexOf("/");
if (pos >= 0 && pos > pos2) {
return imageurl.substring(0, pos);
} else {
return imageurl;
}
}
function getFilename(imageurl) {
return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
}
// Two very different actions depending on if this is on twitter.com or twing.com
if (window.location.href.includes('pbs.twimg.com/media')){
var params = new URLSearchParams(document.location.search.substring(1));
if (params.has("name")) {
if (params.get("name") !== "orig" ) {
// new Twitter UI - no need anymore to modify the SaveAs filename
params.set("name", "orig");
document.location.search = "?" + params.toString();
}
} else {
// old Twitter UI - insert link with attrib "download" to modify the SaveAs filename (need to click the image)
var image = document.querySelector('img');
var url = image.src;
insertLinkElement(document, image, getCleanImageURL(url)+":orig", getFilename(url));
}
}
else
{
var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var reactrootnode = reactrootmatch.singleNodeValue;
if (reactrootnode !== null) {
//new Twitter UI
// create an observer instance and iterate through each individual new node
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(addedNode) {
new_observeArticles(mutation.target.ownerDocument, addedNode);
});
});
});
// configuration of the observer
var config = { attributes: false, childList: true, characterData: false, subtree: true };
//process already loaded nodes (the initial posts before scrolling down for the first time)
new_observeArticles(document, reactrootnode);
//start the observer for new nodes
observer.observe(reactrootnode, config);
} else {
// old Twitter UI
// create an observer instance and iterate through each individual new node
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(addedNode) {
old_createImageLinks(mutation.target.ownerDocument, addedNode);
});
});
});
// configuration of the observer
// NOTE: subtree is false as the wanted nodes are direct children of <ol id="posts"> -> notable performance improvement
var config = { attributes: false, childList: true, characterData: false, subtree: false };
// pass in the target node (<ol> element contains all stream posts), as well as the observer options
var postsmatch = document.evaluate("//ol[@id='stream-items-id']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var postsnode = postsmatch.singleNodeValue;
//process already loaded nodes (the initial posts before scrolling down for the first time)
old_createImageLinks(document, postsnode);
//start the observer for new nodes
observer.observe(postsnode, config);
// also observe the overlay node - this is the node used when opening an individsual post as overlay
// NOTE: subtree is true here as the wanted nodes are ancestors of the node used as observer root
var config2 = { attributes: false, childList: true, characterData: false, subtree: true };
// pass in the target node, as well as the observer options
var overlaymatch = document.evaluate("//div[contains(@class,'PermalinkOverlay-content')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var overlaynode = overlaymatch.singleNodeValue;
//start the observer for overlays
observer.observe(overlaynode, config2);
}
}