Image Viewer

View full image without leaving the page

目前為 2022-05-14 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Image Viewer
// @version      1.2.1
// @description  View full image without leaving the page
// @namespace    https://github.com/nikolay-borzov
// @author       nikolay-borzov
// @license      MIT
// @icon         https://raw.githubusercontent.com/nikolay-borzov/user-scripts/master/image-viewer/icon.png
// @homepageURL  https://github.com/nikolay-borzov/user-scripts
// @homepage     https://github.com/nikolay-borzov/user-scripts
// @supportURL   https://github.com/nikolay-borzov/user-scripts/issues
// @match        *://*/*
// @connect      imagebam.com
// @connect      imagevenue.com
// @connect      www.turboimagehost.com
// @connect      fastpic.ru
// @connect      fastpic.org
// @connect      radikal.ru
// @connect      imagetwist.com
// @noframes
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_setValue
// @grant        GM.setValue
// @grant        GM_getValue
// @grant        GM.getValue
// @grant        GM_registerMenuCommand
// @grant        GM.registerMenuCommand
// ==/UserScript==
;(function () {
  /* global Bliss */
  // eslint-disable-next-line -- blissfuljs v1.0.6 Shy https://blissfuljs.com/
  !function(){function e(o,i,t){return i=void 0===i?1:i,(t=t||i+1)-i<=1?function(){if(arguments.length<=i||"string"===c.type(arguments[i]))return o.apply(this,arguments);var t,e,n=arguments[i];for(e in n){var r=Array.prototype.slice.call(arguments);r.splice(i,1,e,n[e]),t=o.apply(this,r);}return t}:e(e(o,i+1,t),i,t-1)}function s(e,n,t){var r=a(t);if("string"===r){var o=Object.getOwnPropertyDescriptor(n,t);!o||o.writable&&o.configurable&&o.enumerable&&!o.get&&!o.set?e[t]=n[t]:(delete e[t],Object.defineProperty(e,t,o));}else if("array"===r)t.forEach(function(t){t in n&&s(e,n,t);});else for(var i in n)t&&("regexp"===r&&!t.test(i)||"function"===r&&!t.call(n,i))||s(e,n,i);return e}function a(t){if(null===t)return "null";if(void 0===t)return "undefined";var e=(Object.prototype.toString.call(t).match(/^\[object\s+(.*?)\]$/)[1]||"").toLowerCase();return "number"==e&&isNaN(t)?"nan":e}var c=self.Bliss=s(function(t,e){return 2==arguments.length&&!e||!t?null:"string"===c.type(t)?(e||document).querySelector(t):t||null},self.Bliss);s(c,{extend:s,overload:e,type:a,property:c.property||"_",listeners:new(self.WeakMap?WeakMap:Map),original:{addEventListener:(self.EventTarget||Node).prototype.addEventListener,removeEventListener:(self.EventTarget||Node).prototype.removeEventListener},sources:{},noop:function(){},$:function(t,e){return t instanceof Node||t instanceof Window?[t]:2!=arguments.length||e?Array.prototype.slice.call("string"==typeof t?(e||document).querySelectorAll(t):t||[]):[]},defined:function(){for(var t=0;t<arguments.length;t++)if(void 0!==arguments[t])return arguments[t]},create:function(t,e){return t instanceof Node?c.set(t,e):(1===arguments.length&&(e="string"===c.type(t)?{}:(t=(e=t).tag,c.extend({},e,function(t){return "tag"!==t}))),c.set(document.createElement(t||"div"),e))},each:function(t,e,n){for(var r in n=n||{},t)n[r]=e.call(t,r,t[r]);return n},ready:function(e,t,n){if("function"!=typeof e||t||(t=e,e=void 0),e=e||document,t&&("loading"!==e.readyState?t():c.once(e,"DOMContentLoaded",function(){t();})),!n)return new Promise(function(t){c.ready(e,t,!0);})},Class:function(t){var e,n,r=["constructor","extends","abstract","static"].concat(Object.keys(c.classProps)),o=t.hasOwnProperty("constructor")?t.constructor:c.noop;2==arguments.length?(n=arguments[0],t=arguments[1]):((n=function(){if(this.constructor.__abstract&&this.constructor===n)throw new Error("Abstract classes cannot be directly instantiated.");n.super&&!e&&n.super.apply(this,arguments),o.apply(this,arguments);}).super=t.extends||null,!n.super||(e=0===(n.super+"").indexOf("class "))&&console.error(`You are using $.Class() to create a fake function-based class that extends a native JS class. This will not work. You should convert your code to use native JS classes too. You can still pass a class into $.Class() to use its conveniences.`),n.prototype=c.extend(Object.create(n.super?n.super.prototype:Object),{constructor:n}),n.prototype.super=n.super?n.super.prototype:null,n.__abstract=!!t.abstract);function i(t){return this.hasOwnProperty(t)&&-1===r.indexOf(t)}if(t.static)for(var s in c.extend(n,t.static,i),c.classProps)s in t.static&&c.classProps[s](n,t.static[s]);for(s in c.extend(n.prototype,t,i),c.classProps)s in t&&c.classProps[s](n.prototype,t[s]);return n},classProps:{lazy:e(function(t,e,n){return Object.defineProperty(t,e,{get:function(){var t=n.call(this);return Object.defineProperty(this,e,{value:t,configurable:!0,enumerable:!0,writable:!0}),t},set:function(t){Object.defineProperty(this,e,{value:t,configurable:!0,enumerable:!0,writable:!0});},configurable:!0,enumerable:!0}),t}),live:e(function(t,n,r){return "function"===c.type(r)&&(r={set:r}),Object.defineProperty(t,n,{get:function(){var t=this["_"+n],e=r.get&&r.get.call(this,t);return void 0!==e?e:t},set:function(t){var e=this["_"+n],e=r.set&&r.set.call(this,t,e);this["_"+n]=void 0!==e?e:t;},configurable:r.configurable,enumerable:r.enumerable}),t})},include:function(){var n=arguments[arguments.length-1],t=2===arguments.length&&arguments[0],r=document.createElement("script");return t?Promise.resolve():new Promise(function(t,e){c.set(r,{async:!0,onload:function(){t(r),r.parentNode&&r.parentNode.removeChild(r);},onerror:function(){e(r);},src:n,inside:document.head});})},load:function t(r,e){e=e?new URL(e,location.href):location.href,r=new URL(r,e);e=t.loading=t.loading||{};return e[r+""]||(/\.css$/.test(r.pathname)?e[r+""]=new Promise(function(t,e){var n=c.create("link",{href:r,rel:"stylesheet",inside:document.head,onload:function(){t(n);},onerror:function(){e(n);}});}):e[r+""]=c.include(r))},fetch:function(t,e){if(!t)throw new TypeError("URL parameter is mandatory and cannot be "+t);var n,r=s({url:new URL(t,location),data:"",method:"GET",headers:{},xhr:new XMLHttpRequest},e);for(n in r.method=r.method.toUpperCase(),c.hooks.run("fetch-args",r),"GET"===r.method&&r.data&&(r.url.search+=r.data),document.body.setAttribute("data-loading",r.url),r.xhr.open(r.method,r.url.href,!1!==r.async,r.user,r.password),e)if("upload"===n)r.xhr.upload&&"object"==typeof e[n]&&c.extend(r.xhr.upload,e[n]);else if(n in r.xhr)try{r.xhr[n]=e[n];}catch(t){self.console&&console.error(t);}var o,t=Object.keys(r.headers).map(function(t){return t.toLowerCase()});for(o in "GET"!==r.method&&-1===t.indexOf("content-type")&&r.xhr.setRequestHeader("Content-type","application/x-www-form-urlencoded"),r.headers)void 0!==r.headers[o]&&r.xhr.setRequestHeader(o,r.headers[o]);t=new Promise(function(t,e){r.xhr.onload=function(){document.body.removeAttribute("data-loading"),0===r.xhr.status||200<=r.xhr.status&&r.xhr.status<300||304===r.xhr.status?t(r.xhr):e(c.extend(Error(r.xhr.statusText),{xhr:r.xhr,get status(){return this.xhr.status}}));},r.xhr.onerror=function(){document.body.removeAttribute("data-loading"),e(c.extend(Error("Network Error"),{xhr:r.xhr}));},r.xhr.ontimeout=function(){document.body.removeAttribute("data-loading"),e(c.extend(Error("Network Timeout"),{xhr:r.xhr}));},r.xhr.send("GET"===r.method?null:r.data);});return t.xhr=r.xhr,t},value:function(t){var e="string"!=typeof t;return c.$(arguments).slice(+e).reduce(function(t,e){return t&&t[e]},e?t:self)}}),c.Hooks=new c.Class({add:function(t,e,n){if("string"==typeof arguments[0])(Array.isArray(t)?t:[t]).forEach(function(t){this[t]=this[t]||[],e&&this[t][n?"unshift":"push"](e);},this);else for(var t in arguments[0])this.add(t,arguments[0][t],e);},run:function(t,e){this[t]=this[t]||[],this[t].forEach(function(t){t.call(e&&e.context?e.context:e,e);});}}),c.hooks=new c.Hooks;c.property;c.Element=function(t){this.subject=t,this.data={},this.bliss={};},c.Element.prototype={set:e(function(t,e){t in c.setProps?c.setProps[t].call(this,e):t in this?this[t]=e:this.setAttribute(t,e);},0),transition:function(o,i){return new Promise(function(t,e){var n,r;"transition"in this.style&&0!==i?(n=c.extend({},this.style,/^transition(Duration|Property)$/),c.style(this,{transitionDuration:(i||400)+"ms",transitionProperty:Object.keys(o).join(", ")}),c.once(this,"transitionend",function(){clearTimeout(r),c.style(this,n),t(this);}),r=setTimeout(t,i+50,this),c.style(this,o)):(c.style(this,o),t(this));}.bind(this))},fire:function(t,e){var n=document.createEvent("HTMLEvents");return n.initEvent(t,!0,!0),this.dispatchEvent(c.extend(n,e))},bind:e(function(t,n){var e;1<arguments.length&&("function"===c.type(n)||n.handleEvent)&&(e=n,(n="object"===c.type(arguments[2])?arguments[2]:{capture:!!arguments[2]}).callback=e);var r=c.listeners.get(this)||{};t.trim().split(/\s+/).forEach(function(t){var e;-1<t.indexOf(".")&&(e=(t=t.split("."))[1],t=t[0]),r[t]=r[t]||[],0===r[t].filter(function(t){return t.callback===n.callback&&t.capture==n.capture}).length&&r[t].push(c.extend({className:e},n)),c.original.addEventListener.call(this,t,n.callback,n);},this),c.listeners.set(this,r);},0),unbind:e(function(t,i){var e;i&&("function"===c.type(i)||i.handleEvent)&&(e=i,i=arguments[2]),(i=(i="boolean"==c.type(i)?{capture:i}:i)||{}).callback=i.callback||e;var s=c.listeners.get(this);(t||"").trim().split(/\s+/).forEach(function(t){var e,n;if(-1<t.indexOf(".")&&(e=(t=t.split("."))[1],t=t[0]),!s)return t&&i.callback?c.original.removeEventListener.call(this,t,i.callback,i.capture):void 0;for(n in s)if(!t||n===t)for(var r,o=0;r=s[n][o];o++)e&&e!==r.className||i.callback&&i.callback!==r.callback||!!i.capture!=!!r.capture&&(t||i.callback||void 0!==i.capture)||(s[n].splice(o,1),c.original.removeEventListener.call(this,n,r.callback,r.capture),o--);},this);},0),when:function(r,o){var t=this;return new Promise(function(n){t.addEventListener(r,function t(e){o&&!o.call(this,e)||(this.removeEventListener(r,t),n(e));});})},toggleAttribute:function(t,e,n){(n=arguments.length<3?null!==e:n)?this.setAttribute(t,e):this.removeAttribute(t);}},c.setProps={style:function(t){for(var e in t)e in this.style?this.style[e]=t[e]:this.style.setProperty(e,t[e]);},attributes:function(t){for(var e in t)this.setAttribute(e,t[e]);},properties:function(t){c.extend(this,t);},events:function(t){if(1!=arguments.length||!t||!t.addEventListener)return c.bind.apply(this,[this].concat(c.$(arguments)));var e,n=this;if(c.listeners){var r,o=c.listeners.get(t);for(r in o)o[r].forEach(function(t){c.bind(n,r,t.callback,t.capture);});}for(e in t)0===e.indexOf("on")&&(this[e]=t[e]);},once:e(function(t,e){function n(){return c.unbind(r,t,n),e.apply(r,arguments)}var r=this;c.bind(this,t,n,{once:!0});},0),delegate:e(function(t,e,n){c.bind(this,t,function(t){t.target.closest(e)&&n.call(this,t);});},0,2),contents:function(t){!t&&0!==t||(Array.isArray(t)?t:[t]).forEach(function(t){var e=c.type(t);/^(string|number)$/.test(e)?t=document.createTextNode(t+""):"object"===e&&(t=c.create(t)),t instanceof Node&&this.appendChild(t);},this);},inside:function(t){t&&t.appendChild(this);},before:function(t){t&&t.parentNode.insertBefore(this,t);},after:function(t){t&&t.parentNode.insertBefore(this,t.nextSibling);},start:function(t){t&&t.insertBefore(this,t.firstChild);},around:function(t){t&&t.parentNode&&c.before(this,t),this.appendChild(t);}},c.Array=function(t){this.subject=t;},c.Array.prototype={all:function(t){var e=c.$(arguments).slice(1);return this[t].apply(this,e)}},c.add=e(function(r,n,o,t){o=c.extend({$:!0,element:!0,array:!0},o),"function"==c.type(n)&&(!o.element||r in c.Element.prototype&&t||(c.Element.prototype[r]=function(){return this.subject&&c.defined(n.apply(this.subject,arguments),this.subject)}),!o.array||r in c.Array.prototype&&t||(c.Array.prototype[r]=function(){var e=arguments;return this.subject.map(function(t){return t&&c.defined(n.apply(t,e),t)})}),o.$&&(c.sources[r]=c[r]=n,(o.array||o.element)&&(c[r]=function(){var t=[].slice.apply(arguments),e=t.shift(),n=o.array&&Array.isArray(e)?"Array":"Element";return c[n].prototype[r].apply({subject:e},t)})));},0),c.add(c.Array.prototype,{element:!1}),c.add(c.Element.prototype),c.add(c.setProps),c.add(c.classProps,{element:!1,array:!1});var n=document.createElement("_");c.add(c.extend({},HTMLElement.prototype,function(t){return "function"===c.type(n[t])}),null,!0);}();
  // eslint-disable-next-line
  const $ = Bliss;
  // eslint-disable-next-line
  const $$ = Bliss.$;

  const GM_METHOD_MAP = {
    GM_addStyle: 'addStyle',
    GM_deleteValue: 'deleteValue',
    GM_getResourceURL: 'getResourceUrl',
    GM_getValue: 'getValue',
    GM_listValues: 'listValues',
    GM_notification: 'notification',
    GM_openInTab: 'openInTab',
    GM_registerMenuCommand: 'registerMenuCommand',
    GM_setClipboard: 'setClipboard',
    GM_setValue: 'setValue',
    GM_xmlhttpRequest: 'xmlHttpRequest',
    GM_getResourceText: 'getResourceText',
  }

  function getGM4PolyfilledMethod(methodName) {
    const gm4MethodName = GM_METHOD_MAP[methodName]

    if (GM !== undefined && gm4MethodName in GM) {
      return GM[gm4MethodName]
    } else if (methodName in window) {
      return function (...arguments_) {
        return new Promise((resolve, reject) => {
          try {
            // eslint-disable-next-line unicorn/no-null
            resolve(window[methodName].apply(null, arguments_))
          } catch (error) {
            reject(error)
          }
        })
      }
    }

    return async function () {
      throw new Error(`Method ${methodName} is not available. Missing @grant?`)
    }
  }

  const addStyle =
    'GM_addStyle' in window
      ? GM_addStyle // eslint-disable-line camelcase
      : (css) => {
          const head = document.querySelectorAll('head')[0]
          const style = document.createElement('style')

          style.innerHTML = css
          head.append(style)

          return style
        }

  const request = getGM4PolyfilledMethod('GM_xmlhttpRequest')

  const store = {
    get: getGM4PolyfilledMethod('GM_getValue'),

    set: getGM4PolyfilledMethod('GM_setValue'),

    async patch(name, value) {
      const oldValue = await store.get(name)

      store.set(name, {
        ...oldValue,
        ...value,
      })
    },
  }

  const registerMenuCommand = getGM4PolyfilledMethod('GM_registerMenuCommand')

  async function getURLFromPage(link, extractor) {
    const html = await getPageHtml(link.url)

    const match = extractor.imageURLRegExp?.exec(html)

    let url

    if (match) {
      url = match.groups ? match.groups.url : match[1]
    }

    if (!url) {
      console.warn(`[image-viewer] Unable to get URL from page ${link.url}`)
    }

    return url
  }

  async function getPageHtml(pageURL) {
    const response = await request({ url: pageURL })

    return response.responseText
  }

  const fastpic = {
    name: 'FastPic',
    linkRegExp: /^http.?:\/\/fastpic\.(?:ru|org)\/view/,
    imageURLRegExp: /src="(?<url>http[^"]+)" class="image img-fluid"/,
    getURL: getURLFromPage,
  }

  const URL_PARTS_REGEXP = /i(\d+).+\.(ru|org)\/big(\/\d+\/\d+\/).+\/([^/]+)$/

  const fastpicDirect = {
    name: 'FastPic (direct link)',
    linkRegExp: /fastpic\.(?:ru|org)\/big/,

    async getURL(link) {
      let hostLink = link.url

      if (hostLink.includes('?')) {
        const urlObject = new URL(hostLink)
        const parameters = new URLSearchParams(urlObject.search)

        for (const parameter of parameters.values()) {
          if (fastpicDirect.linkRegExp.test(parameter)) {
            hostLink = parameter
            break
          }
        }
      }

      const [, index, domain, date, filename] =
        URL_PARTS_REGEXP.exec(hostLink) || []

      const url = `https://fastpic.${domain}/view/${index}${date}${filename}.html`

      return fastpic.getURL({ ...link, url }, fastpic)
    },
  }

  const imagebam = {
    name: 'ImageBam',
    linkRegExp: /^http:\/\/www\.imagebam\.com\/image/,
    imageURLRegExp: /src="(?<url>[^"]+)".+class="main-image/,
    getURL: getURLFromPage,
  }

  const DATE_PATTERN = /(\d{4})\.(\d{2})\.(\d{2})/

  const imageban = {
    name: 'ImageBan.ru',
    linkRegExp: /\/\/imageban\.ru\/show/,

    async getURL(link) {
      return link.thumbnailURL
        .replace('thumbs', 'out')
        .replace(DATE_PATTERN, '$1/$2/$3')
    },
  }

  const imagebanDirect = {
    name: 'ImageBan.ru (direct link)',
    linkRegExp: /imageban\.ru\/out/,

    async getURL(link) {
      return link.url
    },
  }

  const imagetwist = {
    name: 'ImageTwist',
    linkRegExp: /imagetwist\.com/,
    hotLinkingDisabled: true,

    async getURL(link) {
      const imageName = link.url.split('/').pop()?.replace('.html', '')
      const extension = imageName?.split('.').pop()
      const imageUrl = link.thumbnailURL
        .replace('/th/', '/i/')
        .slice(0, -(extension?.length ?? 0))

      return `${imageUrl}${extension}/${imageName}`
    },
  }

  const HOST_REPLACE_REG_EXP = /(picturelol|picshick|imageshimage)/

  const imagetwistBased = {
    name: 'ImageTwist based (legacy)',
    hosts: ['Picturelol.com', 'PicShick.com', 'Imageshimage.com'],
    linkRegExp: /^https?:\/\/(picturelol|picshick|imageshimage)\.com/,

    async getURL(link) {
      const imageName = link.url.split('/').pop()
      const imageUrl = link.thumbnailURL
        .replace('/th/', '/i/')
        .replace(HOST_REPLACE_REG_EXP, 'imagetwist')

      return `${imageUrl}/${imageName}`
    },
  }

  const imagevenueLegacy = {
    name: 'ImageVenue.com',

    linkRegExp: /(imagevenue.com\/img.php|www.imagevenue.com\/\\w+$)/,
    imageURLRegExp: /data-toggle="full">\W*<img src="(?<url>[^"]*)/im,

    getURL: getURLFromPage,
  }

  const imgadult = {
    name: 'ImgAdult.com',
    linkRegExp: /^https:\/\/imgadult\.com/,

    async getURL(link) {
      return link.thumbnailURL.replace('/small/', '/big/')
    },
  }

  const imgbb = {
    name: 'imgbb.com',
    linkRegExp: /^https:\/\/ibb\.co/,

    async getURL(link) {
      return link.thumbnailURL.replace('//thumb', '//image')
    },
  }

  const imgbox = {
    name: 'imgbox.com',
    linkRegExp: /^https?:\/\/imgbox\.com/,

    async getURL(link) {
      return link.thumbnailURL.replace('/thumbs', '/images').replace('_t', '_o')
    },
  }

  const imgbum = {
    name: 'imgbum.net',
    linkRegExp: /^http:\/\/imgbum\.net/,

    async getURL(link) {
      return link.thumbnailURL.replace('-thumb', '')
    },
  }

  const imgchilibum = {
    name: 'imgchilibum.ru',
    linkRegExp: /^http:\/\/imgchilibum\.ru\/v/,

    async getURL(link) {
      return link.thumbnailURL.replace('_s/', '_b/')
    },
  }

  const imgdrive = {
    name: 'ImgDrive.net',
    linkRegExp: /^https:\/\/imgdrive\.net/,

    async getURL(link) {
      return link.thumbnailURL.replace('small', 'big')
    },
  }

  const imgtaxi = {
    name: 'imgtaxi.com',
    linkRegExp: /^https:\/\/imgtaxi\.com/,

    async getURL(link) {
      return link.thumbnailURL
        .replace('/small/', '/big/')
        .replace('/small-medium/', '/big/')
    },
  }

  const imx = {
    name: 'IMX.to',
    linkRegExp: /^https:\/\/imx\.to/,

    async getURL(link) {
      return link.thumbnailURL.replace('/imx', '/i.imx').replace('/u/t/', '/i/')
    },
  }

  const lostpic = {
    name: 'Lostpic.net',
    linkRegExp: /^http:\/\/lostpic\.net/,

    async getURL(link) {
      return link.thumbnailURL.replace('.th', '').replace('http:', 'https:')
    },
  }

  const moneyPic = {
    name: 'money-pic.ru',
    linkRegExp: /^http:\/\/money-pic\.ru/,

    async getURL(link) {
      return link.thumbnailURL.replace('-thumb', '')
    },
  }

  const nikapic = {
    name: 'nikapic.ru',
    linkRegExp: /^http:\/\/nikapic\.ru/,

    async getURL(link) {
      return link.thumbnailURL.replace('/small/', '/big/')
    },
  }

  const picage = {
    name: 'picage.ru',
    linkRegExp: /^http:\/\/picage\.ru/,

    async getURL(link) {
      return link.thumbnailURL
        .replace('picage', 'pic4you')
        .replace('-thumb', '')
    },
  }

  const piccash = {
    name: 'PicCash.net',
    linkRegExp: /^http:\/\/piccash\.net\//,

    async getURL(link) {
      return link.thumbnailURL.replace('_thumb', '_full').replace('-thumb', '')
    },
  }

  const picforall = {
    name: 'PicForAll.ru',
    hosts: [
      'freescreens.ru',
      'imgclick.ru',
      'picclick.ru',
      'payforpic.ru',
      'picforall.ru',
      'imgbase.ru',
    ],
    linkRegExp:
      /^http:\/\/(freescreens\.ru|imgclick\.ru|picclick\.ru|payforpic\.ru|picforall\.ru)/,

    async getURL(link) {
      return link.thumbnailURL.replace('-thumb', '')
    },
  }

  const HOST_REPLACE_REG_EX =
    /(iceimg\.net|pixsense\.net|vestimage\.site|chaosimg\.site)/

  const pixsense = {
    name: 'PixSense',
    hosts: [
      'www.iceimg.net',
      'www.pixsense.net',
      'www.vestimage.site',
      'www.chaosimg.site',
    ],
    linkRegExp:
      /^http:\/\/www\.(iceimg\.net|pixsense\.net|vestimage\.site|chaosimg\.site)/,

    async getURL(link) {
      return link.thumbnailURL
        .replace(HOST_REPLACE_REG_EX, 'fortstore.net')
        .replace('small-', '')
        .replace('/small/', '/big/')
    },
  }

  const radikal = {
    name: 'Radikal.ru',
    linkRegExp: /https?:\/\/.\.radikal\.ru\//,

    async getURL(link) {
      return link.url
    },
  }

  const radikalLegacy = {
    name: 'Radikal.ru (legacy)',
    linkRegExp: /^http:\/\/radikal\.ru\//,
    imageURLRegExp: /id="imgFullSize" src="(?<url>[^"]+)"/,
    getURL: getURLFromPage,
  }

  const stuffed = {
    name: 'stuffed.ru',
    linkRegExp: /^http:\/\/stuffed\.ru/,

    async getURL(link) {
      return link.thumbnailURL.replace('-thumb', '')
    },
  }

  const turboimagehost = {
    name: 'TurboImageHost',
    linkRegExp: /^https:\/\/www\.turboimagehost\.com\/p/,
    imageURLRegExp: /property="og:image" content="([^"]*)"/,
    getURL: getURLFromPage,
  }

  const vfl = {
    name: 'VFL.ru',
    linkRegExp: /^http:\/\/vfl\.ru/,

    async getURL(link) {
      return link.thumbnailURL.replace('_s', '')
    },
  }

  const xxxscreens = {
    name: 'XXXScreens.com',
    linkRegExp: /^http:\/\/xxxscreens\.com/,

    async getURL(link) {
      return link.thumbnailURL.replace('small/', 'big/')
    },
  }

  const hostExtractors = /* #__PURE__ */ Object.freeze({
    __proto__: null,
    fastpic,
    fastpicDirect,
    imagebam,
    imageban,
    imagebanDirect,
    imagetwist,
    imagetwistBased,
    imagevenueLegacy,
    imgadult,
    imgbb,
    imgbox,
    imgbum,
    imgchilibum,
    imgdrive,
    imgtaxi,
    imx,
    lostpic,
    moneyPic,
    nikapic,
    picage,
    piccash,
    picforall,
    pixsense,
    radikal,
    radikalLegacy,
    stuffed,
    turboimagehost,
    vfl,
    xxxscreens,
  })

  let extractorsActive = []

  const extractors = Object.values(hostExtractors).filter(Boolean)

  const extractorsByName = extractors.reduce((result, extractor) => {
    result[extractor.name] = extractor

    return result
  }, {})

  const urlExtractor = {
    getImageHostsMetadata() {
      const result = extractors.map(({ name, hosts }) => ({
        name,
        description: hosts ? hosts.join(', ') : '',
      }))

      return sortCaseInsensitive(result, ({ name }) => name)
    },

    getImageURL(link) {
      const extractor = extractorsByName[link.host]

      return extractor.getURL(link, extractor)
    },

    isHotLinkingDisabled(host) {
      return extractorsByName[host].hotLinkingDisabled ?? false
    },

    getHostNameMatcher(enabledHosts) {
      extractorsActive = extractors.filter((extractor) =>
        enabledHosts.includes(extractor.name)
      )

      let previousExtractor

      return (url) => {
        if (previousExtractor && previousExtractor.linkRegExp.test(url)) {
          return previousExtractor.name
        }

        const extractor = extractorsActive.find((extractor) =>
          extractor.linkRegExp.test(url)
        )

        if (extractor) {
          previousExtractor = extractor

          return extractor.name
        }
      }
    },
  }

  function sortCaseInsensitive(items, getValue) {
    return items
      .map((value, index) => ({ index, value: getValue(value).toLowerCase() }))
      .sort((a, b) => {
        if (a.value > b.value) {
          return 1
        }
        if (a.value < b.value) {
          return -1
        }

        return 0
      })
      .map((m) => items[m.index])
  }

  const CLASSES$1 = {
    open: 'iv-config-form--open',
  }

  let configMenu
  const currentHost = unsafeWindow.location.host

  async function initHostConfig() {
    const config = await getHostConfig()

    await registerMenuCommand('Settings', () => showMenu(config))

    return config
  }

  async function getHostConfig() {
    const hosts = urlExtractor.getImageHostsMetadata()

    const storedConfig = await store.get(currentHost, { hosts: {} })
    const enabledHosts = []

    for (const host of hosts) {
      const id = host.name
      const isEnabled = id in storedConfig.hosts ? storedConfig.hosts[id] : true

      host.isEnabled = isEnabled
      storedConfig.hosts[id] = isEnabled

      if (isEnabled) {
        enabledHosts.push(id)
      }
    }

    storedConfig.hosts = hosts.reduce((result, host) => {
      result[host.name] = host.isEnabled

      return result
    }, {})

    return {
      hosts,
      storedConfig,
      enabledHosts,
    }
  }

  function showMenu(config) {
    createMenuElement(config).classList.add(CLASSES$1.open)
  }

  function createMenuElement(config) {
    if (configMenu) {
      return configMenu
    }

    const rows = config.hosts.map((host) => createConfigMenuRow(host))

    configMenu = $.create('div', {
      id: 'iv-config-form',
      className: 'iv-config-form',
      contents: [
        createMenuHeader(),
        {
          tag: 'div',
          className: 'iv-config-form__options',
          contents: rows,
        },
      ],
      delegate: {
        change: {
          '.js-iv-config-checkbox': ({ target: { value, checked } }) =>
            updateHostConfig(config.storedConfig, value, checked),
        },
      },
    })

    document.body.append(configMenu)

    return configMenu
  }

  function createConfigMenuRow(host) {
    return $.create('label', {
      className: 'iv-config-form__label',
      title: host.description,
      contents: [
        {
          tag: 'input',
          type: 'checkbox',
          className: 'iv-config-form__checkbox js-iv-config-checkbox',
          checked: host.isEnabled,
          value: host.name,
        },
        host.name,
      ],
    })
  }

  function createMenuHeader() {
    const closeButton = $.create('a', {
      href: '#',
      title: 'Close',
      className: `iv-icon-button iv-icon-button--small iv-icon iv-icon--type-close`,
      events: {
        click: (event) => {
          event.preventDefault()
          configMenu.classList.remove(CLASSES$1.open)
        },
      },
    })

    return {
      tag: 'div',
      className: 'iv-config-form__header',
      contents: [
        {
          tag: 'span',
          className: 'iv-config-form__header-title',
          contents: `Settings for ${currentHost}`,
        },
        closeButton,
      ],
    }
  }

  function updateHostConfig(config, hostName, isEnabled) {
    config.hosts[hostName] = isEnabled
    store.set(currentHost, config)
  }

  const css_248z =
    "@keyframes spin{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}.iv-icon{position:relative}.iv-icon:after,.iv-image-link img:after{background-position:50%;background-repeat:no-repeat;background-size:contain;content:\"\";height:100%;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:100%;z-index:2}.iv-icon--hover:after{opacity:0;transition:opacity .35s ease}.iv-icon--hover:hover:after{opacity:1}.iv-icon--size-button:after{height:50px;width:50px}.iv-icon--type-loading:after{animation:spin 1s linear infinite;background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff'%3E%3Cpath d='M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Z'/%3E%3C/svg%3E\")!important;opacity:1}.iv-icon--type-zoom:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zm.5-7H9v2H7v1h2v2h1v-2h2V9h-2z'/%3E%3C/svg%3E\")}.iv-icon--type-next:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M10.02 6 8.61 7.41 13.19 12l-4.58 4.59L10.02 18l6-6-6-6z'/%3E%3C/svg%3E\")}.iv-icon--type-previous:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M15.61 7.41 14.2 6l-6 6 6 6 1.41-1.41L11.03 12l4.58-4.59z'/%3E%3C/svg%3E\")}.iv-icon--type-close:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z'/%3E%3C/svg%3E\")}.iv-icon--type-expand:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'/%3E%3C/svg%3E\")}.iv-icon--type-shrink:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' width='24' fill='%23fff'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z'/%3E%3C/svg%3E\")}.iv-icon--type-image-broken:after,.iv-image-link img:after{background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff'%3E%3Cpath d='M21 5v6.59l-3-3.01-4 4.01-4-4-4 4-3-3.01V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2m-3 6.42 3 3.01V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6.58l3 2.99 4-4 4 4'/%3E%3C/svg%3E\")}.iv-image-link{border:1px solid rgba(0,0,0,.2);box-shadow:1px 1px 3px rgba(0,0,0,.5);display:inline-flex;margin:3px;min-height:50px;min-width:50px;padding:4px;vertical-align:top}.iv-image-link img{margin:0}.iv-image-link>:not(img){align-items:center;display:flex;justify-content:center;width:100%}.iv-image-link:before{background-color:rgba(0,0,0,.5);bottom:4px;content:\"\";left:4px;opacity:0;position:absolute;right:4px;top:4px;transition:opacity .35s ease;z-index:1}.iv-image-link.iv-icon--type-loading:before,.iv-image-link:hover:before{opacity:1}.iv-image-link img:after,.iv-image-link img:before{content:\"\";position:absolute}.iv-image-link img:before{background-color:rgba(0,0,0,.2);height:100%;left:0;top:0;width:100%}.iv-image-link img:after{height:35px;width:35px;z-index:0}.iv-image-view{background-color:rgba(0,0,0,.8);color:#fff;display:none;flex-direction:column;height:0;opacity:0;transition:opacity .35s ease-out;user-select:none}.iv-image-view--open body,html.iv-image-view--open{overflow:hidden}.iv-image-view--open .iv-image-view{bottom:0;display:flex;height:auto;left:0;opacity:1;position:fixed;right:0;top:0;z-index:3}.iv-image-view--single .single-hide{visibility:hidden}.iv-image-view__footer,.iv-image-view__header{background-color:rgba(0,0,0,.8);display:flex}.iv-image-view__footer-wrapper,.iv-image-view__header-wrapper{z-index:2}.iv-image-view__header-wrapper{box-shadow:0 3px 7px rgba(0,0,0,.7)}.iv-image-view__footer-wrapper{box-shadow:0 -3px 7px rgba(0,0,0,.7)}.iv-image-view__header{justify-content:space-between}.iv-image-view__footer{justify-content:center}.iv-image-view__body{display:flex;height:100%;overflow:auto;position:relative}.iv-image-view__body::-webkit-scrollbar{width:20px}.iv-image-view__body::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,.8)}.iv-image-view__body::-webkit-scrollbar-track{background-color:hsla(0,0%,100%,.8)}.iv-thumbnail-wrapper{display:flex;height:100%;left:0;position:absolute;top:0;width:100%;z-index:0}.iv-image-view__number{align-items:center;display:flex;font-size:18px;padding:0 40px}.iv-image-view__backdrop{height:100%;left:0;position:fixed;top:0;width:100%;z-index:1}.iv-image,.iv-thumbnail{margin:auto;max-height:100%;max-width:100%;object-fit:contain}.iv-image{opacity:1;transition:opacity .35s ease-out;z-index:2}.iv-thumbnail{filter:blur(5px)}.iv-icon--type-error .iv-image,.iv-image-view__image--loading .iv-image,.iv-image-view__image--thumbnail .iv-image{opacity:0}.iv-image-view__image--thumbnail .iv-thumbnail-wrapper{z-index:2}.iv-image-view--full-height .iv-image,.iv-image-view--full-height .iv-thumbnail{cursor:grab;max-height:none}.iv-image-view--full-height .iv-image--grabbing{cursor:grabbing}.iv-icon-button{height:50px;transition:all .35s ease-out;width:50px}.iv-icon-button--small{height:25px;width:25px}.iv-icon-button+.iv-icon-button{margin-left:5px}.iv-icon-button:hover{background-color:hsla(0,0%,100%,.1)}.iv-icon-button--active,.iv-icon-button:active{background-color:hsla(0,0%,100%,.2)}.iv-config-form{background-color:rgba(0,0,0,.85);color:#fff;display:none;flex-direction:column;height:50%;left:10px;max-width:500px;padding:10px;top:10px;width:50%}.iv-config-form--open{display:flex;position:fixed;z-index:3}.iv-config-form__header{align-items:center;display:flex;padding:10px}.iv-config-form__header-title{flex-grow:1}.iv-config-form__options{display:flex;flex-flow:column wrap;flex-grow:1;overflow:auto}.iv-config-form__label{align-items:center;display:flex;flex:0 0 auto;margin:0;padding:10px;transition:all .35s ease-out}.iv-config-form__label:hover{background-color:hsla(0,0%,100%,.15)}.iv-config-form__checkbox{margin:0 5px 0 0!important}"

  const CLASSES = {
    imageLink: 'js-image-link',
    imageLinkZoom: 'iv-icon--type-zoom',
    imageLinkHover: 'iv-icon--hover',
    brokenImage: 'iv-icon--type-image-broken',
    loadingIcon: 'iv-icon--type-loading',
    loading: 'iv-image-view__image--loading',
    thumbnail: 'iv-image-view__image--thumbnail',
    open: 'iv-image-view--open',
    single: 'iv-image-view--single',
    fullHeight: 'iv-image-view--full-height',
    iconExpand: 'iv-icon--type-expand',
    iconShrink: 'iv-icon--type-shrink',
    grabbing: 'iv-image--grabbing',
    buttonActive: 'iv-icon-button--active',
  }

  const SELECTORS = {
    imageLink: `.${CLASSES.imageLink}`,
  }

  const EMPTY_SRC =
    'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI='

  const TRANSITION_DURATION = 350

  function initViewer(enabledHosts) {
    addStyle(css_248z)

    const container = $('body')

    const linkClasses = [
      CLASSES.imageLink,
      'iv-image-link',
      'iv-icon',
      'iv-icon--hover',
      CLASSES.imageLinkZoom,
      'iv-icon--size-button',
    ]

    const getHostName = urlExtractor.getHostNameMatcher(enabledHosts)

    const imagesWithLinks = $$('a > img, a > var', container)
      .map((img) => ({
        link: img.parentElement,
        thumbnailUrl: img.src || img.title,
      }))
      .filter(({ link }) => link.href)

    for (const { link, thumbnailUrl } of imagesWithLinks) {
      const hostName = getHostName(link.href)

      if (hostName) {
        link.dataset.ivHost = hostName
        link.dataset.ivThumbnail = thumbnailUrl
        link.classList.add(...linkClasses)
      }
    }

    $.delegate(container, 'click', SELECTORS.imageLink, events.linkClick)
  }

  const elements = {
    container: undefined,
    image: undefined,
    imageThumbnail: undefined,
    imageContainer: undefined,
    imageNumber: undefined,
    imageTotal: undefined,
    buttons: {
      next: undefined,
      previous: undefined,
      close: undefined,
      toggleFullHeight: undefined,
    },
  }

  const state = {
    isFirstClick: true,
    isOpened: false,
    currentLink: undefined,
    linksSet: [],
    isSingle: false,
    getCurrentLinkIndex() {
      return this.currentLink === undefined
        ? -1
        : this.linksSet.indexOf(this.currentLink)
    },
    getLastLinkIndex() {
      return this.linksSet.length - 1
    },
    dragPosition: 0,
    dragging: false,
  }

  const image = {
    async show(link) {
      const container = elements.container
      const img = elements.image
      const thumbnail = elements.imageThumbnail

      state.currentLink = link

      if (state.isSingle) {
        container.classList.add(CLASSES.single)
      } else {
        container.classList.remove(CLASSES.single)
        elements.imageNumber.textContent = (
          state.getCurrentLinkIndex() + 1
        ).toString()
      }

      if (!state.isOpened) {
        document.documentElement.classList.add(CLASSES.open)
        state.isOpened = true
      }

      img.src = EMPTY_SRC

      if (link.classList.contains(CLASSES.brokenImage)) {
        container.classList.add(CLASSES.brokenImage)

        return
      }

      container.classList.remove(CLASSES.brokenImage)

      container.classList.add(CLASSES.loading, CLASSES.loadingIcon)

      const isSizeKnown = !!link.dataset.ivWidth
      const thumbnailURL = link.dataset.ivThumbnail
      const imageHost = link.dataset.ivHost

      if (!thumbnailURL || !imageHost) {
        throw new Error(
          '[image-viewer] Either thumbnail URL or host is not set'
        )
      }

      if (isSizeKnown) {
        thumbnail.width = Number(link.dataset.ivWidth)
        thumbnail.src = thumbnailURL

        container.classList.add(CLASSES.thumbnail)
      }

      let imageURL = link.dataset.ivImgUrl

      if (!imageURL) {
        imageURL = await urlExtractor.getImageURL({
          url: link.href,
          thumbnailURL,
          host: imageHost,
        })

        if (!imageURL) {
          image.markAsBroken(link)

          return
        }

        link.dataset.ivImgUrl = imageURL
      }

      try {
        if (urlExtractor.isHotLinkingDisabled(imageHost)) {
          img.src = await image.loadAsBlob(imageURL)
        } else {
          await image.preload(
            imageURL,
            isSizeKnown ? undefined : image.setThumbnailSize
          )

          img.src = imageURL
        }

        container.classList.remove(
          CLASSES.thumbnail,
          CLASSES.loading,
          CLASSES.loadingIcon
        )

        setTimeout(image.hideThumbnail, TRANSITION_DURATION)
      } catch {
        link.classList.remove(CLASSES.imageLink)
        image.markAsBroken(link)

        $.attributes(link, { target: '_blank' })
      }
    },

    preload(url, onSizeGet) {
      return new Promise((resolve, reject) => {
        const imageObject = new Image()

        imageObject.addEventListener('load', () => resolve())
        imageObject.addEventListener('error', reject)

        imageObject.src = url

        if (onSizeGet) {
          image.getSize(imageObject).then(onSizeGet)
        }
      })
    },

    async loadAsBlob(url) {
      const response = await request({
        url,
        headers: { referer: url, origin: url },
        responseType: 'blob',
      })

      return window.URL.createObjectURL(response.response)
    },

    getSize(img) {
      return new Promise((resolve) => {
        const intervalId = setInterval(() => {
          if (img.naturalWidth) {
            clearInterval(intervalId)
            resolve({ width: img.naturalWidth, isLoaded: img.complete })
          }
        }, 10)
      })
    },

    setThumbnailSize({ width, isLoaded }) {
      if (!state.currentLink) {
        throw new Error('[image-viewer] currentLink is not set')
      }

      const thumbnailURL = state.currentLink.dataset.ivThumbnail

      if (!thumbnailURL) {
        throw new Error('[image-viewer] Thumbnail URL is not set')
      }

      elements.imageThumbnail.width = width
      elements.imageThumbnail.src = thumbnailURL

      if (!isLoaded) {
        elements.container.classList.add(CLASSES.thumbnail)
      }

      state.currentLink.dataset.ivWidth = width.toString()
    },

    hideThumbnail() {
      elements.imageThumbnail.removeAttribute('width')
      elements.imageThumbnail.src = EMPTY_SRC
    },

    hide() {
      document.documentElement.classList.remove(CLASSES.open)
      state.isOpened = false
      state.currentLink = undefined
      elements.image.src = EMPTY_SRC
      events.keyboard.unbind()
    },

    next() {
      const currentIndex = state.getCurrentLinkIndex()
      const newIndex =
        currentIndex < state.getLastLinkIndex() ? currentIndex + 1 : 0

      image.show(state.linksSet[newIndex])
    },

    previous() {
      const currentIndex = state.getCurrentLinkIndex()
      const newIndex =
        currentIndex === 0 ? state.getLastLinkIndex() : currentIndex - 1

      image.show(state.linksSet[newIndex])
    },

    toggleFullHeight() {
      elements.container.classList.toggle(CLASSES.fullHeight)
      elements.buttons.toggleFullHeight.classList.toggle(CLASSES.iconExpand)
      elements.buttons.toggleFullHeight.classList.toggle(CLASSES.iconShrink)
    },

    markAsBroken(link) {
      elements.container.classList.replace(
        CLASSES.loadingIcon,
        CLASSES.brokenImage
      )
      elements.container.classList.remove(CLASSES.loading)
      link.classList.replace(CLASSES.imageLinkZoom, CLASSES.brokenImage)
    },
  }

  const events = {
    linkClick(event) {
      event.preventDefault()

      if (state.isFirstClick) {
        create.viewContainer()
        state.isFirstClick = false
      }

      const link = event.target

      state.linksSet = $$(SELECTORS.imageLink, link.parentElement ?? undefined)
      state.isSingle = state.linksSet.length === 1

      if (!state.isSingle) {
        elements.imageTotal.textContent = state.linksSet.length.toString()
      }

      events.keyboard.bind()

      image.show(link)
    },

    keyboard: {
      bind() {
        document.addEventListener('keydown', events.keyboard.handler, true)
      },
      unbind() {
        document.removeEventListener('keydown', events.keyboard.handler, true)
      },

      handler(event) {
        if (event.defaultPrevented || event.repeat) {
          return
        }

        switch (event.key) {
          case 'ArrowRight':
            image.next()
            break

          case 'ArrowLeft':
            image.previous()
            break

          case 'Escape':
            image.hide()
            break

          case ' ':
            image.toggleFullHeight()
            break

          default:
            return
        }

        event.preventDefault()
      },
    },

    mouse(event) {
      switch (event.type) {
        case 'mousedown':
          state.dragging = true
          state.dragPosition = event.clientY
          elements.image.classList.add(CLASSES.grabbing)
          break

        case 'mousemove':
          if (state.dragging) {
            elements.imageContainer.scrollTop -=
              event.clientY - state.dragPosition
            state.dragPosition = event.clientY
          }
          break

        case 'mouseup':

        // fall through
        case 'mouseout':
          state.dragging = false
          elements.image.classList.remove(CLASSES.grabbing)
          break

        case 'dblclick':
          image.toggleFullHeight()
          break

        default:
          return
      }

      event.preventDefault()
    },
  }

  const create = {
    viewContainer() {
      elements.container = $.create('div', {
        className: 'iv-image-view iv-icon iv-icon--size-button',
        contents: [
          create.viewContainerHeader(),
          create.viewContainerBody(),
          create.viewContainerFooter(),
        ],
      })

      document.body.append(elements.container)
    },

    viewContainerBody() {
      elements.image = $.create('img', {
        className: 'iv-image',
        events: {
          'mousedown mouseup mousemove mouseout dblclick': events.mouse,
        },
      })

      elements.imageThumbnail = $.create('img', {
        className: 'iv-thumbnail',
      })

      elements.imageContainer = $.create('div', {
        className: 'iv-image-view__body',
        contents: [
          {
            tag: 'div',
            className: 'iv-image-view__backdrop',
            events: {
              click: image.hide,
            },
          },
          {
            tag: 'div',
            className: 'iv-thumbnail-wrapper',
            contents: elements.imageThumbnail,
          },
          elements.image,
        ],
      })

      return elements.imageContainer
    },

    viewContainerHeader() {
      elements.imageNumber = document.createElement('span')
      elements.imageTotal = document.createElement('span')
      const imageNumber = $.create('div', {
        className: 'iv-image-view__number single-hide',
        contents: [elements.imageNumber, '/', elements.imageTotal],
      })

      elements.buttons.close = create.toolbarButton(
        'Close (Esc)',
        'close',
        image.hide
      )

      return {
        tag: 'div',
        className: 'iv-image-view__header-wrapper',
        contents: {
          tag: 'div',
          className: 'iv-image-view__header',
          contents: [imageNumber, elements.buttons.close],
        },
      }
    },

    viewContainerFooter() {
      const buttons = elements.buttons

      buttons.previous = create.toolbarButton(
        'Previous (←)',
        'previous',
        image.previous,
        'single-hide'
      )
      buttons.toggleFullHeight = create.toolbarButton(
        'Toggle full height (Space)',
        'expand',
        image.toggleFullHeight
      )
      buttons.next = create.toolbarButton(
        'Next (→)',
        'next',
        image.next,
        'single-hide'
      )

      return {
        tag: 'div',
        className: 'iv-image-view__footer-wrapper',
        contents: {
          tag: 'div',
          className: 'iv-image-view__footer',
          contents: [buttons.previous, buttons.toggleFullHeight, buttons.next],
        },
      }
    },

    toolbarButton(title, icon, handler, className = '') {
      return $.create('a', {
        href: '#',
        title,
        className: `iv-icon-button iv-icon iv-icon--type-${icon} ${className}`,
        events: {
          click: (event) => {
            event.preventDefault()
            handler()
          },
        },
      })
    },
  }

  $.ready().then(async () => {
    const hostConfig = await initHostConfig()

    initViewer(hostConfig.enabledHosts)
  })
})()