// ==UserScript== // @name Drop My Flickr Links! // @namespace https://github.com/stanleyqubit/drop-my-flickr-links // @license MIT License // @author stanleyqubit // @compatible firefox Tampermonkey with UserScripts API Dynamic // @compatible chrome Violentmonkey or Tampermonkey // @compatible edge Violentmonkey or Tampermonkey // @compatible opera Tampermonkey // @match *://*.flickr.com/* // @connect www.flickr.com // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_download // @grant GM_openInTab // @grant GM_notification // @grant GM.xmlHttpRequest // @grant GM_registerMenuCommand // @version 2.2 // @icon https://www.google.com/s2/favicons?sz=64&domain=flickr.com // @description Creates a hoverable dropdown menu that shows links to all available sizes for Flickr photos. // @downloadURL none // ==/UserScript== /* The photos available for download through this userscript may be protected by * copyright laws. Downloading a photo constitutes your agreement to use the photo * in accordance with the license associated with it. Please check the individual * photo's license information before use. * * Note -- Firefox + Tampermonkey users: in order for the script to have full * access to the Flickr YUI `appContext` global variable and thus avoid having to * resort to workarounds which may result in incorrectly displayed links or * incomplete photo data, go to the Tampermonkey dashboard -> Settings, under * "Config mode" select "Advanced", then under "Content Script API" select * "UserScripts API Dynamic", then click "Save". * * FYI -- some authors may choose to disable photo downloads which means that * Flickr will not make certain photo sizes (e.g. originals) available for users * that aren't signed in with a Flickr account. */ console.log("Loaded."); const SCRIPT_NAME = "Drop My Flickr Links!"; const BUTTON_TEXT = 'D'; const defaultSettings = { IMMEDIATE: { type: "boolean", value: true, name: 'Immediate', desc: 'On: get sizes for all photos as soon as they appear inside a page. ' + 'Off: only get sizes on button hover.', }, MAIN_PHOTO_ENGAGEMENT_VIEW: { type: "boolean", value: true, name: 'Main photo page engagement view', desc: 'Place the dropdown inside the engagement view when navigating the ' + 'main photo page.', }, REPLACE_FLICKR_DL_BUTTON: { type: "boolean", value: false, name: 'Replace Flickr download button', desc: 'Replace the Flickr download button shown in the main photo page ' + 'with our button. Requires "Main photo page engagement view".', }, PREPEND_AUTHOR_ID: { type: "boolean", value: true, name: 'Prepend author ID to downloaded image file name', desc: 'Self-explanatory.', }, /* Dropdown button appearance */ BUTTON_WIDTH: { type: "number", value: 25, min: 10, max: 100, step: 1, name: 'Dropdown button width', desc: 'CSS pixel unit value.', }, BUTTON_TEXT_COLOR: { type: "text", value: '#ffffff', name: 'Dropdown button text color', desc: 'CSS color value.', }, BUTTON_BG_COLOR: { type: "text", value: '#6495ed', name: 'Dropdown button background color', desc: 'CSS color value.', }, BUTTON_HOVER_BG_COLOR: { type: "text", value: '#519c60', name: 'Dropdown button background color on hover', desc: 'CSS color value.', }, BUTTON_OPACITY: { type: "number", value: 0.75, min: 0, max: 1, step: 0.01, name: 'Dropdown button opacity', desc: 'CSS alpha value. Range [0.0, 1.0].', }, BUTTON_HOVER_OPACITY: { type: "number", value: 0.9, min: 0, max: 1, step: 0.01, name: 'Dropdown button opacity on hover', desc: 'CSS alpha value. Range [0.0, 1.0].', }, /* Dropdown menu appearance */ CONTENT_TEXT_COLOR: { type: "text", value: '#000000', name: 'Dropdown menu text color', desc: 'CSS color value.', }, CONTENT_TEXT_SIZE: { type: "number", value: 18, min: 5, max: 100, step: 1, name: 'Dropdown menu text size', desc: 'CSS pixel unit value.', }, CONTENT_A_BG_COLOR: { type: "text", value: '#e8e9db', name: 'Dropdown menu anchor element background color', desc: 'CSS color value.', }, CONTENT_A_HOVER_BG_COLOR: { type: "text", value: '#cfdbe1', name: 'Dropdown menu anchor element background color on hover', desc: 'CSS color value.', }, CONTENT_DIV_BG_COLOR: { type: "text", value: '#e7e4c5', name: 'Dropdown menu preview element background color', desc: 'CSS color value.', }, CONTENT_DIV_HOVER_BG_COLOR: { type: "text", value: '#8dc5ed', name: 'Dropdown menu preview element background color on hover', desc: 'CSS color value.', }, /* Preview mode */ PREVIEW_MODE_SHOW_CONTROLS: { type: "boolean", value: true, name: 'Preview mode show image controls', desc: 'Adds a widget for image control when in preview mode.', }, PREVIEW_MODE_SHOW_CLOSE_BUTTON: { type: "boolean", value: true, name: 'Preview mode show close button', desc: 'Adds a clickable close button to the top right corner when in preview mode.', }, PREVIEW_MODE_SHOW_LICENSE_INFO: { type: "boolean", value: true, name: 'Preview mode show license information', desc: 'Shows a hyperlink to the photo\'s license when in preview mode.', }, PREVIEW_MODE_ICON_WIDTH: { type: "number", value: 50, min: 30, max: 150, step: 1, name: 'Preview mode icon size', desc: 'CSS pixel unit value.', }, PREVIEW_MODE_ICON_FILL_COLOR: { type: "text", value: '#f0fff0', /* honeydew */ name: 'Preview mode icon fill color', desc: 'Fill color for the vector graphic shown inside the icon. CSS color value.', }, /* PREVIEW_MODE_ICON_SVG_SCALE: { type: "number", value: 0.6, min: 0.1, max: 0.8, step: 0.1, name: 'Preview mode icon SVG scale', desc: 'Scale of the SVG relative to the icon size. This value is multiplied ' + 'by the preview mode icon size in order to obtain the width and height ' + 'of the vector graphic displayed inside the icon.', }, */ PREVIEW_MODE_SCROLL_TO_ZOOM: { type: "boolean", value: true, name: 'Preview mode zoom on mouse scroll', desc: 'Zoom the preview image with the mouse wheel.', }, PREVIEW_MODE_EXIT_ON_MOUSE_EVENT: { type: "select", value: 'dblclick', name: 'Preview mode exit on mouse event', desc: 'Exits preview mode on this mouse event.', opts: {'dblclick': "Double click", 'click': "Click", '': "None"}, }, PREVIEW_MODE_EXIT_KB: { type: "kbd", value: {enabled: true, keyCode: 27, key: "Escape", modifierKeys: []}, name: 'Preview mode exit on key', desc: 'Exits preview mode when this key is pressed.', }, PREVIEW_MODE_ROTATE_CW_KB: { type: "kbd", value: {enabled: true, keyCode: 190, key: ">", modifierKeys: ["shiftKey"]}, name: 'Preview mode rotate clockwise key', desc: 'Rotates the preview image 90 degrees clockwise when this key is pressed.', }, PREVIEW_MODE_ROTATE_CCW_KB: { type: "kbd", value: {enabled: true, keyCode: 188, key: "<", modifierKeys: ["shiftKey"]}, name: 'Preview mode rotate counter-clockwise key', desc: 'Rotates the preview image 90 degrees counter-clockwise when this key is pressed.', }, PREVIEW_MODE_ZOOM_IN_KB: { type: "kbd", value: {enabled: true, keyCode: 107, key: "+", modifierKeys: []}, // Numpad "+" key. name: 'Preview mode zoom in key', desc: 'Zooms in the preview image when this key is pressed.', }, PREVIEW_MODE_ZOOM_OUT_KB: { type: "kbd", value: {enabled: true, keyCode: 109, key: "-", modifierKeys: []}, // Numpad "-" key. name: 'Preview mode zoom out key', desc: 'Zooms out the preview image when this key is pressed.', }, PREVIEW_MODE_TOGGLE_FIT_KB: { type: "kbd", value: {enabled: true, keyCode: 106, key: "*", modifierKeys: []}, // Numpad "*" key. name: 'Preview mode toggle fit to screen key', desc: 'Toggles the preview image between fit to screen view and full size view when this key is pressed.', }, } const getSettingValue = (key, settings) => { const value = settings[key]?.value; const defaultValue = defaultSettings[key].value; if (typeof value === typeof defaultValue) { if (typeof value === 'object') { for (const [k, v] of Object.entries(defaultValue)) { if (typeof v !== typeof value[k]) { return defaultValue; } } } return value; } return defaultValue; } const o = { KEYBINDINGS: {} }; const storedSettings = GM_getValue('settings', {}); for (const key in defaultSettings) { const value = getSettingValue(key, storedSettings); o[key] = value; if (key.endsWith('_KB') && value.enabled) { o.KEYBINDINGS[value.keyCode] = {"setting": key, "data": value}; } } const LICENSE_INFO = [ { value: '0', text: 'All rights reserved', url: 'https://flickrhelp.com/hc/en-us/articles/4404078674324-Change-Your-Photo-s-License-in-Flickr' }, { value: '1', text: 'Attribution-NonCommercial-ShareAlike', url: 'https://creativecommons.org/licenses/by-nc-sa/2.0/' }, { value: '2', text: 'Attribution-NonCommercial', url: 'https://creativecommons.org/licenses/by-nc/2.0/' }, { value: '3', text: 'Attribution-NonCommercial-NoDerivs', url: 'https://creativecommons.org/licenses/by-nc-nd/2.0/' }, { value: '4', text: 'Attribution', url: 'https://creativecommons.org/licenses/by/2.0/' }, { value: '5', text: 'Attribution-ShareAlike', url: 'https://creativecommons.org/licenses/by-sa/2.0/' }, { value: '6', text: 'Attribution-NoDerivs', url: 'https://creativecommons.org/licenses/by-nd/2.0/' }, { value: '7', text: 'No known copyright restrictions', url: '/commons/usage/' }, { value: '8', text: 'United States government work', url: 'http://www.usa.gov/copyright.shtml' }, { value: '9', text: 'Public Domain Dedication (CC0)', url: 'https://creativecommons.org/publicdomain/zero/1.0/' }, { value: '10', text: 'Public Domain Work', url: 'https://creativecommons.org/publicdomain/mark/1.0/' } ]; const canSvg = !!(document.createElementNS && document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGRect); const SVG_WIDTH = o.PREVIEW_MODE_ICON_WIDTH * (o.PREVIEW_MODE_ICON_SVG_SCALE || 0.6); const SVG_FILL = o.PREVIEW_MODE_ICON_FILL_COLOR; const STYLE = ` :root.dmfl-preview-mode, .dmfl-modal-mode { overflow: hidden; } /* ================ === Dropdown === ================ */ .dmfl-dropdown-container { width: ${o.BUTTON_WIDTH}px; height: ${o.BUTTON_WIDTH}px; display: block; cursor: pointer; z-index: 203; } .dmfl-dropdown-container.dmfl-thumbnail { position: absolute; width: max-content; height: max-content; padding: 3px; } .dmfl-dropdown-container.dmfl-engagement-view { display: flex; position: relative; } .dmfl-dropdown-container.dmfl-main-photo-page { align-items: center; margin-right: 12px; } .dmfl-dropdown-container:hover .dmfl-dropdown-content { display: block; } .dmfl-dropdown-container:hover .dmfl-dropdown-button.dmfl-populated { background-color: ${o.BUTTON_HOVER_BG_COLOR}; opacity: ${o.BUTTON_HOVER_OPACITY}; } .dmfl-dropdown-button { display: flex; width: ${o.BUTTON_WIDTH}px; height: ${o.BUTTON_WIDTH}px; justify-content: center; align-items: center; font-size: calc(${o.BUTTON_WIDTH}px * 0.75); color: ${o.BUTTON_TEXT_COLOR}; background-color: ${o.BUTTON_BG_COLOR}; opacity: ${o.BUTTON_OPACITY}; } .dmfl-dropdown-button.dmfl-populating { border-radius: 50%; -webkit-animation: dmfl-spin-anim 2s linear infinite; /* Safari */ animation: dmfl-spin-anim 2s linear infinite; background-color: #a9b1c1b5; border: ${o.BUTTON_WIDTH / 10}px solid #f3f3f3; border-top: ${o.BUTTON_WIDTH / 10}px solid #3498db; opacity: .75; } .dmfl-dropdown-button.dmfl-thumbnail { position: relative; } .dmfl-dropdown-button.dmfl-engagement-view { position: absolute; } .dmfl-dropdown-button.dmfl-populated-fail { background-color: #f08080; /* lightcoral */ } .dmfl-dropdown-content { display: none; width: max-content; height: max-content; background-color: #f1f1f1; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); font-size: ${o.CONTENT_TEXT_SIZE}px; text-align: center; text-decoration: none; user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; } .dmfl-dropdown-content.dmfl-thumbnail { position: relative; } .dmfl-dropdown-content.dmfl-engagement-view { position: absolute; right: 0; bottom: ${o.BUTTON_WIDTH}px; } .dmfl-dropdown-content.dmfl-populated-fail { background-color: #efe4eb; box-shadow: inset 0px 0px 5px 0px rgba(0, 0, 0, 0.2); max-width: 300px; padding: 5px; text-align: left; } .dmfl-dropdown-entry { display: grid; grid-template-columns: 1fr auto; white-space: nowrap; cursor: pointer; } .dmfl-dropdown-entry a, .dmfl-dropdown-entry-preview-button { padding: 5px 10px; } .dmfl-dropdown-entry a { color: ${o.CONTENT_TEXT_COLOR}; background-color: ${o.CONTENT_A_BG_COLOR}; } .dmfl-dropdown-entry a:hover { background-color: ${o.CONTENT_A_HOVER_BG_COLOR}; } .dmfl-dropdown-entry .dmfl-dropdown-entry-preview-button { font-family: sans-serif; font-weight: lighter; color: ${o.CONTENT_TEXT_COLOR}; background-color: ${o.CONTENT_DIV_BG_COLOR}; } .dmfl-dropdown-entry .dmfl-dropdown-entry-preview-button:hover { background-color: ${o.CONTENT_DIV_HOVER_BG_COLOR}; opacity: .9; } /* ==================== === Preview mode === ==================== */ .dmfl-preview-background { background-color: rgb(0,0,0); /* Fallback color */ background-color: rgba(0,0,0,0.9); display: flex; position: fixed; z-index: 30000; left: 0; top: 0; width: 100%; height: 100%; user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; } .dmfl-preview-image-container { position: fixed; width: 100vw; height: 100vh; top: 0; left: 0; overflow: hidden; } .dmfl-preview-image { --dmfl-preview-image-translateX: 0; --dmfl-preview-image-translateY: 0; --dmfl-preview-image-scale: 1; --dmfl-preview-image-rotate: 0deg; position: absolute; cursor: grab; translate: none; rotate: none; scale: none; transform-origin: 0 0; transform: translateX(var(--dmfl-preview-image-translateX)) translateY(var(--dmfl-preview-image-translateY)) rotate(var(--dmfl-preview-image-rotate)) scale(var(--dmfl-preview-image-scale)); } .dmfl-svg.dmfl-svg-pm-dlbut, .dmfl-preview-background svg[class*=dmfl-svg-pm-pc] { width: ${SVG_WIDTH}px; height: ${SVG_WIDTH}px; } .dmfl-preview-background svg[class*=dmfl-svg-pm-]:not(.dmfl-svg-pm-closebut) > .dmfl-svg-path { fill: ${SVG_FILL}; } .dmfl-preview-controls-container { --dmfl-preview-controls-opacity: 0; display: flex; align-items: center; font-size: ${o.PREVIEW_MODE_ICON_WIDTH * 0.7}px; line-height: 1; color: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; border-radius: 10px; background-color: #7a8191; position: fixed; top: 20px; left: 20px; z-index: 30001; cursor: pointer; opacity: var(--dmfl-preview-controls-opacity); transition: all 1s; } .dmfl-preview-controls-rubberband { display: inline-flex; justify-content: space-around; width: 0px; height: ${o.PREVIEW_MODE_ICON_WIDTH}px; opacity: .35; transition: all 0.5s ease-out; } .dmfl-preview-controls-rubberband > span { align-self: center; } .dmfl-preview-controls-main-button { display: inline-flex; width: ${o.PREVIEW_MODE_ICON_WIDTH}px; height: ${o.PREVIEW_MODE_ICON_WIDTH}px; margin: 0; justify-content: center; align-items: center; rotate: 0deg; transition: rotate 0.5s linear; } .dmfl-preview-controls-rubberband > span:not(.dmfl-preview-controls-main-button) { display: none; opacity: 0; transform-origin: bottom; transform: translateY(${o.PREVIEW_MODE_ICON_WIDTH}px); animation-name: dmfl-preview-controls-anim; animation-timing-function: ease-out; animation-fill-mode: forwards; animation-duration: 0.2s; } .dmfl-preview-controls-container:hover .dmfl-preview-controls-rubberband { width: ${o.PREVIEW_MODE_ICON_WIDTH * 6}px; opacity: 1; } .dmfl-preview-controls-container:hover { opacity: 1; background-color: #586887; } .dmfl-preview-controls-container:hover .dmfl-preview-controls-main-button { rotate: 90deg; } .dmfl-preview-controls-container:hover .dmfl-preview-controls-rubberband > span:not(.dmfl-preview-controls-main-button) { display: inline-flex; } .dmfl-preview-controls-rot-cw { animation-delay: 0.3s; } .dmfl-preview-controls-rot-ccw { animation-delay: 0.4s; } .dmfl-preview-controls-toggle-fit { animation-delay: 0.5s; } .dmfl-preview-controls-zoom-in { animation-delay: 0.6s; } .dmfl-preview-controls-zoom-out { animation-delay: 0.7s; } .dmfl-preview-controls-rubberband > span:not(.dmfl-preview-controls-main-button):hover { color: #7fffd4; /* aquamarine */ } .dmfl-preview-controls-rubberband > span:not(.dmfl-preview-controls-main-button):hover .dmfl-svg-path { fill: #7fffd4; } @keyframes dmfl-preview-controls-anim { to { opacity: 1; transform: translateY(0); } } .dmfl-preview-close-button { position: absolute; display: flex; align-items: ${canSvg ? 'center' : 'flex-end'}; justify-content: ${canSvg ? 'center' : 'flex-end'}; width: ${o.PREVIEW_MODE_ICON_WIDTH}px; height: ${o.PREVIEW_MODE_ICON_WIDTH}px; top: 20px; right: 20px; font-size: ${o.PREVIEW_MODE_ICON_WIDTH}px; font-weight: bold; color: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; background-color: ${canSvg ? '#7a8191' : 'unset'}; border-radius: 10px; opacity: .3; text-shadow: 1px 1px 1px black; z-index: 30001; cursor: pointer; } .dmfl-preview-close-button:hover { color: #f3c84b; opacity: .8; } .dmfl-preview-close-button .dmfl-svg { fill: ${SVG_FILL}; } .dmfl-preview-close-button:hover .dmfl-svg { fill: #f3c84b; } .dmfl-preview-download-button { display: flex; align-items: center; justify-content: center; text-align: center; text-decoration: underline; font-size: ${o.PREVIEW_MODE_ICON_WIDTH * 0.7}px; font-weight: bold; line-height: 1; height: ${o.PREVIEW_MODE_ICON_WIDTH}px; width: ${o.PREVIEW_MODE_ICON_WIDTH}px; color: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; background-color: ${o.BUTTON_BG_COLOR}; position: fixed; z-index: 30001; right: 20px; bottom: 20px; border-radius: 10px; opacity: .7; cursor: pointer; } .dmfl-preview-download-button:hover { background-color: ${o.BUTTON_HOVER_BG_COLOR}; opacity: 1; } .dmfl-preview-license-info-anchor { display: flex; color: #fff !important; position: fixed; z-index: 30001; left: 20px; bottom: 20px; } /* ====================== === Settings modal === ====================== */ .dmfl-settings-modal { display: flex; justify-content: center; align-items: center; position: fixed; /* Stay in place */ z-index: 20000; /* Sit on top */ left: 0; top: 0; width: 100%; /* Full width */ height: 100%; /* Full height */ overflow: auto; /* Enable scroll if needed */ background-color: rgb(0,0,0); /* Fallback color */ background-color: rgba(0,0,0,0.6); /* Black w/ opacity */ } .dmfl-settings-modal-content { display: flex; flex-direction: column; position: absolute; background-color: #fefefe; padding: 1.25rem; border: 1px solid #888; width: max-content; max-height: 80%; overflow: hidden; overscroll-behavior: contain; border-radius: 10px; scale: 1; transition: scale 1s ease-in; transition-delay: 0.1s; } .dmfl-settings-modal-content h2 { font-size: 25px !important; line-height: normal !important; color: #000; } .dmfl-settings-modal-body { display: grid; row-gap: 5px; overflow: auto; } .dmfl-settings-modal-header { display: flex; flex-direction: row; justify-content: center; align-items: center; margin-bottom: 1.75rem; font-size: 2em; font-weight: bold; } .dmfl-settings-modal-footer { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-top: 1.25rem; } .dmfl-settings-modal-save-button, .dmfl-settings-modal-restore-defaults-button { height: 2.25rem !important; transition: none !important; } .dmfl-settings-modal-entry { display: flex; align-items: center; width: 100%; padding: 5px 0px 5px 5px; box-sizing: border-box; } .dmfl-settings-modal-entry:nth-child(odd) { background: #f2f2f2; } .dmfl-settings-modal-label { position: relative; display: flex; margin-right: 10px; } .dmfl-settings-modal-entry input { margin: 0 10px 0 10px !important; } .dmfl-settings-modal-entry input[type="number"] { text-align: center; width: 65px; padding-block: 2px; } .dmfl-settings-modal-tooltiptext { visibility: hidden; width: max-content; max-width: 300px; background-color: #65779f; box-shadow: 0px 0px 5px 0px #6c7ea9; color: #fff; text-align: left; border-radius: 6px; padding: 10px; /* Position the tooltip */ position: absolute; z-index: 1; left: -9999px; top: -9999px; /* Fade in tooltip */ opacity: 0; transition: opacity 0.5s; } .dmfl-settings-modal-color-picker { display: inline-block; margin: 0 10px 0 10px !important; } .dmfl-settings-modal-close { position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; color: #aaaaaa; font-size: 2.5rem; font-weight: bold; cursor: pointer; } .dmfl-settings-modal-close:hover, .dmfl-settings-modal-close:focus { color: #a97174; text-decoration: none; } .dmfl-settings-modal-change-key-button { height: 25px !important; line-height: normal !important; margin: 0 10px 0 10px !important; vertical-align: inherit !important; padding: 0 15px !important; transition: none !important; } .dmfl-settings-modal-kbd { border: 2px solid #cdcdcd; border-radius: 0.25rem; box-shadow: inset 0 -1px 0 0 #cdcdcd; font-size: .825rem; padding: .25rem; box-sizing: border-box; font-family: monospace; font-weight: 600; line-height: 1.5; text-align: left; margin: 0 10px 0 10px !important; } .dmfl-settings-modal-select { font-size: 100%; margin: 0 10px 0 10px !important; border: 1px solid darkgray; } /* ====================== === Popup messages === ====================== */ .dmfl-message-container { position: fixed; display: flex; justify-content: center; align-items: center; visibility: hidden; color: #fff; /* White text */ border: 1px solid rgba(119, 233, 220, 0.31); opacity: 0; /* Initially hidden */ transition: opacity 0.35s ease-in-out; cursor: default; z-index: 99999; } .dmfl-message-container-topleft { --dmfl-mc-topleft-minwidth: max(50px, ${o.PREVIEW_MODE_ICON_WIDTH}px); box-sizing: border-box; border-radius: 10px; top: ${o.PREVIEW_MODE_SHOW_CONTROLS ? `${o.PREVIEW_MODE_ICON_WIDTH + 30}` : 20}px; left: 20px; width: var(--dmfl-mc-topleft-minwidth); height: var(--dmfl-mc-topleft-minwidth); background-color: rgba(95, 129, 191, 0.7); font-size: calc(var(--dmfl-mc-topleft-minwidth) * 0.3); font-weight: 500; white-space: nowrap; } .dmfl-message-container-bottom { border-radius: 3px; bottom: 20px; left: 50%; min-width: 300px; background-color: rgb(47 57 76 / 85%); font-size: 18px; padding: 10px 20px; transform: translateX(-50%); } /* ============================== === Main loading indicator === ============================== */ .dmfl-loader-container { display: flex; justify-content: center; align-items: center; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 30002; } .dmfl-loader { position: relative; border: 8px solid #f3f3f3; border-radius: 50%; border-top: 8px solid #3498db; width: 45px; height: 45px; opacity: .65; -webkit-animation: dmfl-spin-anim 2s linear infinite; /* Safari */ animation: dmfl-spin-anim 2s linear infinite; } /* Safari */ @-webkit-keyframes dmfl-spin-anim { 0% { -webkit-transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); } } @keyframes dmfl-spin-anim { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* ====================== === Startup loader === ====================== */ .dmfl-startup-loader { display: flex; bottom: 70px; left: 50%; align-items: center; justify-content: center; width: 50px; height: 50px; z-index: 60000; font-size: 30px; color: ${o.BUTTON_TEXT_COLOR}; background: ${o.BUTTON_BG_COLOR}; position: fixed; cursor: wait; animation: dmfl-startup-anim 3s infinite; -moz-animation: dmfl-startup-anim 3s infinite; -webkit-animation: dmfl-startup-anim 3s infinite; -o-animation: dmfl-startup-anim 3s infinite; border-radius: 0px; -webkit-border-radius: 0px; } @keyframes dmfl-startup-anim { 0% {transform: translateX(-50%) rotate(0deg);} 50% {transform: translateX(-50%) rotate(180deg); background:${o.BUTTON_HOVER_BG_COLOR};} 100% {transform: translateX(-50%) rotate(360deg);} } `; const nodesProcessed = new Map(); const nodesBlacklisted = new Set(); const idsPopulating = new Set(); const urlsDownloading = new Set(); const messages = {}; const cache = Object.create(null); let lastURL, lastRootView; let lastScrollX, lastScrollY; let lastScrollHeight, lastScrollWidth; let mainPhoto = null; let isLightboxPage = false; let lightboxIntervalID = null; let overlayContainer; let messageContainerBottom; let messageContainerTopleft; let previewControlsContainer; let previewDlButton; let previewCloseButton; let appInitOk = false; let appInitComplete = false; let pageContent; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function getRandomNumber(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function clamp(min, max, value) { return Math.max(min, Math.min(max, value)); } function isLightboxURL(url) { return url.lastIndexOf('/lightbox') > 34; } function hasClass(node, className) { return node.classList?.contains(className); } function isDropdownElement(el) { return el?.getAttribute?.('class')?.startsWith?.('dmfl-dropdown'); } function isValidImageURL(url) { return /(live|farm[\d]*)\.static\.?flickr\.com\/[\d]+\/[\d]+_[a-z0-9]+(_[a-z0-9]{1,2})?\.[a-z0-9]{3,4}$/.test(url); } function isValidHref(href) { return /flickr\.com\/(photos(?!\/tags\/)\/[-\w@]+\/[0-9]+|gp\/[-\w@]+\/[\w]+)(?!.*\/sizes\/)/.test(href); } function showMessage(text, duration, container) { container.textContent = text; container.style.visibility = 'visible'; container.style.opacity = 1; clearTimeout(messages[container.className]); const messageTimeoutId = setTimeout(() => { container.style.opacity = 0; setTimeout(() => { if (container.style.getPropertyValue('opacity') != 0) return; container.style.visibility = 'hidden'; container.textContent = ''; }, 350); }, duration); messages[container.className] = messageTimeoutId; } const ICONS = { "default": { /* * https://www.svgrepo.com/collection/chunk-16px-thick-interface-icons/ * Author: Noah Jacobus * Website: https://noahjacob.us/ * License: PD */ pm_close_but: ` ` , pm_dl_but: ` ` , pc_main: ` ` , pc_rotcw: ` ` , pc_rotccw: ` ` , pc_togglefit: ` ` , pc_zoomin: ` ` , pc_zoomout: ` ` , }, }; const previewControlsHTML = ` ${canSvg ? ICONS.default.pc_main : '\u2699'}
${canSvg ? ICONS.default.pc_rotcw : '\u21BB'} ${canSvg ? ICONS.default.pc_rotccw : '\u21BA'} ${canSvg ? ICONS.default.pc_togglefit : '\u21F1'} ${canSvg ? ICONS.default.pc_zoomin : '\u229E'} ${canSvg ? ICONS.default.pc_zoomout : '\u229F'}
`; const previewCloseButtonHTML = `${canSvg ? ICONS.default.pm_close_but : '×'}`; const previewDlButtonHTML = `${canSvg ? ICONS.default.pm_dl_but : '\u21e3'}`; const settingsModalHTML = `
×

