// ==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 flickr.com // @connect staticflickr.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 3.0 // @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. */ const SCRIPT_NAME = "Drop My Flickr Links!"; const $ = (selector, node=document) => node.querySelector(selector); const $$ = (selector, node=document) => node.querySelectorAll(selector); const $new = (tagName, className='', innerHTML='') => { const elem = document.createElement(tagName); elem.className = className; elem.innerHTML = innerHTML; return elem; } const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const random = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; const clamp = (min, max, value) => Math.max(min, Math.min(max, value)); const sequence = (min, max, step) => [...Array(Math.floor((max - min) / step) + 1).keys()].map(i => i * step + min); const getOr = (...args) => { while(args.length) { const v = args.shift(); if (v != null) { if (typeof v === 'string' && !v.length) continue; return v; }; } } const isLightboxURL = (url) => url.lastIndexOf('/lightbox') > 34; const hasClass = (node, className) => node.classList?.contains(className); const isDropdownElement = (el) => el?.getAttribute?.('class')?.startsWith?.('dmfl-dd'); const mouseInside = (e, rect) => (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom); const isValidImageURL = (url) => /(? /flickr\.com\/(photos(?!\/tags\/)\/[-\w@]+\/[0-9]+|gp\/[-\w@]+\/[\w]+)(?!.*\/sizes\/)/.test(href); const Settings = { defaults: { MAIN_PHOTO_ENGAGEMENT_VIEW: { section: 'general', type: 'checkbox', 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: { section: 'general', type: 'checkbox', 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: { section: 'general', type: 'checkbox', value: true, name: 'Prepend author ID to the saved image file name 429 ?', desc: `While this may be a quality-of-life feature, enabling this option comes at the expense of using 'GM_download' to save images, which means that a custom XHR is sent every time you attempt to download an image. Downloading a lot of images in a short period of time may trigger 429 response status codes from the server. While the script does have mitigations set in place (such as automatic retries) if this is bound to happen, if 429 errors become too frequent and a nuisance for you, consider switching this option off.`, }, /* Dropdown button appearance */ BUTTON_WIDTH: { section: 'appearance', type: 'select', value: 24, // Should be an even number so that the svg inside centers properly name: 'Dropdown button size', desc: `CSS pixel unit value.`, options: sequence(10, 100, 2), }, BUTTON_TEXT_COLOR: { section: 'appearance', type: 'text', value: '#ffffff', name: 'Dropdown button text color', desc: `CSS color value.`, }, BUTTON_BG_COLOR: { section: 'appearance', type: 'text', value: '#5272ad', /* '#6495ed' */ name: 'Dropdown button background color', desc: `CSS color value.`, }, BUTTON_HOVER_BG_COLOR: { section: 'appearance', type: 'text', value: '#519c60', name: 'Dropdown button background color on hover', desc: `CSS color value.`, }, BUTTON_OPACITY: { section: 'appearance', 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: { section: 'appearance', 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_SIZE: { section: 'appearance', type: 'number', value: 18, min: 5, max: 100, step: 1, name: 'Dropdown menu text size', desc: `CSS pixel unit value.`, }, CONTENT_A_TEXT_COLOR: { section: 'appearance', type: 'text', value: '#000000', name: 'Dropdown menu anchor element text color', desc: `CSS color value.`, }, CONTENT_A_BG_COLOR: { section: 'appearance', type: 'text', value: '#e8e9db', name: 'Dropdown menu anchor element background color', desc: `CSS color value.`, }, CONTENT_A_HOVER_BG_COLOR: { section: 'appearance', type: 'text', value: '#cfdbe1', name: 'Dropdown menu anchor element background color on hover', desc: `CSS color value.`, }, CONTENT_A_PADDING: { section: 'appearance', type: 'text', value: '5px 10px', name: 'Dropdown menu anchor element padding', desc: `CSS padding value.`, }, CONTENT_DIV_TEXT_COLOR: { section: 'appearance', type: 'text', value: '#000000', name: 'Dropdown menu preview element text color', desc: `CSS color value.`, }, CONTENT_DIV_BG_COLOR: { section: 'appearance', type: 'text', value: '#e7e4c5', name: 'Dropdown menu preview element background color', desc: `CSS color value.`, }, CONTENT_DIV_HOVER_BG_COLOR: { section: 'appearance', type: 'text', value: '#8dc5ed', name: 'Dropdown menu preview element background color on hover', desc: `CSS color value.`, }, CONTENT_DIV_PADDING: { section: 'appearance', type: 'text', value: '5px 18px', name: 'Dropdown menu preview element padding', desc: `CSS padding value.`, }, /* Dropdown navigation */ DROPDOWN_NAV_UP_KB: { section: 'keybindings', type: 'kbd', value: 'q', name: 'Dropdown navigation "up"', desc: `Cycles through the dropdown entries upwards and around.`, }, DROPDOWN_NAV_DOWN_KB: { section: 'keybindings', type: 'kbd', value: 'w', name: 'Dropdown navigation "down"', desc: `Cycles through the dropdown entries downwards and around.`, }, /* Preview mode */ PREVIEW_MODE_FADE_IN: { section: 'appearance', type: 'checkbox', value: true, name: 'Preview mode fade in transition', desc: `Adds a "fade in" animation when entering preview mode.`, }, PREVIEW_MODE_SHOW_CONTROLS: { section: 'appearance', type: 'checkbox', value: true, name: 'Preview mode show image controls', desc: `Adds a widget for image control to the top left corner when in preview mode.`, }, PREVIEW_MODE_SHOW_CLOSE_BUTTON: { section: 'appearance', type: 'checkbox', 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_DOWNLOAD_BUTTON: { section: 'appearance', type: 'checkbox', value: true, name: 'Preview mode show download button', desc: `Adds a clickable download button to the bottom right corner when in preview mode.`, }, PREVIEW_MODE_SHOW_RESOLUTION_INFO: { section: 'appearance', type: 'checkbox', value: true, name: 'Preview mode show image resolution information', desc: `Shows the photo's dimensions when in preview mode.`, }, PREVIEW_MODE_SHOW_LICENSE_INFO: { section: 'appearance', type: 'checkbox', value: true, name: 'Preview mode show license information', desc: `Shows a hyperlink to the photo's license when in preview mode.`, }, PREVIEW_MODE_AUTOENTER: { section: 'general', type: 'checkbox', value: false, name: 'Preview mode auto-enter', desc: `Automatically enters preview mode with the largest available image size when hovering images with the mouse cursor.`, }, PREVIEW_MODE_AUTOENTER_DELAY: { section: 'general', type: 'number', value: 1000, min: 100, max: 10000, step: 100, name: 'Preview mode auto-enter delay', desc: `How much time to wait (in milliseconds) after hovering an image and before loading the preview. Has no effect if "Preview mode auto-enter" is off.`, }, PREVIEW_MODE_AUTOENTER_FREEZE: { section: 'general', type: 'checkbox', value: false, name: 'Preview mode auto-enter freeze', desc: `If on, mouse movement does not exit preview mode. If off, preview mode exits as soon as the mouse cursor leaves the bounding box of the image that was previously hovered. Has no effect if "Preview mode auto-enter" is off.`, }, PREVIEW_MODE_SCROLL_TO_ZOOM: { section: 'general', type: 'checkbox', value: true, name: 'Preview mode zoom on mouse scroll', desc: `Zoom the preview image with the mouse wheel.`, }, PREVIEW_MODE_EXIT_ON_MOUSE_EVENT: { section: 'general', type: 'select', value: 'dblclick', name: 'Preview mode exit on mouse event', desc: `Exits preview mode on this mouse event.`, options: {'Double click': 'dblclick', 'Click': 'click', 'None': ''}, }, PREVIEW_MODE_BACKGROUND_OPACITY: { section: 'appearance', type: 'number', value: 0.65, min: 0, max: 1, step: 0.01, name: 'Preview mode background opacity', desc: `CSS alpha value. Range [0.0, 1.0].`, }, PREVIEW_MODE_ICON_WIDTH: { section: 'appearance', type: 'select', value: 40, // Should be an even number so that the svg inside centers properly name: 'Preview mode icon size', desc: `CSS pixel unit value.`, options: sequence(30, 150, 2), }, PREVIEW_MODE_ICON_FILL_COLOR: { section: 'appearance', 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_BG_COLOR: { section: 'appearance', type: 'text', value: '#586887', name: 'Preview mode icon background color', desc: `CSS color value.`, }, PREVIEW_MODE_ICON_OPACITY: { section: 'appearance', type: 'number', value: 0.4, min: 0, max: 1, step: 0.01, name: 'Preview mode icon opacity', desc: `CSS alpha value. Range [0.0, 1.0].`, }, SAVE_IMAGE_KB: { section: 'keybindings', type: 'kbd', value: 's', name: 'Save image', desc: `Downloads and saves the image locally (same as manually clicking the links in the dropdown). Can be pressed when the preview mode is open or when the dropdown element is shown inside the page, in which case, either the largest available image size or the one selected via dropdown navigation keys will be saved.`, }, PREVIEW_MODE_ENTER_KB: { section: 'keybindings', type: 'kbd', value: 'e', name: 'Preview mode enter', desc: `Enters preview mode with the largest available image size if no dropdown entry is selected via dropdown navigation keys, or with the selected navigation entry image size.`, }, PREVIEW_MODE_EXIT_KB: { section: 'keybindings', type: 'kbd', value: 'Escape', name: 'Preview mode exit / Dropdown navigation hide', desc: `Exits preview mode or hides the dropdown content if navigation has been started.`, }, PREVIEW_MODE_ROTATE_CW_KB: { section: 'keybindings', type: 'kbd', value: '>', name: 'Preview mode rotate clockwise key', desc: `Rotates the preview image 90 degrees clockwise when this key is pressed.`, }, PREVIEW_MODE_ROTATE_CCW_KB: { section: 'keybindings', type: 'kbd', value: '<', 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: { section: 'keybindings', type: 'kbd', value: '+', name: 'Preview mode zoom in key', desc: `Zooms in the preview image when this key is pressed.`, }, PREVIEW_MODE_ZOOM_OUT_KB: { section: 'keybindings', type: 'kbd', value: '-', name: 'Preview mode zoom out key', desc: `Zooms out the preview image when this key is pressed.`, }, PREVIEW_MODE_TOGGLE_FIT_KB: { section: 'keybindings', type: 'kbd', value: '*', 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.`, }, }, getValue(settingName, settingsObj) { const defaultData = this.defaults[settingName]; const defaultOpts = defaultData.options; const defaultValue = defaultData.value; let value = settingsObj.hasOwnProperty(settingName) ? settingsObj[settingName] : defaultValue; // Starting with version 3, none of the setting values are objects. In order // to preserve the existing stored settings, try to salvage saved values // from the previous settings object which had a different structure. if (typeof value === 'object') { value = value.value; if (typeof value === 'object') { value = value.key; } } if (typeof value === typeof defaultValue) { if (defaultOpts && !Object.values(defaultOpts).includes(value)) { return defaultValue; } return value; } return defaultValue; }, // Flatten the the settings object down to the `value` field getOpts(settingsObj) { const opts = Object.create(null); opts.KEYBINDINGS = Object.create(null); for (const settingName in this.defaults) { const value = this.getValue(settingName, settingsObj); opts[settingName] = value; if (settingName.endsWith('_KB') && getOr(value)) { opts.KEYBINDINGS[value] = settingName; } } opts.PREVIEW_MODE_IS_VOLATILE = opts.PREVIEW_MODE_AUTOENTER && !opts.PREVIEW_MODE_AUTOENTER_FREEZE; return opts; }, } 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 ICONS = { "default": { /* * https://www.svgrepo.com/collection/chunk-16px-thick-interface-icons/ * Author: Noah Jacobus * Website: https://noahjacob.us/ * License: PD */ loader: ` ` , dd_db_populated: ` ` , pm_close_but: ` ` , pm_dl_but: ` ` , pc_main: ` ` , pc_rotcw: ` ` , pc_rotccw: ` ` , pc_togglefit: ` ` , pc_zoomin: ` ` , pc_zoomout: ` ` , }, }; const SIZES_ORDER = [ "o", "8k", "7k", "6k", "5k", "4k", "3k", "k", "h", "l", "c", "z", "m", "w", "n", "s", "q", "t", "sq" ]; const overlay = $new('div', 'dmfl-overlay'); overlay.style.display = 'none'; const loader = $new('div', 'dmfl-loader', ICONS.default.loader); let startupLoader = $new('div', 'dmfl-startup-loader', ICONS.default.dd_db_populated); const nodesProcessed = new Map(); const nodesBlacklisted = new Set(); const idsPopulating = new Set(); const urlsDownloading = new Set(); const page = Object.create(null); const cache = Object.create(null); const o = Settings.getOpts(GM_getValue('settings', {})); let styleElement; function setStyle(o) { styleElement?.remove(); const style = ` :root.dmfl-pv-open, :root.dmfl-sm-open { overflow: hidden; } :root.dmfl-pv-open.dmfl-sm-open .dmfl-sm { transition: background-color 1.5s; background-color: rgba(0,0,0,0); } @keyframes dmfl-fade-anim { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes dmfl-scale-anim { 0% { transform: scale(0); visibility: hidden; } 100% { transform: scale(1); visibility: visible; } } @keyframes dmfl-spin-anim { 0% { transform: rotate(0deg); } 50% { transform: rotate(180deg); background-color: ${o.BUTTON_HOVER_BG_COLOR}; } 100% { transform: rotate(360deg); } } /* ================ === Dropdown === ================ */ .dmfl-dd-container { width: ${o.BUTTON_WIDTH}px; height: ${o.BUTTON_WIDTH}px; display: block; cursor: pointer; z-index: 203; } .dmfl-dd-container.dmfl-sm-mode { position: absolute; z-index: 20001; } .dmfl-dd-container.dmfl-thumbnail { position: absolute; width: max-content; height: max-content; padding: 3px; } .dmfl-dd-container[class*="dmfl-engagement-view"] { display: flex; position: relative; } .dmfl-dd-container.dmfl-engagement-view-main-photo-page { align-items: center; margin-right: 12px; } .dmfl-dd-container:hover .dmfl-dd-content, .dmfl-dd-container.dmfl-dd-select-mode .dmfl-dd-content { display: block; } .dmfl-dd-container:hover .dmfl-dd-button.dmfl-populated, .dmfl-dd-container.dmfl-dd-select-mode .dmfl-dd-button.dmfl-populated { opacity: ${o.BUTTON_HOVER_OPACITY}; } .dmfl-dd-container:hover .dmfl-dd-button.dmfl-populated, .dmfl-dd-container.dmfl-dd-select-mode .dmfl-dd-button.dmfl-populated { background-color: ${o.BUTTON_HOVER_BG_COLOR}; } .dmfl-dd-button .dmfl-svg-dd-db-populated { width: round(down, ${o.BUTTON_WIDTH * 0.5}px, 2px); height: round(down, ${o.BUTTON_WIDTH * 0.5}px, 2px); fill: ${o.BUTTON_TEXT_COLOR}; } .dmfl-dd-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-dd-button.dmfl-sm-mode { animation: 0.5s ease-out 0s 1 normal dmfl-spin-anim; } .dmfl-dd-button.dmfl-thumbnail { position: relative; } .dmfl-dd-button[class*="dmfl-engagement-view"] { position: absolute; } .dmfl-dd-button.dmfl-populated-fail { background-color: #f08080; /* lightcoral */ } .dmfl-dd-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; } .dmfl-dd-content.dmfl-thumbnail { position: relative; } .dmfl-dd-content[class*="dmfl-engagement-view"] { position: absolute; right: 0; bottom: ${o.BUTTON_WIDTH}px; } .dmfl-dd-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-dd-entry { display: grid; grid-template-columns: 1fr auto; white-space: nowrap; line-height: normal; cursor: pointer; } .dmfl-dd-entry.dmfl-selected { outline: solid #6495ed; } .dmfl-dd-entry a { color: ${o.CONTENT_A_TEXT_COLOR} !important; background-color: ${o.CONTENT_A_BG_COLOR}; padding: ${o.CONTENT_A_PADDING}; } .dmfl-dd-entry a:hover { background-color: ${o.CONTENT_A_HOVER_BG_COLOR}; text-decoration: underline; } .dmfl-dd-entry .dmfl-dd-entry-pv { font-family: sans-serif; font-weight: lighter; color: ${o.CONTENT_DIV_TEXT_COLOR}; background-color: ${o.CONTENT_DIV_BG_COLOR}; padding: ${o.CONTENT_DIV_PADDING}; } .dmfl-dd-entry .dmfl-dd-entry-pv .dmfl-svg-dd-dc-pv { fill: ${o.CONTENT_DIV_TEXT_COLOR}; width: 100%; height: 100%; } .dmfl-dd-entry .dmfl-dd-entry-pv:hover { background-color: ${o.CONTENT_DIV_HOVER_BG_COLOR}; opacity: .9; } /* ==================== === Preview mode === ==================== */ .dmfl-pv { position: fixed; width: 100%; height: 100%; top: 0; left: 0; z-index: 30000; animation: ${o.PREVIEW_MODE_FADE_IN ? '0.35s ease-out forwards dmfl-fade-anim' : 'none'}; } .dmfl-pv-bg { background-color: rgb(0,0,0); /* Fallback color */ background-color: rgba(0,0,0,${o.PREVIEW_MODE_BACKGROUND_OPACITY}); display: flex; position: fixed; z-index: 30000; left: 0; top: 0; width: 100%; height: 100%; user-select: none; } .dmfl-pv-img-wrapper { position: fixed; width: 100vw; height: 100vh; top: 0; left: 0; overflow: hidden; } .dmfl-pv-img { --dmfl-pv-img-translateX: 0; --dmfl-pv-img-translateY: 0; --dmfl-pv-img-scale: 1; --dmfl-pv-img-rotate: 0deg; visibility: hidden; position: absolute; cursor: grab; max-width: none; max-height: none; translate: none; rotate: none; scale: none; transform-origin: 0 0; transform: translateX(var(--dmfl-pv-img-translateX)) translateY(var(--dmfl-pv-img-translateY)) rotate(var(--dmfl-pv-img-rotate)) scale(var(--dmfl-pv-img-scale)); } .dmfl-pv-bg svg[class*=dmfl-svg-pm-] { width: round(down, ${o.PREVIEW_MODE_ICON_WIDTH * 0.6}px, 2px); height: round(down, ${o.PREVIEW_MODE_ICON_WIDTH * 0.6}px, 2px); } .dmfl-pv-bg svg[class*=dmfl-svg-pm-]:not(.dmfl-svg-pm-closebut) > .dmfl-svg-path { fill: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; } .dmfl-pv-controls { --dmfl-pv-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: calc(${o.PREVIEW_MODE_ICON_WIDTH}px / 4); background-color: ${o.PREVIEW_MODE_ICON_BG_COLOR}; position: fixed; top: 20px; left: 20px; z-index: 30001; cursor: pointer; opacity: var(--dmfl-pv-controls-opacity); transition: all 1s; } .dmfl-pv-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-pv-controls-rubberband > span { align-self: center; } .dmfl-pv-controls-main { 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-svg-path.dmfl-svg-pm-pc-main { transition: fill 2s; } .dmfl-pv-controls-rubberband > span:not(.dmfl-pv-controls-main) { display: none; opacity: 0; transform-origin: bottom; transform: translateY(${o.PREVIEW_MODE_ICON_WIDTH}px); animation-name: dmfl-pv-controls-anim; animation-timing-function: ease-out; animation-fill-mode: forwards; animation-duration: 0.2s; } .dmfl-pv-controls:hover .dmfl-pv-controls-rubberband { width: ${o.PREVIEW_MODE_ICON_WIDTH * 6}px; opacity: 1; } .dmfl-pv-controls:hover { opacity: 1; background-color: #586887; } .dmfl-pv-controls:hover .dmfl-pv-controls-main { rotate: 90deg; } .dmfl-pv-controls:hover .dmfl-svg-path.dmfl-svg-pm-pc-main { fill: #e1d59f !important; } .dmfl-pv-controls:hover .dmfl-pv-controls-rubberband > span:not(.dmfl-pv-controls-main) { display: inline-flex; } .dmfl-pv-controls-rot-cw { animation-delay: 0.3s; } .dmfl-pv-controls-rot-ccw { animation-delay: 0.4s; } .dmfl-pv-controls-toggle-fit { animation-delay: 0.5s; } .dmfl-pv-controls-zoom-in { animation-delay: 0.6s; } .dmfl-pv-controls-zoom-out { animation-delay: 0.7s; } .dmfl-pv-controls-rubberband > span:not(.dmfl-pv-controls-main):hover { color: #7fffd4; /* aquamarine */ } .dmfl-pv-controls-rubberband > span:not(.dmfl-pv-controls-main):hover .dmfl-svg-path { fill: #7fffd4; } @keyframes dmfl-pv-controls-anim { to { opacity: 1; transform: translateY(0); } } .dmfl-pv-close { position: absolute; display: flex; align-items: center; justify-content: center; 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: ${o.PREVIEW_MODE_ICON_BG_COLOR}; border-radius: calc(${o.PREVIEW_MODE_ICON_WIDTH}px / 4); opacity: ${o.PREVIEW_MODE_ICON_OPACITY}; text-shadow: 1px 1px 1px black; z-index: 30001; cursor: pointer; } .dmfl-pv-close:hover { color: #c5a853; opacity: 1; } .dmfl-pv-close .dmfl-svg { fill: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; } .dmfl-pv-close:hover .dmfl-svg { fill: #c5a853; } .dmfl-pv-download { 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.PREVIEW_MODE_ICON_BG_COLOR}; position: fixed; z-index: 30001; right: 20px; bottom: 20px; border-radius: calc(${o.PREVIEW_MODE_ICON_WIDTH}px / 4); opacity: ${o.PREVIEW_MODE_ICON_OPACITY}; cursor: pointer; } .dmfl-pv-download:hover { opacity: 1; } .dmfl-pv-photo-info-wrapper { display: flex; color: #fff !important; font-size: smaller; position: fixed; z-index: 30001; left: 20px; bottom: 20px; } .dmfl-pv-photo-info-wrapper * { position: relative; display: inline-block; color: #fff !important; margin-right: 5px; padding: 2px 5px 2px 5px; border-radius: 5px; } .dmfl-pv-license-info { background-color: #2f4f4fa8; } .dmfl-pv-resolution-info { background-color: #4a5a78b3; } /* ====================== === Settings modal === ====================== */ .dmfl-sm { display: flex; visibility: hidden; 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-sm.open, .dmfl-sm.opening { visibility: visible; } .dmfl-sm.opening { animation: 0.35s ease-out forwards dmfl-fade-anim; animation-direction: normal; } .dmfl-sm.closing { animation: 0.35s ease-in forwards dmfl-fade-anim; animation-direction: reverse; } .dmfl-sm-content { display: flex; flex-direction: column; position: absolute; background-color: #dce0e9; padding: 1.25rem; border: 1px solid #628b97; width: max-content; max-height: 80%; overflow: hidden; overscroll-behavior: contain; border-radius: 10px; } .dmfl-sm-content.opening { animation: 0.35s ease-out forwards dmfl-scale-anim; animation-direction: normal; } .dmfl-sm-content.closing { animation: 0.35s ease-in forwards dmfl-scale-anim; animation-direction: reverse; } .dmfl-sm-body { display: grid; row-gap: 1.5em; overflow: auto; } .dmfl-sm-section { display: grid; row-gap: 5px; } .dmfl-sm-section h3 { color: #2860b7; background-color: #d8e5f3; font-weight: 500; font-size: 24px; padding: 5px; line-height: 30px; margin-block-start: 0 !important; margin-block-end: 0 !important; } .dmfl-sm-dummy-target { width: ${o.BUTTON_WIDTH}px; height: ${o.BUTTON_WIDTH}px; margin-right: 2px; } .dmfl-sm-header { display: inline-flex; justify-content: center; align-items: center; margin-top: 1em; margin-bottom: 2em; padding: 0px 50px 0px 50px; user-select: none; } .dmfl-sm-header span { font-weight: 400; font-size: calc(${o.BUTTON_WIDTH}px * 0.75) !important; color: #000; text-decoration: underline; } .dmfl-sm-footer { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-top: 1.25rem; } .dmfl-sm-save, .dmfl-sm-restore { color: #fff; background: #1f95dd; padding: 0 20px; height: 2.25rem !important; transition: none !important; border: none; border-radius: 3px; box-sizing: border-box; } .dmfl-sm-save:disabled, .dmfl-sm-restore:disabled, .dmfl-sm-change-key-button:disabled { color: #8b8989; background: #c5c7c9; } .dmfl-sm-entry { display: flex; column-gap: 20px; align-items: center; width: 100%; height: 2.5em; padding: 5px 15px 5px 5px; box-sizing: border-box; } .dmfl-sm-entry:nth-child(odd) { background: #d1d6df; } .dmfl-sm-label { position: relative; display: flex; border-bottom: 1px dotted black; cursor: context-menu; } .dmfl-sm-label sup { position: relative; line-height: 0; vertical-align: baseline; top: 0; color: #f1972a; font-size: 70%; } .dmfl-sm-entry input[type="text"] { padding: 3px 5px; margin: 0; } .dmfl-sm-entry input[type="number"] { text-align: center; width: 65px; padding-block: 2px; padding-inline: 2px; line-height: normal; } .dmfl-sm-color-picker { display: inline-block; inline-size: 48px; block-size: 26px; } .dmfl-sm-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-sm-close:hover, .dmfl-sm-close:focus { color: #a97174; text-decoration: none; } .dmfl-sm-change-key-button { color: #fff; background: #1f95dd; border: none; border-radius: 3px; box-sizing: border-box; height: 25px !important; line-height: normal !important; vertical-align: inherit !important; padding: 0 15px !important; transition: none !important; } .dmfl-sm-kbd { background: #f5f5f5; border: 2px solid #ada6a6; border-radius: 0.25rem; box-shadow: inset 0 -1px 0 0 #958e8e; font-size: .825rem; padding: .25rem; box-sizing: border-box; font-family: monospace; font-weight: 600; line-height: 1.5; text-align: left; } .dmfl-sm-select { font-size: 100%; border: 1px solid darkgray; line-height: normal; } /* ====================== === Popup messages === ====================== */ .dmfl-msg { position: fixed; display: flex; justify-content: center; align-items: center; visibility: hidden; color: #fff; 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-msg.top { --dmfl-msg-top-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-msg-top-minwidth); height: var(--dmfl-msg-top-minwidth); background-color: rgba(95, 129, 191, 0.7); font-size: calc(var(--dmfl-msg-top-minwidth) * 0.3); font-weight: 500; white-space: nowrap; } .dmfl-msg.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-svg-loader circle { stroke: ${o.PREVIEW_MODE_ICON_FILL_COLOR}; } .dmfl-loader { position: absolute; top: 50vh; left: 50vw; translate: -50% -50%; width: 45px; height: 45px; opacity: .65; z-index: 30002; } /* ====================== === Startup loader === ====================== */ .dmfl-startup-loader { display: flex; bottom: 70px; left: 50%; translate: -50%; align-items: center; justify-content: center; width: 50px; height: 50px; z-index: 60000; color: ${o.BUTTON_TEXT_COLOR}; background: ${o.BUTTON_BG_COLOR}; position: fixed; cursor: wait; animation: 3s infinite dmfl-spin-anim; } .dmfl-startup-loader .dmfl-svg-dd-db-populated { width: 24px; height: 24px; fill: ${o.BUTTON_TEXT_COLOR}; } `; console.log('Adding styles.'); styleElement = GM_addStyle(style); } const Messages = { init() { this.messages = Object.create(null); this.top = $new('div', 'dmfl-msg top'); this.bottom = $new('div', 'dmfl-msg bottom'); document.body.append(this.top, this.bottom); }, show(text, duration, location) { const container = this[location]; container.textContent = text; container.style.visibility = 'visible'; container.style.opacity = 1; clearTimeout(this.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); this.messages[container.className] = messageTimeoutId; } } const SettingsModal = { init() { this.onResize = () => this.dropdown?.updatePos(); this.modal = $new('div', 'dmfl-sm', `
`); document.body.appendChild(this.modal); this.content = $('.dmfl-sm-content', this.modal); this.closeButton = $('.dmfl-sm-close', this.modal); this.saveButton = $('.dmfl-sm-save', this.modal); this.restoreButton = $('.dmfl-sm-restore', this.modal); this.body = $('.dmfl-sm-body', this.modal); this.dummyTarget = $('.dmfl-sm-dummy-target', this.modal); this.dropdown = new Dropdown({ node: this.dummyTarget, photoId: 30891517230, photoPageURL: 'https://flickr.com/photos/giftsoftheuniverse/30891517230/', author: 'giftsoftheuniverse', }); this.dropdown.container.classList.add('dmfl-sm-mode'); this.dropdown.button.classList.add('dmfl-sm-mode', 'dmfl-thumbnail'); this.dropdown.content.classList.add('dmfl-thumbnail'); this.modal.onanimationend = (e) => { if (e.target.classList.contains('opening')) { e.target.classList.add('open'); e.target.classList.remove('opening'); } else if (e.target.classList.contains('closing')) { e.target.classList.remove('closing'); e.target.classList.remove('open'); } } this.content.onanimationend = (e) => { if (e.target.classList.contains('opening')) { console.log('opened'); e.target.classList.remove('opening'); Dropdown.active = this.dropdown; this.dropdown.show(); window.addEventListener('resize', this.onResize); } else if (e.target.classList.contains('closing')) { console.log('closed'); e.target.classList.remove('closing'); this.clearEntries(); this.shown = false; setStyle(Object.assign(o, this.currentOpts)); Dropdown.active = this.lastActiveDropdown?.container.isConnected ? this.lastActiveDropdown : null; window.removeEventListener('resize', this.onResize); MouseHandler.init(); } } this.closeButton.onclick = () => { this.cancelKeyWait(); this.dropdown?.hide(); this.content.classList.add('closing'); this.modal.classList.add('closing'); $(':root').classList.remove('dmfl-sm-open'); } this.restoreButton.onclick = () => { this.cancelKeyWait(); this.clearEntries(); this.tempSettings = {}; this.fill(Settings.defaults); setStyle(Object.assign(o, Settings.getOpts({}))); this.saveButton.disabled = false; this.restoreButton.disabled = true; } this.content.onsubmit = (e) => { if (e.submitter != this.saveButton) return; const kbEntries = Object.entries(this.tempSettings) .filter(([k, v]) => k.endsWith('_KB') && Boolean(v)); while (kbEntries.length) { const kbEntry = kbEntries.shift(); if (kbEntries.find(entry => entry[1] === kbEntry[1])) { Messages.show(`Key '${kbEntry[1]}' assigned more than once.`, 4000, 'bottom'); return; } } this.saveButton.disabled = true; this.restoreButton.disabled = true; this.cancelKeyWait(); GM_setValue('settings', this.tempSettings); this.dropdown?.hide(); this.content.style.setProperty('transition', 'scale 1s ease-in'); this.content.style.setProperty('transition-delay', '0.1s'); this.content.style.setProperty('scale', 0); setTimeout(() => { this.modal.appendChild(loader); location.reload(); }, 1000); } }, onValueChanged(key, value) { this.saveButton.disabled = false; this.restoreButton.disabled = false; console.debug(`${key} value changed:`, value); this.tempSettings[key] = value; Object.assign(o, Settings.getOpts(this.tempSettings)); if (Settings.defaults[key]?.section == 'appearance') { requestAnimationFrame(() => { setStyle(o); }); } }, cancelKeyWait() { if (!this.shown) return; if (this.onKeyDown) { document.removeEventListener('keydown', this.onKeyDown, true); this.onKeyDown = null; } this.waitingForKey = false; if (this.changeKeyButtonPressed) { this.changeKeyButtonPressed.textContent = 'Change'; this.changeKeyButtonPressed.disabled = false; } }, clearEntries() { $$('.dmfl-sm-entry', this.modal).forEach(entry => entry.remove()); }, fill(settings) { for (const [settingName, settingData] of Object.entries(Settings.defaults)) { const entryChildren = []; const entry = $new('div', 'dmfl-sm-entry'); let valueDesc = settingData.value; // Initialize temporary settings with either saved or default settings const settingValue = Settings.getValue(settingName, settings); this.tempSettings[settingName] = settingValue; const label = $new('label', 'dmfl-sm-label', settingData.name); entryChildren.push(label); if (/^(text|number|checkbox)$/.test(settingData.type)) { const inputElem = $new('input', 'dmfl-sm-input'); inputElem.setAttribute('type', settingData.type); let propertyToGet, propertyToSet; if (typeof settingData.value === 'boolean') { propertyToGet = propertyToSet = 'checked'; } else if (typeof settingData.value === 'number') { inputElem.setAttribute('min', settingData.min); inputElem.setAttribute('max', settingData.max); inputElem.setAttribute('step', settingData.step); inputElem.required = true; propertyToGet = 'valueAsNumber'; propertyToSet = 'value'; } else { propertyToGet = propertyToSet = 'value'; } inputElem[propertyToSet] = settingValue; inputElem.addEventListener('input', () => { this.onValueChanged(settingName, inputElem[propertyToGet]); }); entryChildren.push(inputElem); if (settingName.indexOf('_COLOR') >= 0) { const colorPicker = $new('input', 'dmfl-sm-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 (settingData.type == "kbd") { const inputElem = $new('input'); inputElem.setAttribute('type', 'checkbox'); inputElem.checked = Boolean(settingValue); const kbdElem = $new('kbd', 'dmfl-sm-kbd'); kbdElem.textContent = settingValue; const changeKeyButton = $new('button', 'dmfl-sm-change-key-button'); changeKeyButton.textContent = 'Change'; if (!inputElem.checked) { kbdElem.style.visibility = 'hidden'; changeKeyButton.style.visibility = 'hidden'; } changeKeyButton.onclick = (e) => { e.preventDefault(); if (this.waitingForKey) return; this.onKeyDown = (e) => { if (/^(Shift|Alt|Control|Meta)$/.test(e.key)) return; e.preventDefault(); e.stopPropagation(); document.addEventListener('keyup', (e) => { e.preventDefault(); e.stopPropagation(); }, { capture: true, once: true }); let keyUnavailable; for (const [k, v] of Object.entries(this.tempSettings)) { if (/_KB$/.test(k) && k != settingName && v === e.key) { const msg = `Key '${e.key}' already assigned to setting '${Settings.defaults[k].name}'.`; console.log(msg); Messages.show(msg, 4000, 'bottom'); keyUnavailable = true; } } if (!keyUnavailable) { kbdElem.textContent = e.key; this.onValueChanged(settingName, e.key); } this.cancelKeyWait(); } changeKeyButton.disabled = true; changeKeyButton.textContent = 'Press any key'; document.addEventListener('keydown', this.onKeyDown, true); this.changeKeyButtonPressed = changeKeyButton; this.waitingForKey = true; } inputElem.addEventListener('input', ({target: {checked: keyEnabled}}) => { if (this.waitingForKey) this.cancelKeyWait(); kbdElem.textContent = keyEnabled ? getOr(kbdElem.textContent, settingData.value) : ''; this.onValueChanged(settingName, kbdElem.textContent); kbdElem.style.visibility = changeKeyButton.style.visibility = keyEnabled ? 'visible' : 'hidden'; }); entryChildren.push(inputElem, kbdElem, changeKeyButton); } else if (settingData.type == "select") { const selectElem = $new('select', 'dmfl-sm-select'); const dataVal = settingData.value; const dataOpts = settingData.options; const isOptionsArray = Array.isArray(dataOpts); for (const [k, v] of Object.entries(dataOpts)) { const opt = $new('option'); opt.value = v; opt.textContent = isOptionsArray ? v : k; selectElem.appendChild(opt); if (v == dataVal) { valueDesc = isOptionsArray ? v : k }; } selectElem.value = settingValue; selectElem.addEventListener('change', ({target: {value: v}}) => { const parsedValue = (typeof dataVal === 'number' ? parseFloat(v) : v); this.onValueChanged(settingName, parsedValue); }); entryChildren.push(selectElem); } label.title = `${settingData.desc.replace(/\s+/g, ' ')}\n\nDefault: ${String(valueDesc) .replace(/^true$/, 'On').replace(/^false$/, 'Off')}`; entry.append(...entryChildren); $(`.dmfl-sm-section.${settingData.section}`, this.modal) .appendChild(entry); } }, show() { if (this.shown) return; this.lastActiveDropdown = Dropdown.active; Dropdown.active?.hide(); if (PreviewMode.active) PreviewMode.clear({ reason: 'opening settings modal' }); MouseHandler.destroy(); this.currentOpts = Object.assign(Object.create(null), o); this.tempSettings = GM_getValue('settings', {}); this.modal.classList.add('opening'); this.content.classList.add('opening'); $(':root').classList.add('dmfl-sm-open'); this.shown = true; this.saveButton.disabled = true; this.restoreButton.disabled = false; this.fill(this.tempSettings); } } const PreviewMode = { SCALE_FACTOR: 1.1, SCALE_MIN: 0.01, SCALE_MAX: 5, init() { this.container = $new('div', 'dmfl-pv'); this.container.style.display = 'none'; document.body.appendChild(this.container); this.photoInfoWrapper = $new('div', 'dmfl-pv-photo-info-wrapper'); this.downloadButton = $new('span', 'dmfl-pv-download', ICONS.default.pm_dl_but); this.closeButton = $new('span', 'dmfl-pv-close', ICONS.default.pm_close_but); this.controls = $new('div', 'dmfl-pv-controls', ` ${ICONS.default.pc_main}