// ==UserScript== // @name Twitter - clickable links to images and show uncropped thumbnails // @namespace twitter_linkify // @version 2.4 // @license GNU AGPLv3 // @description All image posts in Twitter Home, other blog streams and single post views link to the high-res "orig" version. Thumbnail images in the stream are modified to display uncropped. // @author marp // @homepageURL https://greasyfork.org/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 // @downloadURL none // ==/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") || !(myStyle.includes("static")) ) ) { myNode.setAttribute("style", "position: static"); } } 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' or @data-testid='tweetPhoto']", 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); var imagenode = singlematch.singleNodeValue; if (imagenode !== null) { var imgurl = new URL(imagenode.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/')]", imagenode, 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 let me know if something is broken 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= 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
    -> notable performance improvement var config = { attributes: false, childList: true, characterData: false, subtree: false }; // pass in the target node (
      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); } }