${SCRIPT_NAME} \u27b2 Settings

`; function showSettings() { if (document.querySelector('.dmfl-settings-modal')) return; const modal = document.createElement('div'); modal.className = 'dmfl-settings-modal'; modal.innerHTML = settingsModalHTML; document.body.appendChild(modal); const modalContent = modal.querySelector('.dmfl-settings-modal-content'); const modalClose = modal.querySelector('.dmfl-settings-modal-close'); const modalSave = modal.querySelector('.dmfl-settings-modal-save-button'); const modalRestore = modal.querySelector('.dmfl-settings-modal-restore-defaults-button'); const modalBody = modal.querySelector('.dmfl-settings-modal-body'); const msgContainer = messageContainerBottom; const state = {}; const onValueChanged = function() { modalSave.disabled = false; modalRestore.disabled = false; } const removeKeyListeners = () => { if (state.detectKeyDown) document.removeEventListener('keydown', state.detectKeyDown, true); if (state.detectKeyUp) document.removeEventListener('keyup', state.detectKeyUp, true); state.detectKeyDown = null; state.detectKeyUp = null; state.detectingKey = false; if (state.changeKeyButtonPressed) { state.changeKeyButtonPressed.textContent = 'Change'; state.changeKeyButtonPressed.disabled = false; } } const fillBody = (settings) => { const tempSettings = state.tempSettings; for (const [key, defaultSetting] of Object.entries(defaultSettings)) { const entry = document.createElement('div'); entry.className = 'dmfl-settings-modal-entry'; if (!tempSettings[key]) { tempSettings[key] = {}; } const settingValue = getSettingValue(key, settings); tempSettings[key].value = settingValue; const entryChildren = []; const label = document.createElement('label'); label.className = 'dmfl-settings-modal-label'; label.textContent = defaultSetting.name; if (defaultSetting.desc) { const t = defaultSetting.type; const v = defaultSetting.value; const o = defaultSetting.opts; let dval = t == "kbd" ? v.key : t == "select" ? o[v] : v; const tooltipText = document.createElement('span'); tooltipText.className = 'dmfl-settings-modal-tooltiptext'; tooltipText.innerText = `${defaultSetting.desc}\n\nDefault: ` + `${String(dval).replace(/^true$/, 'On').replace(/^false$/, 'Off')}`; label.style.borderBottom = '1px dotted black'; label.style.cursor = 'context-menu'; label.onmouseenter = () => { tooltipText.style.transitionDelay = '0.7s'; const rect = label.getBoundingClientRect(); const down = rect.y < (innerHeight / 2); tooltipText.style.top = down ? `${rect.top - 5}px` : `${rect.bottom + 5 - tooltipText.getBoundingClientRect().height}px`; tooltipText.style.left = `${rect.left + rect.width + 10}px`; tooltipText.style.visibility = 'visible'; requestAnimationFrame(() => { tooltipText.style.opacity = 1; }); } label.onmouseleave = () => { tooltipText.style.transitionDelay = 'unset'; tooltipText.style.opacity = 0; tooltipText.style.visibility = 'hidden'; tooltipText.style.top = '-9999px'; tooltipText.style.left = '-9999px'; } modal.appendChild(tooltipText); } entryChildren.push(label); if (/(text|number|boolean)/.test(defaultSetting.type)) { const inputElem = document.createElement('input'); inputElem.className = 'dmfl-settings-modal-input'; let valGetter, valSetter; if (typeof defaultSetting.value === 'boolean') { inputElem.setAttribute('type', 'checkbox'); valGetter = 'checked'; valSetter = 'checked'; } else if (typeof defaultSetting.value === 'number') { inputElem.setAttribute('type', 'number'); inputElem.setAttribute('min', defaultSetting.min); inputElem.setAttribute('max', defaultSetting.max); inputElem.setAttribute('step', defaultSetting.step); inputElem.required = true; valGetter = 'valueAsNumber'; valSetter = 'value'; } else { inputElem.setAttribute('type', 'text'); valGetter = 'value'; valSetter = 'value'; } inputElem[valSetter] = settingValue; inputElem.addEventListener('input', () => { onValueChanged(); tempSettings[key].value = inputElem[valGetter]; }); entryChildren.push(inputElem); if (key.indexOf('_COLOR') >= 0) { const colorPicker = document.createElement('input'); colorPicker.className = 'dmfl-settings-modal-color-picker'; colorPicker.setAttribute('type', 'color'); colorPicker.value = inputElem.value; colorPicker.addEventListener('input', () => { inputElem.value = colorPicker.value; inputElem.dispatchEvent(new Event('input')); }) entryChildren.push(colorPicker); } } else if (defaultSetting.type == "kbd") { const inputElem = document.createElement('input'); inputElem.setAttribute('type', 'checkbox'); inputElem.checked = settingValue.enabled; const kbdElem = document.createElement('kbd'); kbdElem.className = 'dmfl-settings-modal-kbd'; kbdElem.textContent = settingValue.key; const changeKeyButton = document.createElement('button'); changeKeyButton.className = 'dmfl-settings-modal-change-key-button'; changeKeyButton.textContent = 'Change'; if (!inputElem.checked) { kbdElem.style.visibility = 'hidden'; changeKeyButton.style.visibility = 'hidden'; } changeKeyButton.onclick = (e) => { e.preventDefault(); if (state.detectingKey) return; state.detectKeyUp = (e) => { e.preventDefault(); e.stopPropagation(); } state.detectKeyDown = (e) => { if (/^(Shift|Alt|Control|Meta)$/.test(e.key)) return; e.preventDefault(); e.stopPropagation(); let keyUnavailable; for (const [k, obj] of Object.entries(tempSettings)) { if (/_KB$/.test(k) && k != key && obj.value.keyCode == e.keyCode && obj.value.modifierKeys.every(modKey => e[modKey] === true)) { const msg = `Key '${e.key}' already assigned to setting '${defaultSettings[k].name}'.`; console.log(msg); showMessage(msg, 4000, msgContainer); keyUnavailable = true; } } if (!keyUnavailable) { onValueChanged(); const mk = ["shiftKey", "altKey", "ctrlKey", "metaKey"].filter(modKey => e[modKey] === true); tempSettings[key].value = {"enabled": true, "keyCode": e.keyCode, "key": e.key, "modifierKeys": mk}; kbdElem.textContent = e.key; console.debug(`${key} value changed: keyCode: ${e.keyCode} | key: ${e.key} | modKeys:`, mk); } removeKeyListeners(); } changeKeyButton.disabled = true; changeKeyButton.textContent = 'Press any key'; document.addEventListener('keydown', state.detectKeyDown, true); document.addEventListener('keyup', state.detectKeyUp, true); state.changeKeyButtonPressed = changeKeyButton; state.detectingKey = true; } inputElem.addEventListener('input', () => { if (state.detectingKey) removeKeyListeners(); onValueChanged(); tempSettings[key].value.enabled = inputElem.checked; const vis = inputElem.checked ? 'visible' : 'hidden'; kbdElem.style.visibility = vis; changeKeyButton.style.visibility = vis; }); entryChildren.push(inputElem); entryChildren.push(kbdElem); entryChildren.push(changeKeyButton); } else if (defaultSetting.type == "select") { const selectElem = document.createElement('select'); selectElem.className = 'dmfl-settings-modal-select'; for (const [oKey, oVal] of Object.entries(defaultSetting.opts)) { const selectOpt = document.createElement('option'); selectOpt.value = oKey; selectOpt.textContent = oVal; selectElem.appendChild(selectOpt); } selectElem.value = settingValue; selectElem.addEventListener('change', (e) => { onValueChanged(); console.debug(`${key} value changed: ${e.target.value}`); tempSettings[key].value = e.target.value; }); entryChildren.push(selectElem); } entryChildren.forEach(child => entry.appendChild(child)); modalBody.appendChild(entry); } } state.tempSettings = GM_getValue('settings', {}); fillBody(state.tempSettings); document.querySelector(':root').classList.add('dmfl-modal-mode'); modalClose.onclick = function() { removeKeyListeners(); document.querySelector(':root').classList.remove('dmfl-modal-mode'); modal.remove(); } modalRestore.onclick = function() { removeKeyListeners(); modalBody.innerHTML = ''; state.tempSettings = {}; fillBody(defaultSettings); modalSave.disabled = false; modalRestore.disabled = true; } modalContent.onsubmit = function(e) { if (e.submitter != modalSave) return; modalSave.disabled = true; modalRestore.disabled = true; removeKeyListeners(); GM_setValue('settings', state.tempSettings); modalContent.style.setProperty('scale', 0); setTimeout(() => { const loader = document.createElement('div'); loader.className = "dmfl-loader"; modal.appendChild(loader); window.location.reload(); }, 1000); } } class PreviewMode { static bg; static img; static img_w; static img_h; static img_offsetX; static img_offsetY; static mouseX; static mouseY; static canTransform; static isFit; static isDragging; static dragAnimationFrameId; static SCALE_FACTOR = 1.1; static SCALE_MIN = 0.01; static SCALE_MAX = 5; static scale; static rotation; static swapDimensions; static controls; static active; static init(data) { const item_w = parseInt(data.item.width); const item_h = parseInt(data.item.height); if (!item_w || !item_h) { console.error("Image dimensions missing.", data); return; } this.canTransform = false; this.swapDimensions = false; this.isDragging = false; this.img_offsetX = 0; this.img_offsetY = 0; this.mouseX = window.innerWidth / 2; this.mouseY = window.innerHeight / 2; this.scale = 1; this.rotation = 0; const bg = document.createElement('div'); bg.className = 'dmfl-preview-background'; this.bg = bg; if (o.PREVIEW_MODE_EXIT_ON_MOUSE_EVENT) { bg.addEventListener(o.PREVIEW_MODE_EXIT_ON_MOUSE_EVENT, (e) => { if (/dmfl-(svg-pm-pc-|preview-controls)/.test(e.target.getAttribute('class'))) return; this.exit(); }); } if (o.PREVIEW_MODE_SHOW_CLOSE_BUTTON) { const closeBut = previewCloseButton; closeBut.onclick = () => this.exit(); bg.appendChild(closeBut); } document.body.appendChild(bg); const dlBut = previewDlButton; dlBut.onclick = (e) => { e.preventDefault(); dl(data.downloadURL, data.downloadFilename); this.exit(); } bg.appendChild(dlBut); if (data.licenseInfo) { const licenseInfoAnchor = document.createElement('a'); licenseInfoAnchor.className = 'dmfl-preview-license-info-anchor'; licenseInfoAnchor.setAttribute('href', data.licenseInfo.url); licenseInfoAnchor.innerText = `License: ${data.licenseInfo.text}`; licenseInfoAnchor.onclick = (e) => { GM_openInTab(licenseInfoAnchor.href, false); e.preventDefault(); e.stopPropagation(); } bg.appendChild(licenseInfoAnchor); } const loaderContainer = document.createElement('div'); loaderContainer.className = 'dmfl-loader-container'; const loader = document.createElement('div'); loader.className = "dmfl-loader"; loaderContainer.appendChild(loader); bg.appendChild(loaderContainer); const img = document.createElement('img'); img.setAttribute('width', item_w); img.setAttribute('height', item_h); img.className = 'dmfl-preview-image'; this.img = img; this.img_w = item_w; this.img_h = item_h; const imgContainer = document.createElement('div'); imgContainer.className = 'dmfl-preview-image-container'; bg.appendChild(imgContainer); img.onload = () => { this.canTransform = (img.naturalWidth > 0); loaderContainer.remove(); if (o.PREVIEW_MODE_SHOW_CONTROLS) this.showControls(); if (o.PREVIEW_MODE_SCROLL_TO_ZOOM) { img.addEventListener('wheel', (e) => { if (e.deltaY == 0) return; this.mouseX = e.clientX; this.mouseY = e.clientY; this.zoom(e.deltaY > 0 ? "out" : "in", true); }, {"passive": true}); } img.onmousedown = (e) => { if (e.button != 0) return; e.preventDefault(); e.stopPropagation(); img.style.cursor = 'grabbing'; this.isDragging = true; this.img_offsetX = e.clientX - this.getX(); this.img_offsetY = e.clientY - this.getY(); } bg.onmouseup = (e) => { if (e.button != 0 || !this.isDragging) return; e.stopPropagation(); this.isDragging = false; img.style.cursor = 'grab'; cancelAnimationFrame(this.dragAnimationFrameId); } bg.onmousemove = (e) => { if (this.isDragging) { this.dragAnimationFrameId = requestAnimationFrame(() => { this.setX(e.clientX - this.img_offsetX); this.setY(e.clientY - this.img_offsetY); this.isFit = false; e.stopPropagation(); }); } } } img.onerror = () => loaderContainer.remove(); img.src = data.imageURL; imgContainer.appendChild(img); document.querySelector(':root').classList.add('dmfl-preview-mode'); this.active = true; this.reset(); } static reset(toFullSize) { if (!this.active) return; requestAnimationFrame(() => { let img_w, img_h; if (this.swapDimensions) { img_w = this.img_h; img_h = this.img_w; } else { img_w = this.img_w; img_h = this.img_h; } const vw = window.innerWidth; const vh = window.innerHeight; const scaleX = vw / img_w; const scaleY = vh / img_h; const needsDownscale = (img_w > vw) || (img_h > vh); let scale; if (needsDownscale && !toFullSize) { scale = Math.min(scaleX, scaleY); this.isFit = true; } else { scale = 1; this.isFit = !needsDownscale; } const scaled_w = img_w * scale; const scaled_h = img_h * scale; let x = (vw - scaled_w) / 2; let y = (vh - scaled_h) / 2; switch (this.rotation) { case 90: case -270: x += scaled_w; break; case -90: case 270: y += scaled_h; break; case 180: case -180: x += scaled_w; y += scaled_h; break; } this.setX(x); this.setY(y); this.setScale(scale); }); } static rotate(degrees) { if (!this.canTransform) return; let newAngle = this.rotation + degrees; let newAngleAbs = Math.abs(newAngle); if (newAngleAbs == 360) { newAngle = 0; newAngleAbs = 0; } const rect = this.img.getBoundingClientRect(); const halfW = rect.width / 2; const halfH = rect.height / 2; let x = rect.x + halfW; let y = rect.y + halfH; this.img.style.setProperty('--dmfl-preview-image-rotate', `${newAngle}deg`); this.rotation = newAngle; this.swapDimensions = (newAngleAbs == 90 || newAngleAbs == 270); if (this.isFit) { this.reset(); return; } switch (newAngle) { case 0: x -= halfH; y -= halfW; break; case 90: case -270: x += halfH; y -= halfW; break; case -90: case 270: x -= halfH; y += halfW; break; case 180: case -180: x += halfH; y += halfW; break; } this.setX(x); this.setY(y); } static zoom(direction, isWheel) { if (!this.canTransform) return; requestAnimationFrame(() => { this.isFit = false; let newScale = this.scale; if (direction == "out") { newScale /= this.SCALE_FACTOR; } else { newScale *= this.SCALE_FACTOR; } newScale = clamp(this.SCALE_MIN, this.SCALE_MAX, newScale); const snapToFullSize = (this.scale < 1 && newScale > 1) || (this.scale > 1 && newScale < 1); if (snapToFullSize) newScale = 1; let x = this.getX(); let y = this.getY(); const originX = isWheel ? this.mouseX : window.innerWidth / 2; const originY = isWheel ? this.mouseY : window.innerHeight / 2; const offsetX = (originX - x) / this.scale; const offsetY = (originY - y) / this.scale; const delta = newScale - this.scale; x -= (offsetX * delta); y -= (offsetY * delta); this.setX(x); this.setY(y); this.setScale(newScale); this.showZoomPercentage(); }) } static showControls() { const controls = previewControlsContainer; controls.style.setProperty('--dmfl-preview-controls-opacity', 0); this.bg.appendChild(controls); setTimeout(() => { requestAnimationFrame(() => { controls.style.setProperty('--dmfl-preview-controls-opacity', 0.3); }); }, 100); controls.querySelector('.dmfl-preview-controls-rot-cw').onclick = () => this.rotate(90); controls.querySelector('.dmfl-preview-controls-rot-ccw').onclick = () => this.rotate(-90); controls.querySelector('.dmfl-preview-controls-toggle-fit').onclick = () => this.toggleFit(); controls.querySelector('.dmfl-preview-controls-zoom-in').onclick = () => this.zoom("in"); controls.querySelector('.dmfl-preview-controls-zoom-out').onclick = () => this.zoom("out"); } static showZoomPercentage() { requestAnimationFrame(() => { showMessage(`${parseInt(this.scale * 100)}%`, 1000, messageContainerTopleft); }); } static toggleFit() { if (!this.canTransform) return; this.reset(this.isFit); this.showZoomPercentage(); } static setX(value) { this.img.style.setProperty('--dmfl-preview-image-translateX', `${parseFloat(value.toFixed(5))}px`); } static setY(value) { this.img.style.setProperty('--dmfl-preview-image-translateY', `${parseFloat(value.toFixed(5))}px`); } static setScale(value) { this.img.style.setProperty('--dmfl-preview-image-scale', parseFloat(value.toFixed(5))); this.scale = value; } static getX() { return parseFloat(this.img.style.getPropertyValue('--dmfl-preview-image-translateX')) || 0; } static getY() { return parseFloat(this.img.style.getPropertyValue('--dmfl-preview-image-translateY')) || 0; } static clear() { document.querySelector(':root').classList.remove('dmfl-preview-mode'); this.bg?.remove(); this.active = false; } static exit() { if (this.active) this.clear(); } } class MouseTarget { static t; static data; static rect; static descriptor; static updateRect() { this.rect = this.t.getBoundingClientRect(); } static set(data) { this.t = data.node; this.updateRect(); this.descriptor = this.t.className || this.t.src || this.t.href; this.data = data; //console.debug(`Target: ${this.t.nodeName} ${this.descriptor}`); } static setPos(dc) { this.updateRect(); dc.style.left = `${this.rect.left + window.scrollX}px`; dc.style.top = `${this.rect.top + window.scrollY}px`; } static isHovered(e) { return ( e.clientX >= this.rect.left && e.clientX <= this.rect.right && e.clientY >= this.rect.top && e.clientY <= this.rect.bottom ); } static clear() { //console.debug('Clearing target.'); Object.keys(this).forEach(k => { this[k] = null }); } } function dropdownShow() { const dc = MouseTarget.data?.dropdownContainer; if (dc instanceof HTMLDivElement) { overlayContainer.appendChild(dc); MouseTarget.setPos(dc); overlayContainer.style.display = 'block'; } } function dropdownHide() { overlayContainer.innerHTML = ''; overlayContainer.style.display = 'none'; lastScrollX = null; lastScrollY = null; } const sizesOrder = ["o", "8k", "7k", "6k", "5k", "4k", "3k", "k", "h", "l", "c", "z", "m", "w", "n", "s", "q", "t", "sq"]; async function appGetInfo(photoId) { if (!appInitOk) return; let ds, owner, ownerId, licenseInfo; try { const info = await unsafeWindow.appContext.getModel('photo-models', photoId); if (info.getValue) { ds = info.getValue('descendingSizes'); owner = info.getValue('owner'); ownerId = owner?.getValue('id') || owner?.getValue('nsid') || owner?.getValue('url')?.split('/')[2]; licenseInfo = info.getPhotoLicenseInformation?.() || {}; } else { const data = info.registry?._data?.[photoId]; const sizes = data?.sizes; if (!sizes) { console.debug(`${photoId} : No sizes data in app registry`); return; } ds = Object.entries(sizes) .map(([key, value]) => { return { ...value, key } }) .sort((a, b) => sizesOrder.indexOf(a) - sizesOrder.indexOf(b)) .reverse(); ownerId = data.owner?.id; licenseInfo = {'value': data.license}; } if (ds) { console.debug(`${photoId} : Got info from app`); return {"descendingSizes": ds, "ownerId": ownerId, "licenseInfo": licenseInfo}; } } catch(e) { if (typeof e === 'object' && e.stat === 'fail') { if (e.message === 'Photo not found') { console.debug(`${photoId} : YUI said: "${e.message}". Photo might be locked or is private. Trying xhr.`); return; } return e; } console.warn(e); } } async function xhrGetInfo(photoId, photoURL) { console.debug(`${photoId} : Sending xhr: ${photoURL}`); let status; const response = await GM.xmlHttpRequest({ method: "GET", url: photoURL, }).catch(error => { console.log(`${photoId} : xhr error:`, error); status = error.status; }); if (!response) return {"message": "Request unsuccessful.", "responseStatus": status}; status = response.status; const dsMatch = response.responseText?.match(/descendingSizes":(\[.+?\])/); const ds = dsMatch?.[1]; if (ds) { const licenseMatch = response.responseText?.match(/"license":(\d+)/); const licenseInfo = {"value": licenseMatch?.[1]}; const ownerId = response.finalUrl.split('/')[4]; console.debug(`${photoId} : Got info from xhr`); return {"descendingSizes": JSON.parse(ds), "ownerId": ownerId, "licenseInfo": licenseInfo, "responseStatus": status}; } else { let msg = `${photoId} : No regex match at url ${response.finalUrl}`; let reason = ''; if (response.finalUrl.endsWith('///')) reason += ' (photo not found)'; if (response.responseText.match(/This photo is private/)) reason += ' (photo is private)'; if (status != 200) reason += ` [status: ${status}]`; console.log(msg + reason); return {"message": reason.trimLeft(), "responseStatus": status}; } } function dl(downloadURL, downloadFilename, retryCount=0) { if (!retryCount && urlsDownloading.has(downloadURL)) return; const maxRetries = 2; const timeoutAfter = 45000; let download, failMsg; const checkStatus = async (responseObject, timeout) => { const status = responseObject.status; if (/^[045]/.test(status)) /* Violentmonkey */ { download.abort(); console.debug('Download failed.', {responseObject}); failMsg = `URL: ${downloadURL}\n\nDownload failed with status code: ${status}.`; if (timeout) failMsg += ` (Timed out after ${timeout / 1000}s)`; } if (responseObject.error) /* Tampermonkey */ { failMsg = `URL: ${downloadURL}\n\nDownload error: ${responseObject.error}`; console.debug(failMsg); } if (!failMsg) return; retryCount++; if (maxRetries && retryCount <= maxRetries) { const delay = getRandomNumber(1800, 2200); const retryMsg = `Status: ${status} | Retry ${retryCount}/${maxRetries} in ${delay / 1000}s.`; console.debug(retryMsg); //showMessage(retryMsg, 1790, messageContainerBottom); await sleep(delay); dl(downloadURL, downloadFilename, retryCount); } else { GM_notification(failMsg, SCRIPT_NAME); urlsDownloading.delete(downloadURL); } }; console.debug("Downloading image:", downloadURL); urlsDownloading.add(downloadURL); download = GM_download({ url: downloadURL, name: downloadFilename, timeout: timeoutAfter, headers: { 'Referer': window.location.origin, 'Accept': '*/*', }, onprogress: (res) => { checkStatus(res) }, ontimeout: (res) => { checkStatus(res, timeoutAfter) }, onerror: (res) => { checkStatus(res) }, onload: () => { console.debug("Download complete."); urlsDownloading.delete(downloadURL); }, }); } async function populate(data) { if (!data.node || idsPopulating.has(data.photoId) || nodesBlacklisted.has(data.node)) return; const linkCount = data.dropdownContent.querySelectorAll('a').length; if (linkCount) { if (linkCount == 1 && /^5/.test(cache[data.photoId]?.responseStatus)) { console.debug('[populate] Last server response was 5xx. Retrying.'); data.dropdownContent.innerHTML = ''; delete cache[data.photoId]; } else { //console.debug('[populate] Dropdown content is populated.'); data.dropdownButton.classList.remove('dmfl-populating'); return; } } if (!appInitComplete) { data.dropdownButton.style.cursor = 'progress'; setTimeout(() => populate(data), 1000); return; } data.dropdownButton.classList.remove('dmfl-populated'); data.dropdownButton.style.cursor = 'inherit'; idsPopulating.add(data.photoId); if (data.populateFailed) { data.dropdownButton.classList.remove('dmfl-populated-fail'); data.dropdownContent.classList.remove('dmfl-populated-fail'); data.dropdownContent.innerText = ''; data.populateFailed = false; } /* First try to get sizes info from the YUI `appContext` global variable. * Some of this object's methods might not be available as the UserScripts API * implementation may differ across userscript managers. For Chromium-based web * browsers this shouldn't be an issue. However, if the YUI module is not * available, a xhr will be sent as a fallback. * * Also, see note at the top of the file. */ const photoId = data.photoId; const photoURL = data.photoURL; const cachedInfo = cache[photoId]; if (!cachedInfo) { data.dropdownButton.textContent = ''; data.dropdownButton.classList.add('dmfl-populating'); } else { console.debug(`${photoId} : Got info from cache.`); } const info = cachedInfo || await appGetInfo(photoId) || await xhrGetInfo(photoId, photoURL); const author = data.author || info?.ownerId; let licenseInfo; if (o.PREVIEW_MODE_SHOW_LICENSE_INFO && info?.licenseInfo?.value != null) { const licenseInfoValue = info.licenseInfo.value.toString(); if (licenseInfoValue) { const isCompleteInfo = (info.licenseInfo.url != null) && (info.licenseInfo.text != null); licenseInfo = isCompleteInfo ? info.licenseInfo : LICENSE_INFO.find((item => item.value === licenseInfoValue)); } } let descendingSizes = info?.descendingSizes; if (!Array.isArray(descendingSizes)) { let failMessage = 'No sizes found.'; failMessage += info?.message ? `\n\n${info.message}` : ''; console.log(`${photoId} : ${failMessage}`); if (data.isImage && data.node.naturalWidth != 0) { console.log(`${photoId} : Adding image src as sole entry.`); const imageData = Object.create(null); imageData.src = data.node.src; imageData.width = parseInt(data.node.getAttribute('width')); imageData.height = parseInt(data.node.getAttribute('height')); imageData.key = '?'; descendingSizes = [imageData]; } else { data.populateFailed = true; data.dropdownButton.classList.remove('dmfl-populated'); data.dropdownButton.classList.remove('dmfl-populating'); data.dropdownButton.classList.add('dmfl-populated-fail'); data.dropdownButton.textContent = '\u26a0'; data.dropdownContent.classList.add('dmfl-populated-fail'); data.dropdownContent.innerText = failMessage; } } if (!cache[photoId]) { cache[photoId] = { 'descendingSizes': descendingSizes, 'ownerId': author, 'licenseInfo': licenseInfo, 'responseStatus': info?.responseStatus, } } if (data.populateFailed) { idsPopulating.delete(photoId); return; } for (const item of descendingSizes) { const imageURL = item.url || item.src || item.displayUrl; if (!imageURL) { console.debug("descendingSizes item has no url"); continue; } const filename = imageURL.split('/').pop(); const extension = filename.split('.').pop(); const entry = document.createElement('div'); entry.className = 'dmfl-dropdown-entry'; const anchor = document.createElement('a'); anchor.className = 'dmfl-dropdown-entry-anchor'; let downloadURL = ''; if (imageURL.startsWith('//')) { downloadURL += data.scheme; } downloadURL += imageURL.replace(/(\.[a-z]+)$/i, '_d$1'); anchor.setAttribute('href', imageURL); anchor.textContent = `${item.width} x ${item.height} (${item.key})`; if (item.key == '?' && info?.message) { anchor.setAttribute('title', `All sizes not available ${info.message}`); } if (!extension.endsWith('jpg')) { anchor.textContent += ` [${extension}]`; } const downloadFilename = author && o.PREPEND_AUTHOR_ID ? `${author}_-_${filename}` : filename; anchor.addEventListener('click', (event) => { dl(downloadURL, downloadFilename); event.preventDefault(); }) entry.appendChild(anchor); const previewButton = document.createElement('div'); previewButton.className = 'dmfl-dropdown-entry-preview-button'; previewButton.textContent = '\u00a0\u229e\u00a0'; const pmData = {}; pmData.downloadURL = downloadURL; pmData.downloadFilename = downloadFilename; pmData.licenseInfo = licenseInfo; pmData.item = item; pmData.imageURL = imageURL; previewButton.onclick = () => PreviewMode.init(pmData); entry.appendChild(previewButton); data.dropdownContent.appendChild(entry); } data.dropdownButton.classList.remove('dmfl-populating'); data.dropdownButton.classList.add('dmfl-populated'); data.dropdownButton.textContent = BUTTON_TEXT; idsPopulating.delete(photoId); } function processNode(node) { let href, isAnchor, isImage, isMainPhoto, isMainPageEngagement, isLightboxEngagement; if (node.nodeName == "A") { href = node.href; if (!isValidHref(href)) return; isAnchor = true; } else if (node.nodeName == "IMG") { isMainPhoto = node.classList.contains('main-photo'); if (isMainPhoto) { if (o.MAIN_PHOTO_ENGAGEMENT_VIEW || isLightboxURL(document.URL)) return; href = document.URL; } else { if (!isValidImageURL(node.src)) return; const anchor = node.parentNode; if (!isValidHref(anchor?.href)) return; href = anchor.href; } isImage = true; } else if (/engagement/.test(node.className)) { if (!node.childElementCount) return; isMainPageEngagement = node.classList.contains('photo-engagement-view'); if (isMainPageEngagement && !o.MAIN_PHOTO_ENGAGEMENT_VIEW) return; isLightboxEngagement = node.classList.contains('photo-card-engagement'); href = document.URL; } else { return; } if (typeof href !== 'string') return; const photoIsLocked = (href.indexOf('flickr.com/gp/') >= 0); const photoIsUnlocked = (href.indexOf('flickr.com/photos/') >= 0); if (!photoIsLocked && !photoIsUnlocked) { console.debug(`(ignore) No valid href at ${document.URL} for node with className "${node.className}", href: ${href}`, node); return; } if (/\/(albums|groups|galleries)\//.test(href)) { console.debug('Blacklisting node with href:', href); nodesBlacklisted.add(node); return; } const components = href.split('/'); const scheme = components[0]; const author = components[4]; const photoId = isImage && !isMainPhoto ? node.src.split('/').pop().split('_')[0] : components[5]; if (!photoId) { console.debug("No photo ID found while processing node", node); nodesBlacklisted.add(node); return; } if (!photoIsLocked && isNaN(Number(photoId))) { console.debug(`Not a valid photoId "${photoId}"`, node); nodesBlacklisted.add(node); return; } components.length = Math.min(components.length, 5); components.push(photoId); const userSignedIn = Boolean(typeof unsafeWindow !== undefined ? unsafeWindow.appContext?.auth?.signedIn : null); const photoURL = photoIsLocked ? href : userSignedIn ? `${window.location.origin}/photo.gne?id=${photoId}` : components.join('/').replace('/gp/', '/photos/'); const dropdownContainer = document.createElement('div'); const dropdownButton = document.createElement('div'); const dropdownContent = document.createElement('div'); dropdownContainer.className = 'dmfl-dropdown-container'; dropdownButton.className = 'dmfl-dropdown-button'; dropdownButton.textContent = BUTTON_TEXT; dropdownButton.onclick = () => showSettings(); dropdownContent.className = 'dmfl-dropdown-content'; dropdownContainer.appendChild(dropdownButton); dropdownContainer.appendChild(dropdownContent); const data = Object.create(null); data.node = node; data.photoId = photoId; data.photoURL = photoURL; data.scheme = scheme; data.author = author; data.isAnchor = isAnchor; data.isImage = isImage; data.isMainPhoto = isMainPhoto; data.isMainPageEngagement = isMainPageEngagement; data.isLightboxEngagement = isLightboxEngagement; data.dropdownButton = dropdownButton; data.dropdownContent = dropdownContent; data.dropdownContainer = dropdownContainer; const dmflNodes = [dropdownContainer, dropdownButton, dropdownContent]; if (isMainPageEngagement) { dmflNodes.forEach(n => { n.classList.add('dmfl-main-photo-page'); n.classList.add('dmfl-engagement-view'); }); const dlButton = node.getElementsByClassName('download')[0]; if (o.REPLACE_FLICKR_DL_BUTTON && dlButton) { node.replaceChild(dropdownContainer, dlButton); } else { node.appendChild(dropdownContainer); } if (!o.IMMEDIATE) { dropdownContainer.onmouseenter = () => populate(data); } } else if (isLightboxEngagement) { const onEnter = (e) => { if (!o.IMMEDIATE) populate(data); const footer = document.getElementsByClassName('foot-overlay')[0]; if (footer && !lightboxIntervalID) { console.debug('[lightbox] starting interval'); lightboxIntervalID = setInterval(() => { if (footer?.isConnected) { footer.dispatchEvent(new MouseEvent('mousemove', {"bubbles": true})); } }, 500) } } const onLeave = (e) => { if (!lightboxIntervalID || isDropdownElement(e.relatedTarget)) return; console.debug('[lightbox] clearing interval'); clearInterval(lightboxIntervalID); lightboxIntervalID = null; } dropdownContainer.onmouseenter = onEnter; dmflNodes.forEach(n => { n.classList.add('dmfl-lightbox-page'); n.classList.add('dmfl-engagement-view'); n.onmouseleave = onLeave; }); node.appendChild(dropdownContainer); } else if (isAnchor || isImage) { // Thumbnails, main photo, discussions page images dmflNodes.forEach(n => n.classList.add('dmfl-thumbnail')); } nodesProcessed.set(node, data); console.debug(`Created dropdown for nodeName ${node.nodeName} | class ${node.className} | nodesProcessed: ${nodesProcessed.size}`); if (o.IMMEDIATE) { setTimeout(() => { /* Don't want to keep the observer busy for _too_ long */ populate(data); }, 200); } } function handleKeydown(e) { if (!PreviewMode.active || !o.KEYBINDINGS[e.keyCode] || !o.KEYBINDINGS[e.keyCode].data.modifierKeys.every(modKey => e[modKey] === true) ) return; e.preventDefault(); e.stopPropagation(); const setting = o.KEYBINDINGS[e.keyCode].setting; switch (setting) { case "PREVIEW_MODE_ROTATE_CW_KB": PreviewMode.rotate(90); break; case "PREVIEW_MODE_ROTATE_CCW_KB": PreviewMode.rotate(-90); break; case "PREVIEW_MODE_ZOOM_IN_KB": PreviewMode.zoom("in"); break; case "PREVIEW_MODE_ZOOM_OUT_KB": PreviewMode.zoom("out"); break; case "PREVIEW_MODE_TOGGLE_FIT_KB": PreviewMode.toggleFit(); break; case "PREVIEW_MODE_EXIT_KB": PreviewMode.exit(); break; } } function handleMouse(e) { if (isDropdownElement(e.target) || isLightboxPage) return; if (e.type == "mouseenter") { let target = e.target; if (!o.MAIN_PHOTO_ENGAGEMENT_VIEW && mainPhoto && mainPhoto.isConnected && /(photo-notes-scrappy-view|facade-of-protection-neue)/.test(target.className) ) { target = mainPhoto; } if (MouseTarget.t == target) return; const data = nodesProcessed.get(target); if (!data || (o.MAIN_PHOTO_ENGAGEMENT_VIEW && data.isMainPageEngagement)) return; // New target acquired, clear the old target and track the new target dropdownHide(); MouseTarget.clear(); MouseTarget.set(data); } else if (e.type == "mousemove") { if (!MouseTarget.t) return; if (!MouseTarget.rect.x || !MouseTarget.rect.y || (lastScrollY != null && (lastScrollY != scrollY || lastScrollX != scrollX)) ) { MouseTarget.updateRect(); } lastScrollX = window.scrollX; lastScrollY = window.scrollY; if (!overlayContainer.children.length && MouseTarget.isHovered(e)) { dropdownShow(); populate(MouseTarget.data); } else if (overlayContainer.children.length && !MouseTarget.isHovered(e)) { dropdownHide(); MouseTarget.clear(); } } } const TARGET_NODES = [ 'img.main-photo', 'td#GoodStuff span.photo_container a img', /* old theme */ 'div.photo-list-view .photo-container a', /* galleries */ 'div.photo-list-view a.overlay', /* common thumbnail */ 'div.photo-list-view a.photo-link', /* new albums layout */ 'div.photo-list-tile-view > a', /* search page tile view */ 'div.group-discussion-topic-view div.message-text a img', /* discussions page images */ 'div.photo-content-upper-container .photo-engagement-view', /* main photo page engagement */ 'div.photo-page-lightbox-scrappy-view .photo-card-engagement', /* lightbox page engagement */ ].join(","); function checkBody() { // Detect page changes and clear our states const rootview = document.body.getElementsByClassName('flickr-view-root-view')[0]; if (rootview && (lastURL != document.URL) && (lastRootView != rootview || !rootview.isConnected)) { if (lastURL) { console.debug('Rootview changed.'); dropdownHide(); MouseTarget.clear(); nodesProcessed.forEach((_, node) => { if (!node.isConnected) nodesProcessed.delete(node); }); PreviewMode.clear(); } lastRootView = rootview; lastURL = document.URL; } // Update dropdown position if document size changes const scrollHeight = document.documentElement.scrollHeight; const scrollWidth = document.documentElement.scrollWidth; if (scrollHeight != lastScrollHeight || scrollWidth != lastScrollWidth) { if (overlayContainer.children.length && MouseTarget.t) { MouseTarget.setPos(overlayContainer.children[0]); } lastScrollHeight = scrollHeight; lastScrollWidth = scrollWidth; } // Determine specific contexts mainPhoto = document.getElementsByClassName('main-photo')[0]; isLightboxPage = isLightboxURL(document.URL); // Scan for newly added nodes document.body.querySelectorAll(TARGET_NODES).forEach(node => { if (!nodesProcessed.has(node) && !nodesBlacklisted.has(node)) processNode(node); }); } async function appInit() { let retryCount = 0; if (typeof unsafeWindow !== undefined) { while (!appInitOk) { if (!unsafeWindow.appContext?.getModel) { retryCount++; console.log(`Waiting for YUI appContext... (Retry ${retryCount}/10)`); if (retryCount == 10 || document.body.querySelector?.('div#Main')) break; await sleep(1000); } else { appInitOk = true; } } } if (!appInitOk) { console.log("YUI failed to init. Running in xhr mode."); } else { console.log("YUI app ready."); } appInitComplete = true; } async function pageContentInit() { let retryCount = 0; while (!pageContent) { retryCount++; if (retryCount == 150) break; pageContent = document.querySelector('div#content') || document.querySelector('div#Main'); await sleep(100); } } (async () => { console.log('Waiting for page content.'); await pageContentInit(); if (pageContent) { console.log('Page content ready.'); } else { console.log('Page content not available'); return; } GM_registerMenuCommand('Settings', showSettings); const sl = document.createElement('div'); sl.className = 'dmfl-startup-loader'; sl.textContent = BUTTON_TEXT; document.body.appendChild(sl); console.log('Starting observer.'); const observer = new MutationObserver(checkBody); observer.observe(pageContent, { childList: true, subtree: true }); console.log('Adding styles.'); GM_addStyle(STYLE); overlayContainer = document.createElement('div'); overlayContainer.className = 'dmfl-overlay-container'; overlayContainer.style.display = 'none'; document.body.appendChild(overlayContainer); messageContainerTopleft = document.createElement('div'); messageContainerTopleft.className = 'dmfl-message-container dmfl-message-container-topleft'; document.body.appendChild(messageContainerTopleft); messageContainerBottom = document.createElement('div'); messageContainerBottom.className = 'dmfl-message-container dmfl-message-container-bottom'; document.body.appendChild(messageContainerBottom); previewControlsContainer = document.createElement('div'); previewControlsContainer.className = 'dmfl-preview-controls-container'; previewControlsContainer.innerHTML = previewControlsHTML; previewDlButton = document.createElement('span'); previewDlButton.className = 'dmfl-preview-download-button'; previewDlButton.innerHTML = previewDlButtonHTML; previewCloseButton = document.createElement('span'); previewCloseButton.className = 'dmfl-preview-close-button'; previewCloseButton.innerHTML = previewCloseButtonHTML; document.addEventListener('keydown', handleKeydown, true); document.addEventListener('mousemove', handleMouse, false); document.addEventListener('mouseenter', handleMouse, true); window.addEventListener('resize', () => PreviewMode.reset()); await appInit(); sl.remove(); })();