// ==UserScript== // @name Twitter - clickable links to images and show uncropped thumbnails // @namespace twitter_linkify // @version 3.2 // @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 // @match https://twitter.com/ // @match https://twitter.com/* // @match https://pbs.twimg.com/media/* // @exclude https://twitter.com/settings // @exclude https://twitter.com/settings/* // @run-at document-end // @downloadURL none // ==/UserScript== function 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("absolute")) ) ) { myNode.setAttribute("style", "position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px"); } } function adjustSingleBackgroundSize(myNode) { var myStyle = myNode.getAttribute("style"); if ( (myStyle !== null) && ( !(myStyle.includes("contain")) ) ) { myNode.style.backgroundSize = "contain"; } } function createSingleImageLink(myDoc, myContext) { if (myContext.nodeType === Node.ELEMENT_NODE) { var singlematch; var singlelink; var observer; var config; 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) { adjustSingleMargin(singlenode); observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { adjustSingleMargin(mutation.target); }); }); 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) { adjustSingleBackgroundSize(singlenode) observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { adjustSingleBackgroundSize(mutation.target); }); }); 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 processImages(myDoc, myContext) { //console.info("processImages-0 ", 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) { 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 { try { return ss.cssRules.length > 0; } catch (e) { return false; } } ).flatMap(ss => Array.from(ss.cssRules).filter(css => css instanceof CSSStyleRule && css.cssText.indexOf('blur(')>=0)).map(css => css.selectorText.substring(1)); } var matches; var pos; for (const bs of blurStyles) { matches = myDoc.evaluate("./descendant-or-self::div[contains(@class, '"+bs+"')]", 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) { // 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) { 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) observeArticles(document, reactrootnode); //start the observer for new nodes observer.observe(reactrootnode, config); } }