// ==UserScript== // @name ppixiv for Pixiv // @author ppixiv // @description Better Pixiv viewing | Fullscreen images | Faster searching | Bigger thumbnails | Download ugoira MKV | Ugoira seek bar | Download manga ZIP | One-click like, bookmark, follow | One-click zoom and pan | Light and dark themes // @include http://*.pixiv.net/* // @include https://*.pixiv.net/* // @run-at document-start // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect pixiv.net // @connect i.pximg.net // @connect self // @namespace ppixiv // @version 116 // @downloadURL none // ==/UserScript== (function() { const ppixiv = this; with(this) { ppixiv.resources = {}; ppixiv.resources["resources/activate-icon.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAsCAYAAAAacYo8AAAACXBIWXMAAC4jAAAuIwF4pT92AAAGU2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yN1QwMjoyMjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6N2ZkYzUwY2ItYjgzMy1hNzQzLTllMjYtNzQ1NmM4NDFlNjM0IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MzMyMzRmNjktNjk2OS1jNjQ1LWI0MjgtYmM1NDUwYTM3NDAzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDozMzIzNGY2OS02OTY5LWM2NDUtYjQyOC1iYzU0NTBhMzc0MDMiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjdUMDI6MjI6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTI3VDAyOjI2OjIwLTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PmQ/KUAAAAQhSURBVFiF7ZlNTxtHGMd/s/aaGGqDHRRLwQergko+lJhW4pJDqdQLpzSfoOHqU/sJGj5By8VXkk8QcuqlUtxjckBWLxxaqVYFVQnCdWiDYy/e6WG8Zr07a68X8xIpfwmtZp6Z2d8+fuaZF4SUkvdRxnUDRNV7Cx4HEEJMbsRKpwTMAc7TUb3316ScqEUd3gltIaW8GHil8zXwBbCGgg2rKvAc2KGcqIftdDHwSmcN+AZ4NF7HQO0AW5QT1VENo4FXOo+A74GCzizaDWg3EN1TOGsNGuNJZOI2cioL8eSwD9ignGhOBnwIsGgdIt4dIlqvwbaGj+O8fCqLTC+pj/Cr2YPfiQ6uJtoPqPj1ARtv9vyeHUMymcPOfAqGqTNvUE488fUZCV7pPEZ52SfRbmAcvYwMPKB4Evv2Z0gzrbP64IPBK5054BkaLzsKAy6TOWR60Qek7WuYdO/cD4r9FXf6dMDjA01UaLxgMP82PeXhwDML2JnlfvnjuzESpqBjSQ6ObNpk6ebXVagd76pGtoVxvIudu68bchtY8Vaer5xqAnqha8BGWOhufh07s0wmZbBaNFktmszPGqSnBfOzBvcW46wWVTzLZA45s9DvK6wTxH913bClHpsGXBm2PdA7lBMrKI8PB777Fd38er+8lI8Nbe/A25nlAS8bJ78HdfHNNcfj2576GiE93c2vg2EiWodhmvf1+Sc9z7vngG0h3h7omhd6K3RfDvimq64JPBy2CPjUi9FxFHP9KO45Id4FOuCBu+CA/4jaAAFUx9k7GEcvEda/YZtrNRDr7UZQs9LAewF63nVCY26cl040p4NaffUrcMldOM8qaoOzCTydHEVIeVbfML/gYB4vJx5PkiesjDd74RqqdaYG13gCerWnwkG0G+EzkmsFvRZwBxrb0s4PaaZGjhEf2SKCXu1ZTN8SLMwbZFLnvvnzsMvfDbtfjv31s7+zYQbtFuvuwqWAG//8yulskd/2TaDrtx/vBoaHTN4JGrbqLlwKuHh7QEy/Ao6UnM4HmZ67CzfqekJOZYNORXXviehSwN0brnEk54pBpk1vxURDJbb/U+S+dmY56BRU1R3hbkSoyI8KA/sVl5rAQ53hUiZnaBkm9mxxGPSXQbvUSODSTGGnlzBO9yOf8uXMAnZqKeicWUdtrWtB/aN53DCR6UW66UWEdQKt1+p51lLPINiprDqy3cpd6FIoOrgbxkyDmcZ7yy6sE7DPem1SQauhW3Xgu6CLIK+igteALdSpZA3NHj4gQ+i0AzwNC+woKnizl6KeAM4laAm4h7qmK6E/kDRRH10DfkGlumYUgMlkFXUIqU5krJC6EXk8ij6AX7WGg6sL0AcaS6E3Ia9Nozz+B/Ctpr4AvKDS0dmuRKOyytYIe21CHGNLfPjP8hXrf5SZd4NRInfBAAAAAElFTkSuQmCC`; ppixiv.resources["resources/checkbox.svg"] = ` `; ppixiv.resources["resources/close-button.svg"] = ` `; ppixiv.resources["resources/disabled.html"] = `
`; ppixiv.resources["resources/download-icon.svg"] = ` `; ppixiv.resources["resources/download-manga-icon.svg"] = ` `; ppixiv.resources["resources/edit-icon.svg"] = ``; ppixiv.resources["resources/exit-icon.svg"] = ` `; ppixiv.resources["resources/eye-icon.svg"] = ` image/svg+xml `; ppixiv.resources["resources/favorited-icon.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGbWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTFkYzgwNjgtMDM1Ny0xNzRlLTlmMDAtNThjYjk4NTQ4OWQ2IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6NzdlYmJlZGEtYjQ4Yy0yYzRkLTk2MTQtYmM3NmZmN2VjYTU5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0Ij4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLimIUiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKYhSIvPiA8L3JkZjpCYWc+IDwvcGhvdG9zaG9wOlRleHRMYXllcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0IiBzdEV2dDp3aGVuPSIyMDE4LTA2LTIwVDAwOjU2OjI5LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMWRjODA2OC0wMzU3LTE3NGUtOWYwMC01OGNiOTg1NDg5ZDYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+LpZUDAAABwxJREFUeJzlm29sG2cdxz93tnOOHedP4yZuq7QpibsSBeKWsVWZyqAQaWKTJmBVEjQoSBOVKAiYqkkVlYbGEO94AWKiiGkvOmnrRMUfsSpNUatVtEQg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`; ppixiv.resources["resources/followed-users-eye.svg"] = ` image/svg+xml `; ppixiv.resources["resources/fullscreen.svg"] = ` `; ppixiv.resources["resources/heart-icon.svg"] = ` `; ppixiv.resources["resources/icon-bookmarks.svg"] = ` `; ppixiv.resources["resources/icon-circlems.svg"] = ` `; ppixiv.resources["resources/icon-pawoo.svg"] = ` `; ppixiv.resources["resources/icon-search.svg"] = ` `; ppixiv.resources["resources/icon-twitter.svg"] = ` `; ppixiv.resources["resources/icon-webpage.svg"] = ` `; ppixiv.resources["resources/last-page.svg"] = ` `; ppixiv.resources["resources/last-viewed-image-marker.svg"] = ` `; ppixiv.resources["resources/like-button.svg"] = ` `; ppixiv.resources["resources/link-icon.svg"] = ` `; ppixiv.resources["resources/main.html"] = `
`; ppixiv.resources["resources/main.scss"] = `/* line 1, resources/main.scss */ * { box-sizing: border-box; } /* line 2, resources/main.scss */ html { overflow: hidden; } /* line 5, resources/main.scss */ body { font-family: "Helvetica Neue", arial, sans-serif; } /* line 9, resources/main.scss */ a { text-decoration: none; /*color: #fff;*/ color: inherit; } /* Theme colors: */ /* line 16, resources/main.scss */ body { --button-color: #888; --button-highlight-color: #eee; /* Colors for major UI boxes */ --ui-bg-color: #222; --ui-fg-color: #fff; --ui-border-color: #000; --ui-shadow-color: #000; /* the shadow around some major UI elements */ --ui-bg-section-color: #555; /* color for sections within UI, like the description box */ --toggle-button-fg-disabled-color: #666; --toggle-button-fg-dim-color: #888; --toggle-button-fg-color: #fff; --toggle-button-bg-dim-color: #222; --toggle-button-bg-color: #444; /* Color for frames like popup menus */ --frame-bg-color: #000; --frame-fg-color: #fff; --frame-border-color: #444; --dropdown-menu-hover-color: #444; /* Box links used for selection in the search UI: */ --box-link-fg-color: var(--frame-fg-color); --box-link-bg-color: var(--frame-bg-color); --box-link-disabled-color: #888; --box-link-hover-color: #443; --box-link-selected-color: #008; /* Color for the minor text style, eg. the bookmark and like counts. * This is smaller text, with a text border applied to make it readable. */ --minor-text-fg-color: #aaa; --minor-text-shadow-color: #000; --title-fg-color: #fff; /* title strip in image-ui */ --title-bg-color: #444; --like-button-color: #888; --like-button-liked-color: #ccc; --like-button-hover-color: #fff; } /* line 60, resources/main.scss */ body.light { --ui-bg-color: #eee; --ui-fg-color: #222; --ui-border-color: #ccc; --ui-shadow-color: #fff; --ui-bg-section-color: #ccc; /* color for subsections */ --button-color: #666; --button-highlight-color: #222; --toggle-button-fg-dim-color: #222; --toggle-button-fg-color: #000; --toggle-button-bg-dim-color: #eee; --toggle-button-bg-color: #ccc; --frame-bg-color: #fff; --frame-fg-color: #222; --dropdown-menu-hover-color: #ccc; --box-link-hover-color: #ddc; --box-link-selected-color: #ffc; --minor-text-fg-color: #555; /* 555 */ --minor-text-shadow-color: #fff; /* fff */ --title-fg-color: #fff; --title-bg-color: #888; --like-button-liked-color: #222; --like-button-hover-color: #000; } /* line 92, resources/main.scss */ ul { padding: 0; margin: 0; } /* line 96, resources/main.scss */ .screen:focus { /* Views have tabindex: -1 set. This causes Chrome to put a blue outline around them * when they're focused, which just puts a weird border around the whole window. Remove * it. */ outline: none; } /* line 102, resources/main.scss */ .screen-illust-container { width: 100%; height: 100%; } /* line 107, resources/main.scss */ .image-container, .preview-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none; cursor: pointer; } /* Let the browser know about our dynamic zooming and panning. This prevents Chrome from baking the * resize when it doesn't change for a while, which causes a big hitch the next time we zoom. */ /* line 119, resources/main.scss */ .image-container > img { will-change: transform; } /* line 127, resources/main.scss */ [hidden] { display: none !important; } /* line 131, resources/main.scss */ textarea:focus, input:focus, a:focus { outline: none; } /* Pixiv sometimes displays a random Recaptcha icon in the corner. It's hard to prevent this since it * sometimes loads before we have a chance to stop it. Try to hide it. */ /* line 137, resources/main.scss */ .grecaptcha-badge { display: none !important; } /* line 141, resources/main.scss */ .main-container { position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; overflow: hidden; } /* line 149, resources/main.scss */ .progress-bar { position: absolute; pointer-events: none; background-color: #F00; bottom: 0px; left: 0px; width: 100%; height: 2px; } @keyframes flash-progress-bar { to { opacity: 0; } } /* line 159, resources/main.scss */ .progress-bar.hide { animation: flash-progress-bar 500ms linear 1 forwards; } /* line 163, resources/main.scss */ .loading-progress-bar .progress-bar { z-index: 100; } /* .seek-bar is the outer seek bar area, which is what can be dragged. */ /* line 168, resources/main.scss */ .seek-bar { position: absolute; bottom: 0px; left: 0px; width: 100%; box-sizing: content-box; height: 12px; padding-top: 25px; cursor: pointer; /* Hide the seek bar by default (move down by 12px). Show it when the mouse is visible * or .visible is set (move down by 6px). Expand it while dragging (0px). */ } /* line 180, resources/main.scss */ .seek-bar .seek-empty { height: 100%; background-color: rgba(0, 0, 0, 0.25); } /* line 185, resources/main.scss */ .seek-bar .seek-fill { background-color: #F00; height: 100%; } /* line 190, resources/main.scss */ .seek-bar .seek-empty { transition: transform .25s; transform: translate(0, 12px); } /* line 197, resources/main.scss */ .mouse-hidden-box.show-cursor .seek-bar .seek-empty, .seek-bar.visible .seek-empty { transform: translate(0, 6px); } /* line 203, resources/main.scss */ .seek-bar.dragging .seek-empty { transform: translate(0, 0); } /* line 208, resources/main.scss */ .title-font { font-weight: 700; font-size: 20px; font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Droid Sans, Helvetica Neue, Hiragino Kaku Gothic ProN, Meiryo, sans-serif; } /* line 215, resources/main.scss */ .small-font { font-size: 0.8em; } /* line 219, resources/main.scss */ .hover-message, .search-results > .no-results { width: 100%; position: absolute; bottom: 0px; display: flex; justify-content: center; } /* line 227, resources/main.scss */ .hover-message > .message, .search-results > .no-results > .message { background-color: var(--frame-bg-color); color: var(--frame-fg-color); font-size: 1.4em; padding: 6px 15px; margin: 4px; max-width: 600px; text-align: center; border-radius: 5px; box-shadow: 0 0 10px 5px #aaa; } /* line 240, resources/main.scss */ .hover-message { transition: opacity .25s; opacity: 0; pointer-events: none; z-index: 100; } /* line 246, resources/main.scss */ .hover-message.show { opacity: 1; } /* The version in the search container is always centered. */ /* line 252, resources/main.scss */ .search-results > .no-results { bottom: 50%; } /* line 257, resources/main.scss */ .screen-illust-container .ui { position: absolute; top: 0px; left: 0px; min-width: 450px; max-height: 500px; width: 30%; height: auto; /* Disable events on the top-level container, so it doesn't block clicks on the * image when the UI isn't visible. We'll reenable events on the hover-box and ui-box * below it where we actually want pointer events. */ pointer-events: none; } /* line 271, resources/main.scss */ .screen-illust-container .ui .disabled { display: none; } /* * This is the box that triggers the UI to be displayed. We use this rather than * ui-box for this so we can give it a fixed size. That way, the UI box won't suddenly * appear when changing to another image because a longer description caused the box * to become bigger. * * This is a little tricky. Hovering over either hover-box or the UI makes it visible. * When the UI is hidden, it's set to pointer-events: none, so it can't be hovered, * but once you hover over hover-box and cause the UI to be visible, pointer events * are reenabled so hovering over anywhere in the UI keeps it visible. The UI is * over hover-box in the Z order, so we don't need to disable pointer events on hover-box * to prevent it from blocking the UI. * * We also disable pointer-events on the UI until it's visible, so it doesn't receive * clicks until it's visible. */ /* line 293, resources/main.scss */ .hover-box { width: 400px; height: 200px; position: absolute; top: 0; left: 0; pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } /* line 302, resources/main.scss */ .hover-sphere { width: 500px; height: 500px; /* Clamp the sphere to a percentage of the viewport width, so it gets smaller for * small windows. */ max-width: 30vw; max-height: 30vw; position: absolute; top: 0; left: 0; } /* line 314, resources/main.scss */ .hover-sphere circle { pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } /* line 318, resources/main.scss */ .hover-sphere > svg { width: 100%; height: 100%; transform: translate(-50%, -50%); } /* line 325, resources/main.scss */ .screen-manga-container .ui-container { width: 600px; max-width: 90%; pointer-events: auto; } /* line 331, resources/main.scss */ .ui-box { background-color: var(--ui-bg-color); color: var(--ui-fg-color); border: solid 2px var(--ui-border-color); padding: 1em; border-radius: 8px; position: relative; /* Since the UI isn't a popup on the manga page, hide the description and * tag list to make it smaller. These can be viewed while viewing a page. */ } /* line 340, resources/main.scss */ .ui-box .author { vertical-align: top; } /* line 345, resources/main.scss */ .ui-box:not(.visible-widget) { display: inherit !important; } /* line 350, resources/main.scss */ .screen-illust-container .ui-box { transition: transform .25s, opacity .25s; opacity: 0; transform: translate(-50px, 0); pointer-events: none; margin: .5em; /* Show the UI on hover when hide-ui isn't set. */ /* Debugging: */ } /* line 359, resources/main.scss */ body:not(.hide-ui) .screen-illust-container .ui-box.visible-widget { opacity: 1; transform: translate(0, 0); pointer-events: auto; } /* line 368, resources/main.scss */ body.force-ui .screen-illust-container .ui-box { opacity: 1; transform: translate(0, 0); pointer-events: inherit; } /* line 379, resources/main.scss */ .screen-manga-container .ui-box > .description, .screen-manga-container .ui-box > .tag-list { display: none; } /* line 386, resources/main.scss */ .ui-box .button > svg { display: block; } /* line 390, resources/main.scss */ .ui-box .button.button-bookmark .count, .ui-box .button.button-like .count { top: calc(100% - 11px); left: calc(-50px + 50%); width: 100px; pointer-events: none; } /* line 400, resources/main.scss */ .button-row { display: flex; flex-direction: row; align-items: center; height: 32px; margin-top: 5px; margin-bottom: 4px; } /* line 408, resources/main.scss */ .button-row .button.enabled { cursor: pointer; } /* The row with the search title, with buttons aligned to the right. The buttons * are always aligned to the top if the title is long. */ /* line 415, resources/main.scss */ .title-with-button-row { display: flex; flex-direction: row; align-items: start; } /* An icon in a button strip. */ /* line 422, resources/main.scss */ .icon-button { display: block; width: 32px; height: auto; /* If this is an icon-button with an svg inside, set the svg to block. */ } /* line 428, resources/main.scss */ .icon-button svg { display: block; } /* line 433, resources/main.scss */ .disable-ui-button:hover > .icon-button { color: #0096FA; } /* line 436, resources/main.scss */ .whats-new-button.updates > svg { color: #cc0; } /* line 439, resources/main.scss */ body.light .whats-new-button.updates > svg { color: #0aa; /* yellow doesn't work in a light theme */ } /* line 443, resources/main.scss */ .navigate-out-button { cursor: pointer; } /* line 447, resources/main.scss */ .menu-slider input { vertical-align: middle; width: 100%; padding: 0; margin: 0; } /* line 454, resources/main.scss */ .popup.avatar-popup:hover:after { left: auto; bottom: auto; top: 60px; right: -10px; } /* line 461, resources/main.scss */ .follow-container { /* For the avatar in the popup menu, use the same size as the other popup menu buttons. */ } /* line 462, resources/main.scss */ .follow-container .avatar { transition: filter .25s; display: block; position: relative; /* .avatar contains an image, and a canvas overlaid on top for hover effects. */ } /* line 468, resources/main.scss */ .follow-container .avatar > canvas { border-radius: 5px; object-fit: cover; width: 100%; height: 100%; position: absolute; top: 0; left: 0; } /* line 478, resources/main.scss */ .follow-container .avatar > canvas.highlight { opacity: 0; transition: opacity .25s; } /* line 483, resources/main.scss */ .follow-container .avatar:hover > canvas.highlight { opacity: 1; } /* line 488, resources/main.scss */ .follow-container:not(.big) .avatar { width: 50px; height: 50px; } /* line 493, resources/main.scss */ .follow-container.big .avatar { width: 170px; height: 170px; } /* line 499, resources/main.scss */ .avatar-widget-container .follow-container .avatar { width: 44px; height: 44px; } /* Hide the avatar while we're waiting for user data to load, since the follow icons aren't * updated until then. */ /* line 507, resources/main.scss */ .follow-container.loading { visibility: hidden; pointer-events: none; } /* The API doesn't tell us whether a follow is private or not, so we can't show * it. The lock is only used to distinguish the "follow" and "follow privately" * buttons. */ /* line 516, resources/main.scss */ .follow-icon .lock { stroke: #888; } /* line 520, resources/main.scss */ .follow-icon:not(.private) .lock { display: none !important; } /* line 525, resources/main.scss */ .follow-container { /* Hide the following icon if we're not following. */ /* Hide the follow buttons if we're already following. */ /* Only show the follow buttons on hover (but always show the following icon). */ /* If use-dropdown is set, this avatar is using the dropdown UI and doesn't show the * follow/unfollow overlay buttons. */ /* Don't show follow buttons or the follow popup for the user himself. */ /* In small avatar buttons, nudge the follow buttons down off of the * avatar, so they don't appear right under the cursor when you're trying * to click the avatar itself. Only do this with the follow buttons that * appears on hover, not the following icon (unfollow button), and don't * do it with the big avatars. */ /* Don't fade the icons in the context menu, since it's too small and it makes * it too hard to see at a glance. */ } /* line 526, resources/main.scss */ .follow-container .follow-icon { position: absolute; bottom: 0; text-align: center; height: auto; width: 50%; /* half the size of the container */ max-width: 50px; /* limit the size for larger avatar displays */ } /* line 534, resources/main.scss */ .follow-container .follow-icon.bottom-left { left: 0; } /* line 538, resources/main.scss */ .follow-container .follow-icon.bottom-right { right: 0; } /* line 543, resources/main.scss */ .follow-container .follow-icon:not(:hover) .outline1 { stroke: none !important; } /* line 548, resources/main.scss */ .follow-container:not(.followed) .follow-icon.following-icon { display: none; } /* line 553, resources/main.scss */ .follow-container.followed .follow-icon.follow-button { display: none; } /* line 558, resources/main.scss */ .follow-container:not(:hover) .follow-icon.follow-button { display: none; } /* line 564, resources/main.scss */ .follow-container[data-mode="dropdown"] .follow-icon.follow-button { display: none; } /* line 569, resources/main.scss */ .follow-container.self .follow-icon, .follow-container.self .follow-popup { display: none; } /* line 580, resources/main.scss */ .follow-container:not(.big) .follow-button { top: calc(100% - 5px); } /* line 584, resources/main.scss */ .follow-container .follow-icon > svg { display: block; width: 100%; height: auto; transition: opacity .25s; /* Move the icon down, so the bottom of the eye is along the bottom of the * container and the lock (if visible) overlaps. */ margin-bottom: -20%; } /* line 595, resources/main.scss */ .follow-container:not(:hover) .follow-icon > svg { opacity: 0.5; } /* line 601, resources/main.scss */ .popup-context-menu .follow-container .follow-icon > svg { opacity: 1; } /* line 605, resources/main.scss */ .follow-container .follow-icon > svg .middle { transition: transform .1s ease-in-out; transform: translate(0px, -2px); } /* line 610, resources/main.scss */ .follow-container .follow-icon.unfollow-button > svg .middle { transform: translate(-2px, -5px); } /* line 614, resources/main.scss */ .follow-container .follow-icon.unfollow-button:hover > svg .middle { transform: translate(2px, 5px); } /* line 618, resources/main.scss */ .follow-container .follow-popup { margin-top: 10px; right: 0px; } /* line 622, resources/main.scss */ .follow-container .follow-popup .folder { display: block; width: 100%; } /* line 627, resources/main.scss */ .follow-container.followed .follow-container .follow-popup .not-following { display: none; } /* line 628, resources/main.scss */ .follow-container:not(.followed) .follow-container .follow-popup .following { display: none; } /* line 630, resources/main.scss */ .follow-container .follow-popup input { padding: .25em; } /* line 635, resources/main.scss */ .follow-container .hover-area { top: -12px; } /* line 639, resources/main.scss */ .follow-container .avatar-link { display: block; } /* Hide the follow dropdown when following, since there's nothing in it. */ /* line 645, resources/main.scss */ .follow-container.followed.popup-visible .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 649, resources/main.scss */ .title-block { display: inline-block; padding: 0 10px; color: var(--title-fg-color); background-color: var(--title-bg-color); margin-right: 1em; border-radius: 8px 0; } /* line 657, resources/main.scss */ .title-block.popup:hover:after { top: 40px; bottom: auto; } /* When .dot is set, show images with nearest neighbor filtering. */ /* line 664, resources/main.scss */ body.dot img.filtering, body.dot canvas.filtering { image-rendering: crisp-edges; image-rendering: pixelated; } /* line 669, resources/main.scss */ .bulb-button:hover > .icon-button { color: #FF0 !important; /* override grey-icon hover color */ } /* line 673, resources/main.scss */ body.light .bulb-button:hover > .icon-button { stroke: #000; } /* line 677, resources/main.scss */ .bulb-button > .icon-button { margin-top: -3px; } /* line 682, resources/main.scss */ .extra-profile-link-button .default-icon svg { transform: translate(0, 2px); } /* line 687, resources/main.scss */ .post-info > * { display: inline-block; background-color: var(--box-link-bg-color); color: var(--box-link-fg-color); padding: 2px 10px; /* Use a smaller, heavier font to distinguish these from tags. */ font-size: .8em; font-weight: bold; } /* line 697, resources/main.scss */ .description { border: solid 1px var(--ui-border-color); padding: .35em; background-color: var(--ui-bg-section-color); max-height: 10em; overflow-y: auto; } /* line 704, resources/main.scss */ body.light .description { border: none; } /* Override obnoxious colors in descriptions. Why would you allow this? */ /* line 708, resources/main.scss */ .description * { color: var(--ui-fg-color); } /* line 712, resources/main.scss */ .popup { position: relative; } /* line 716, resources/main.scss */ .popup:hover:after { pointer-events: none; background: #111; border-radius: .5em; left: 0em; top: -2.0em; color: #fff; content: attr(data-popup); display: block; padding: .3em 1em; position: absolute; text-shadow: 0 1px 0 #000; white-space: nowrap; z-index: 98; } /* line 731, resources/main.scss */ .popup-bottom:hover:after { top: auto; bottom: -2em; } /* line 736, resources/main.scss */ body:not(.premium) .premium-only { display: none; } /* line 737, resources/main.scss */ body.hide-r18 .r18 { display: none; } /* line 738, resources/main.scss */ body.hide-r18g .r18g { display: none; } /* line 740, resources/main.scss */ .popup-menu-box { position: absolute; left: 0; top: 100%; min-width: 10em; background-color: var(--frame-bg-color); border: 1px solid var(--frame-border-color); padding: .25em .5em; z-index: 2; } /* line 751, resources/main.scss */ .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 754, resources/main.scss */ .popup-visible .popup-menu-box.hover-menu-box { visibility: inherit; } /* This is an invisible block underneath the hover zone to keep the hover UI visible. */ /* line 759, resources/main.scss */ .hover-area { position: absolute; top: -50%; left: -33%; width: 150%; height: 200%; z-index: -1; } /* line 768, resources/main.scss */ .popup-menu-box .button { padding: .25em; cursor: pointer; width: 100%; } /* line 775, resources/main.scss */ .popup-menu-box .button:hover { background-color: var(--dropdown-menu-hover-color); } /* line 779, resources/main.scss */ .top-ui-box { /* This places the thumbnail UI at the top, so the thumbnails sit below it when * scrolled all the way up, and scroll underneath it. */ position: sticky; top: 0; width: 100%; display: flex; flex-direction: row; align-items: center; padding-top: 1em; padding-bottom: .5em; z-index: 1; /* Prevent the empty space around the UI for centering from eating button presses. */ pointer-events: none; /* If .ui-on-hover is set, switch to showing the top UI when it's hovered instead of sticky. */ /* This is used to temporarily disable the transition when the ui-on-hover setting is * changed in the options menu. */ } /* line 797, resources/main.scss */ body.ui-on-hover .top-ui-box { position: fixed; top: auto; bottom: 100%; transition: transform ease-out .2s; /* Normally pointer-events is disabled above, so the sides of the UI box don't cover clicks. * However, that also makes the hover not include the top padding above the UI, causing it * to flicker on and off when the mouse is in that area. This is tricky to fix nicely, so just * stop disabling pointer-events when ui-on-hover is enabled. */ pointer-events: auto; } /* line 813, resources/main.scss */ body.ui-on-hover .top-ui-box.disable-transition { transition: none; } /* .force-open is set to lock the UI in place when a menu is open. It has the same * effect as a hover. */ /* line 821, resources/main.scss */ body.ui-on-hover .top-ui-box.hover, body.ui-on-hover .top-ui-box.force-open { transform: translateY(100%); } /* line 827, resources/main.scss */ body.ui-on-hover .top-ui-box:not(.hover):not(.force-open) { /* This is the amount the UI pokes on-screen when not hovered. */ transform: translateY(40px); } /* When ui-on-hover is disabled we get spacing at the top of the thumbs automatically from * position: sticky, but ui-on-hover is position: fixed and we don't get that, so we have * to add padding manually. */ /* line 836, resources/main.scss */ body.ui-on-hover .top-ui-box + .top-ui-box-padding { height: 30px; } /* Search result views. This is used for both the search view and the manga page list. */ /* line 842, resources/main.scss */ .search-results { position: absolute; width: 100%; height: 100%; top: 0; left: 0; overflow-x: hidden; /* Always show the vertical scrollbar, so we don't relayout as images load. */ overflow-y: scroll; color: #fff; /* .thumbnails is the actual thumbnail list. */ } /* line 853, resources/main.scss */ .search-results .thumbnail-ui-box { width: 50%; /* Make sure this doesn't get too narrow, or it'll overlap too much of the thumbnail area. */ min-width: 800px; background-color: var(--ui-bg-color); color: var(--ui-fg-color); box-shadow: 0 0 15px 10px var(--ui-shadow-color); border-radius: 4px; padding: 10px; pointer-events: auto; } /* line 865, resources/main.scss */ .search-results .thumbnail-ui-box .disable-ui-button { margin-right: 2px; } /* line 868, resources/main.scss */ .search-results .thumbnail-ui-box .disable-ui-button > svg { width: 22px; } /* line 873, resources/main.scss */ .search-results .thumbnail-ui-box .displaying { padding-bottom: 4px; } /* line 876, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word { padding: 0px 5px; } /* line 880, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word:first-child { padding-left: 0px; /* remove left padding from the first item */ } /* line 884, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word.or { font-size: 12px; padding: 0; color: #bbb; } /* line 890, resources/main.scss */ .search-results .thumbnail-ui-box .bookmarks-link > svg, .search-results .thumbnail-ui-box .following-link > svg { width: 32px; height: 32px; } /* line 896, resources/main.scss */ .search-results .thumbnail-ui-box .contact-link > svg { width: 31px; height: 31px; margin: 0 3px; } /* line 902, resources/main.scss */ .search-results .thumbnail-ui-box .webpage-link > svg { margin: 0 2px; width: 26px; height: 26px; } /* line 908, resources/main.scss */ .search-results .thumbnail-ui-box .circlems-icon > svg { margin: 2px 0 0 0; } /* line 914, resources/main.scss */ .search-results .thumbnails { user-select: none; padding: 0; text-align: center; } /* line 920, resources/main.scss */ .search-results .thumbnails { display: flex; flex-wrap: wrap; justify-content: center; margin: 0 auto; /* center */ } /* line 930, resources/main.scss */ .search-results .flash a { animation-name: flash-thumbnail; animation-duration: 300ms; animation-timing-function: ease-out; animation-iteration-count: 1; } @keyframes flash-thumbnail { 0% { filter: brightness(200%); } } /* line 944, resources/main.scss */ .search-results .last-viewed-image-marker { position: absolute; left: 0; top: 0; pointer-events: none; height: auto; } /* line 954, resources/main.scss */ .search-results .thumbnail-box:not(.flash) .last-viewed-image-marker { display: none; } /* line 959, resources/main.scss */ .thumbnail-load-previous { width: 100%; } /* line 962, resources/main.scss */ .thumbnail-load-previous > .load-previous-buttons { margin-left: auto; margin-right: auto; display: flex; flex-direction: row; margin-top: 10px; margin-bottom: 4px; justify-content: center; height: 40px; max-width: 800px; } /* line 973, resources/main.scss */ .thumbnail-load-previous > .load-previous-buttons > .load-previous-button { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; margin: 0 10px; background-color: #880; border-radius: 4px; padding: 0 10px; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); } /* line 986, resources/main.scss */ .thumbnail-load-previous > .load-previous-buttons > .load-previous-button:hover { background-color: var(--box-link-hover-color); } /* line 993, resources/main.scss */ .thumbnail-box { /* Hide pending images (they haven't been set up yet). */ /* * thumbnail-box[data-nearby] is set on thumbs that are close to being onscreen. * If they don't have data-nearby, tell the browser they're not visible. This * significantly improves performance when we have a lot of thumbs loaded, making * offscreen thumbs essentially free. * * Note that content-intrinsic-size is set programmatically. */ } /* line 995, resources/main.scss */ .thumbnail-box[data-pending] { visibility: hidden; } /* line 998, resources/main.scss */ .thumbnail-box .thumbnail-inner { position: relative; } /* line 1010, resources/main.scss */ .thumbnail-box:not([data-nearby]) .thumbnail-inner { content-visibility: hidden; } /* line 1014, resources/main.scss */ .thumbnail-box a.thumbnail-link { display: block; width: 100%; height: 100%; border-radius: 4px; overflow: hidden; position: relative; text-decoration: none; color: #fff; } /* line 1027, resources/main.scss */ .page-count-box { position: absolute; right: 2px; bottom: 2px; padding: 4px 8px; background-color: rgba(0, 0, 0, 0.6); border-radius: 6px; transition: opacity .5s; } /* line 1036, resources/main.scss */ .page-count-box .page-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; } /* line 1043, resources/main.scss */ .page-count-box:hover .regular { display: none; } /* line 1046, resources/main.scss */ .page-count-box:not(:hover) .hover { display: none; } /* line 1050, resources/main.scss */ .page-count-box .page-count { vertical-align: middle; margin-left: -4px; } /* The similar illusts button on top of thumbnails. */ /* line 1059, resources/main.scss */ .screen-search-container .thumbnail-box .similar-illusts-button { display: block; width: 32px; height: 32px; margin-top: -2px; } /* line 1066, resources/main.scss */ .screen-search-container .thumbnail-box:not(:hover) .similar-illusts-button { visibility: hidden; } /* line 1070, resources/main.scss */ .screen-search-container .thumbnail-box .similar-illusts-button { color: #FF0 !important; /* override grey-icon hover color */ opacity: 0.5; /* Use a very subtle stroke when not hovered, so it's not completely invisible * on light backgrounds. */ stroke: rgba(0, 0, 0, 0.5); } /* line 1079, resources/main.scss */ .screen-search-container .thumbnail-box .similar-illusts-button:hover { opacity: 1; stroke: #000; } /* line 1084, resources/main.scss */ .screen-search-container .thumbnail-box .thumbnail-bottom-left { position: absolute; display: flex; left: 0px; bottom: 0px; } /* line 1090, resources/main.scss */ .screen-search-container .thumbnail-box .heart { pointer-events: none; width: 32px; height: 32px; } /* line 1095, resources/main.scss */ .screen-search-container .thumbnail-box .heart > svg { transition: opacity .5s; } /* line 1099, resources/main.scss */ .screen-search-container .thumbnail-box .ugoira-icon { pointer-events: none; width: 32px; height: 32px; right: 0px; bottom: 0px; color: #fff; position: absolute; transition: opacity .5s; } /* line 1111, resources/main.scss */ .screen-search-container .thumbnail-inner:hover .heart > svg { opacity: 0.5; } /* line 1114, resources/main.scss */ .screen-search-container .thumbnail-inner:hover .ugoira-icon { opacity: 0.5; } /* line 1121, resources/main.scss */ .thumbnail-box .thumb { object-fit: cover; /* Show the top-center of the thunbnail. This generally makes more sense * than cropping the center. */ object-position: 50% 0%; width: 100%; height: 100%; } /* line 1132, resources/main.scss */ .thumbnail-box[data-pending] .thumb { display: none; } /* line 1137, resources/main.scss */ .thumbnail-box .thumbnail-label { position: absolute; bottom: 3px; pointer-events: none; white-space: nowrap; color: var(--frame-fg-color); background-color: var(--frame-bg-color); left: 50%; position: absolute; transform: translate(-50%, 0); padding: 1px 8px; /* Max width fills the thumbnail the label is in, minus some space so we don't overlap bottom-left icons. */ max-width: calc(100% - 50px); overflow: hidden; border-radius: 2px; text-overflow: ellipsis; } /* line 1155, resources/main.scss */ .thumbnail-box .thumbnail-label > .label { /* Specify a line-height explicitly, so vertical centering is reasonably consistent for * both EN and JP text. */ line-height: 19px; } /* line 1163, resources/main.scss */ .thumbnail-box .thumb { transition: transform .5s; transform: scale(1, 1); } /* line 1173, resources/main.scss */ body:not(.disable-thumbnail-zooming) .thumbnail-box .thumbnail-inner:not(:hover) .thumb, body:not(.disable-thumbnail-zooming).pause-thumbnail-animation .thumbnail-box .thumb { transform: scale(1.25, 1.25); } /* line 1181, resources/main.scss */ .thumbnail-box.vertical-panning .thumb, .thumbnail-box.horizontal-panning .thumb { animation-duration: 4s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; } /* line 1192, resources/main.scss */ .thumbnail-box .thumbnail-inner:not(:hover) .thumb, body.pause-thumbnail-animation .thumbnail-box .thumb { animation-play-state: paused; } /* line 1197, resources/main.scss */ body:not(.disable-thumbnail-panning) .thumbnail-box.horizontal-panning .thumb { animation-name: pan-thumbnail-horizontally; object-position: left top; /* The full animation is 4 seconds, and we want to start 20% in, at the halfway * point of the first left-right pan, where the pan is exactly in the center where * we are before any animation. This is different from vertical panning, since it * pans from the top, which is already where we start (top center). */ animation-delay: -.8s; } /* line 1208, resources/main.scss */ body:not(.disable-thumbnail-panning) .thumbnail-box.vertical-panning .thumb { animation-name: pan-thumbnail-vertically; } @keyframes pan-thumbnail-horizontally { /* This starts in the middle, pans left, pauses, pans right, pauses, returns to the middle, then pauses again. */ 0% { object-position: left top; } /* left */ 40% { object-position: right top; } /* pan right */ 50% { object-position: right top; } /* pause */ 90% { object-position: left top; } /* pan left */ 100% { object-position: left top; } /* pause */ } @keyframes pan-thumbnail-vertically { /* This starts at the top, pans down, pauses, pans back up, then pauses again. */ 0% { object-position: 50% 0%; } 40% { object-position: 50% 100%; } 50% { object-position: 50% 100%; } 90% { object-position: 50% 0%; } 100% { object-position: 50% 0%; } } /* line 1230, resources/main.scss */ .thumbnail-box.muted { /* Zoom muted images in a little, and zoom them out on hover, which is the opposite * of other images. This also helps hide the black bleed around the edge caused by * the blur. */ } /* line 1231, resources/main.scss */ .thumbnail-box.muted .muted-text { pointer-events: none; left: 0; top: 50%; width: 100%; height: 32px; position: absolute; color: #000; text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff; font-size: 22px; } /* line 1246, resources/main.scss */ .thumbnail-box.muted .thumb { filter: blur(10px); transform: scale(1.25, 1.25); } /* line 1251, resources/main.scss */ body:not(.disable-thumbnail-zooming) .thumbnail-box.muted .thumb:hover { transform: scale(1, 1); } /* line 1256, resources/main.scss */ .thumbnail-box:not(.muted) .muted-text { display: none; } /* line 1262, resources/main.scss */ .screen-search-container .following-tag { text-decoration: none; } /* line 1266, resources/main.scss */ .screen-search-container .right-side-button { display: inline-block; vertical-align: middle; cursor: pointer; user-select: none; } /* line 1272, resources/main.scss */ .screen-search-container .right-side-button > svg { vertical-align: middle; } /* line 1278, resources/main.scss */ .screen-search-container .edit-search-button { margin-left: -58px; /* overlap the input */ } /* line 1287, resources/main.scss */ .box-link-row { display: flex; flex-direction: row; align-items: center; gap: 0.5em; } /* line 1295, resources/main.scss */ .box-link-row > .box-link { padding-left: 0.25em; padding-right: 0.25em; } /* line 1302, resources/main.scss */ .box-button-row { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; } /* line 1308, resources/main.scss */ .box-button-row .box-link { margin: 0.25em .25em; padding: 0 .5em; } /* line 1317, resources/main.scss */ .box-button-row .box-link > * { padding: .25em 0; } /* line 1331, resources/main.scss */ .vertical-list > .box-link { padding: 0 .25em; } /* line 1333, resources/main.scss */ .vertical-list > .box-link { display: flex; flex-direction: row; align-items: center; margin-top: 0; margin-bottom: 0; } /* line 1344, resources/main.scss */ .box-link { display: inline-flex; cursor: pointer; text-decoration: none; margin: 0; align-content: center; align-items: center; height: 2em; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); user-select: none; white-space: nowrap; } /* line 1358, resources/main.scss */ .box-link.disabled { color: var(--box-link-disabled-color); cursor: auto; pointer-events: none; } /* line 1366, resources/main.scss */ .box-link:hover:not(.disabled) { background-color: var(--box-link-hover-color); } /* line 1370, resources/main.scss */ .box-link.selected { background-color: var(--box-link-selected-color); } /* line 1374, resources/main.scss */ .box-link.tag { /* Some tags are way too long, since translations don't put any sanity limit on length. * Cut these off so they don't break the layout. */ max-width: 100%; text-overflow: ellipsis; overflow: hidden; } /* line 1382, resources/main.scss */ .box-link .label { margin-left: .25em; } /* line 1386, resources/main.scss */ .box-link .icon { display: inline-block; width: 1em; position: relative; top: 0.125em; } /* line 1392, resources/main.scss */ .box-link .icon svg { width: auto; height: 1em; } /* line 1399, resources/main.scss */ .box-link.active { background-color: var(--box-link-selected-color); } /* line 1405, resources/main.scss */ a.box-link, span.box-link { padding-top: 0.5em; padding-bottom: 0.5em; } /* line 1411, resources/main.scss */ .search-box { white-space: nowrap; margin-bottom: 4px; position: relative; /* to position the search dropdown */ } /* The block around the input box and submit button. A history dropdown widget will * be placed in here. */ /* line 1419, resources/main.scss */ .tag-search-box { display: inline-block; position: relative; } /* line 1424, resources/main.scss */ input.search-users { font-size: 1.2em; padding: 6px 10px; vertical-align: middle; padding-right: 30px; /* extra space for the submit button */ } /* line 1430, resources/main.scss */ .user-search-box .search-submit-button { margin-left: -30px; /* overlap the input */ } /* line 1433, resources/main.scss */ input.search-tags { font-size: 1.2em; padding: 6px 10px; vertical-align: middle; } /* Search box in the search page: */ /* line 1440, resources/main.scss */ .tag-search-box input.search-tags { padding-right: 60px; /* extra space for the submit button */ } /* line 1444, resources/main.scss */ .search-submit-button { /* Work around HTML's stupid whitespace handling */ font-size: 0; display: inline-block; } /* Search box in the menu: */ /* line 1452, resources/main.scss */ .navigation-search-box .search-submit-button { vertical-align: middle; margin-left: -30px; /* overlap the search box */ } /* line 1456, resources/main.scss */ .navigation-search-box input.search-tags { padding-right: 30px; /* extra space for the submit button */ } /* line 1461, resources/main.scss */ .thumbnail-ui-box .avatar-container { float: right; position: relative; margin-left: 25px; } /* line 1467, resources/main.scss */ .image-for-suggestions { float: right; margin-left: 25px; } /* line 1471, resources/main.scss */ .image-for-suggestions > img { display: block; height: 150px; width: 150px; object-fit: cover; border-radius: 5px; /* matches the avatar display */ } /* line 1480, resources/main.scss */ .grey-icon { color: var(--button-color); cursor: pointer; /* If a grey-icon is directly inside a visible popup menu, eg. the navigation icon: */ } /* line 1486, resources/main.scss */ .grey-icon:hover, :hover > .grey-icon.parent-highlight { color: var(--button-highlight-color); } /* line 1493, resources/main.scss */ .popup-visible > .grey-icon { color: var(--button-highlight-color); } /* line 1499, resources/main.scss */ .mute-display .muted-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(20px); opacity: .75; } /* line 1510, resources/main.scss */ .mute-display .muted-text { position: absolute; width: 100%; top: 50%; left: 0; text-align: center; font-size: 30px; color: #000; text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff; } /* Tag lists are usually inline. Make the tag filter a vertical list. */ /* line 1523, resources/main.scss */ .member-tags-box .post-tag-list, .search-tags-box .related-tag-list { max-height: 300px; overflow-x: hidden; overflow-y: auto; white-space: nowrap; } /* line 1532, resources/main.scss */ .member-tags-box .post-tag-list .following-tag:hover:after, .search-tags-box .related-tag-list .tag:hover:after { left: auto; right: 0px; } /* These affect both the search edit and search history boxes. */ /* line 1539, resources/main.scss */ .input-dropdown { width: 500px; /* overridden by script */ max-width: 800px; margin: 1px; z-index: 1; user-select: none; /* This is used for the search tag dropdown, which is in a fixed position at the top of the * screen. Limit the height to the size of the window minus (roughly) its position. */ max-height: 400px; max-height: calc(100vh - 400px); /* Always show the vertical scrollbar. Otherwise, the resize handle falls under the buttons * when it's not shown. */ overflow-x: hidden; overflow-y: scroll; resize: horizontal; position: absolute; background-color: #fff; /* Styles specific to the search history version of the dropdown: */ /* Styles specific to the edit search version of the dropdown. */ } /* line 1559, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list { display: flex; flex-direction: column; white-space: normal; } /* line 1565, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry { display: flex; flex-direction: row; color: #000; align-items: center; /* This 6px vertical padding should match the remove-history-entry padding. */ padding: 6px 0; } /* line 1574, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search { color: #000; flex: 1; padding-left: 7px; height: 100%; } /* line 1581, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search .word { display: inline-flex; align-items: center; height: 100%; padding: 0px 5px; } /* line 1587, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search .word.or { font-size: 12px; padding: 0; color: #333; } /* line 1596, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list { /* Hide the button to remove history entries from non-history entries. */ } /* line 1597, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry .suggestion-icon { margin: 2px -2px 0 2px; } /* line 1600, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.autocomplete) .suggestion-icon { display: none; } /* line 1604, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry.selected { background-color: #ffa; } /* line 1608, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover { background-color: #ddd; } /* line 1612, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list .remove-history-entry { height: 30px; width: 30px; /* Set an arbitrarily low negative margin. This makes it so the button extends into the * into the surrounding row's padding instead of pushing the whole row out. See * .input-dropdown-list > .entry padding. */ margin: -6px 0; display: inline-flex; align-items: center; justify-content: center; visibility: hidden; } /* line 1628, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry { display: none; } /* line 1632, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover .remove-history-entry { visibility: visible; } /* line 1635, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list .remove-history-entry:hover { color: #000; background-color: #c0c0c0; } /* line 1642, resources/main.scss */ .edit-search > .input-dropdown { padding: 4px 0; /* The edit search list is shown as a wrapped list, so enable wrapping and switch items from flex to inline-flex. */ } /* line 1646, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list { white-space: normal; max-width: 100%; } /* line 1650, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry { display: inline-flex; } /* line 1653, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight { background-color: #eeee00; } /* line 1654, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag:hover { background-color: #0099FF; } /* line 1655, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight:hover { background-color: #00CCFF; } /* line 1662, resources/main.scss */ .manga-thumbnail-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 240px; max-height: 30%; user-select: none; /* The .strip container is the overall strip. This is a flexbox that puts the nav * arrows on the outside, and the thumb strip stretching in the middle. The thumb * strip itself is also a flexbox, for the actual thumbs. */ } /* line 1672, resources/main.scss */ body.hide-ui .manga-thumbnail-container { display: none; } /* line 1680, resources/main.scss */ .manga-thumbnail-container > .strip { background-color: var(--ui-bg-color); height: 100%; display: flex; flex-direction: row; opacity: 0; transition: transform .15s, opacity .15s; transform: translate(0, 25px); } /* line 1691, resources/main.scss */ .manga-thumbnail-container > .strip > .manga-thumbnails { flex: 1; display: flex; flex-direction: row; overflow: hidden; justify-content: left; scroll-behavior: smooth; height: 100%; padding: 5px 0; } /* line 1704, resources/main.scss */ .manga-thumbnail-container.visible > .strip { opacity: 1; transform: translate(0, 0); } /* line 1710, resources/main.scss */ .manga-thumbnail-container .manga-thumbnail-box { cursor: pointer; height: 100%; margin: 0 5px; /* The first entry has the cursor inside it. Set these to relative, so the * cursor position is relative to it. */ position: relative; } /* line 1720, resources/main.scss */ .manga-thumbnail-container .manga-thumbnail-box img.manga-thumb { height: 100%; width: auto; border-radius: 3px; /* This will limit the width to 300px, cropping if needed. This prevents * very wide aspect ratio images from breaking the layout. Only a fixed * size will work here, percentage values won't work. */ max-width: 400px; object-fit: cover; } /* line 1735, resources/main.scss */ .manga-thumbnail-arrow { height: 100%; width: 30px; margin: 0 6px; } /* line 1741, resources/main.scss */ .manga-thumbnail-arrow > svg { fill: #888; } /* line 1745, resources/main.scss */ .manga-thumbnail-arrow:hover > svg { fill: #ff0; } /* line 1750, resources/main.scss */ body.light .manga-thumbnail-arrow { stroke: #aa0; } /* line 1755, resources/main.scss */ .manga-thumbnail-arrow > svg { display: block; height: 100%; width: 100%; padding: 4px; } /* line 1764, resources/main.scss */ .thumb-list-cursor { position: absolute; left: 0; bottom: -6px; width: 40px; height: 4px; background-color: var(--ui-fg-color); border-radius: 2px; } /* line 1775, resources/main.scss */ .widget:not(.visible-widget) { display: none; } /* The right click context menu for the image view: */ /* line 1781, resources/main.scss */ .popup-context-menu { color: #fff; position: fixed; top: 100px; left: 350px; text-align: left; padding: 10px; border-radius: 8px; display: flex; flex-direction: column; user-select: none; will-change: opacity, transform; transition: opacity ease 0.15s, transform ease 0.15s; /* Hide the normal tooltips. The context menu shows them differently. */ } /* line 1798, resources/main.scss */ .popup-context-menu:not(.visible-widget) { display: inherit; opacity: 0; pointer-events: none; transform: scale(0.85); } /* line 1806, resources/main.scss */ .popup-context-menu.visible-widget { opacity: 1; } /* line 1811, resources/main.scss */ .popup-context-menu > * { transform-origin: unset; } /* line 1816, resources/main.scss */ .popup-context-menu .popup:hover:after { display: none; } /* line 1820, resources/main.scss */ .popup-context-menu .tooltip-display { display: flex; align-items: stretch; padding: 10px 0 0 8px; pointer-events: none; } /* line 1827, resources/main.scss */ .popup-context-menu .tooltip-display .tooltip-display-text { background-color: var(--frame-bg-color); color: var(--frame-fg-color); padding: 2px 8px; border-radius: 4px; } /* line 1834, resources/main.scss */ .popup-context-menu .button-strip { display: flex; align-items: stretch; /* Remove the double horizontal padding: */ /* Remove the double vertical padding. Do this with a negative margin instead of zeroing * the padding, so the rounded black background stays the same size. */ /* Round the outer corners of each strip. */ /* This nudges the zoom strip to the left by the width of one button, to add the browser * back button to the left of other buttons. */ } /* line 1838, resources/main.scss */ .popup-context-menu .button-strip > .button-block { display: inline-block; background-color: var(--frame-bg-color); padding: 12px; } /* line 1845, resources/main.scss */ .popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; } /* line 1849, resources/main.scss */ .popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; } /* line 1852, resources/main.scss */ .popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; } /* line 1853, resources/main.scss */ .popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; } /* line 1855, resources/main.scss */ .popup-context-menu .button-strip .button { border-radius: 4px; padding: 6px; height: 100%; text-align: center; cursor: pointer; display: flex; flex-direction: column; justify-content: center; background-color: var(--toggle-button-bg-dim-color); color: var(--toggle-button-fg-dim-color); /* Grey out the buttons if this strip isn't enabled. */ /* We don't have a way to add classes to inlined SVGs yet, so for now just use nth-child. The first child is the + icon and the second child is -. */ /* Popup menu bookmarking */ } /* line 1868, resources/main.scss */ .popup-context-menu .button-strip .button:not(.enabled) { cursor: inherit; color: var(--toggle-button-fg-disabled-color); } /* line 1874, resources/main.scss */ .popup-context-menu .button-strip .button > * { min-width: 32px; } /* line 1877, resources/main.scss */ .popup-context-menu .button-strip .button > svg { width: 32px; height: 32px; } /* line 1882, resources/main.scss */ .popup-context-menu .button-strip .button.enabled:hover { color: var(--toggle-button-fg-color); } /* line 1886, resources/main.scss */ .popup-context-menu .button-strip .button.enabled.selected { background-color: var(--toggle-button-bg-color); color: var(--toggle-button-fg-color); } /* line 1893, resources/main.scss */ .popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; } /* line 1894, resources/main.scss */ .popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; } /* line 1897, resources/main.scss */ .popup-context-menu .button-strip .button .tag-dropdown-arrow { width: 0; height: 0; border-top: 10px solid #222; border-left: 10px solid transparent; border-right: 10px solid transparent; } /* line 1905, resources/main.scss */ body.light .popup-context-menu .button-strip .button .tag-dropdown-arrow { border-top-color: #ccc; } /* line 1912, resources/main.scss */ .popup-context-menu .button-strip > .button-block.shift-left { margin-left: -56px; } /* line 1917, resources/main.scss */ .popup-context-menu .context-menu-image-info { /* Bottom align within the row. */ align-self: flex-end; display: flex; flex-direction: column; align-items: center; background-color: var(--box-link-bg-color); padding-right: 8px; } /* line 1926, resources/main.scss */ .popup-context-menu .context-menu-image-info > * { background-color: var(--box-link-bg-color); color: var(--box-link-fg-color); padding: 2px 0 0px 0px; font-size: .8em; font-weight: bold; } /* line 1935, resources/main.scss */ .popup-context-menu .popup-bookmark-tag-dropdown { right: -100%; } /* line 1944, resources/main.scss */ .popup-more-options-container .button-send-image svg .arrow { transition: transform ease-in-out .15s; } /* line 1948, resources/main.scss */ .popup-more-options-container .button-send-image:not(.disabled):hover svg .arrow { transform: translate(2px, -2px); } /* line 1954, resources/main.scss */ .popup-bookmark-tag-dropdown, .popup-more-options-dropdown { background-color: var(--frame-bg-color); color: var(--frame-fg-color); position: absolute; padding: 8px; top: calc(100%); border-radius: 0px 0px 4px 4px; /* Put this on top of other elements, like the image-ui tag list. */ z-index: 1; /* In the context menu version, nudge the tag dropdown up slightly to cover * the rounded corners. */ /* Recent bookmark tags in the popup menu: */ } /* line 1968, resources/main.scss */ .popup-context-menu .popup-bookmark-tag-dropdown, .popup-context-menu .popup-more-options-dropdown { top: calc(100% - 4px); } /* line 1972, resources/main.scss */ .popup-bookmark-tag-dropdown > .tag-list, .popup-more-options-dropdown > .tag-list { display: flex; flex-direction: column; min-width: 200px; overflow-x: hidden; overflow-y: auto; } /* line 1980, resources/main.scss */ .popup-bookmark-tag-dropdown > .tag-right-button-strip, .popup-more-options-dropdown > .tag-right-button-strip { position: absolute; top: 0; left: 100%; background-color: var(--frame-bg-color); color: var(--frame-fg-color); display: flex; flex-direction: column; align-items: stretch; } /* line 1990, resources/main.scss */ .popup-bookmark-tag-dropdown > .tag-right-button-strip .tag-button, .popup-more-options-dropdown > .tag-right-button-strip .tag-button { cursor: pointer; } /* line 1996, resources/main.scss */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry, .popup-more-options-dropdown .popup-bookmark-tag-entry { display: flex; flex-direction: row; align-items: center; padding: 4px 0px; display: flex; cursor: pointer; } /* line 2003, resources/main.scss */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry > .tag-name, .popup-more-options-dropdown .popup-bookmark-tag-entry > .tag-name { flex: 1; } /* line 2006, resources/main.scss */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active, .popup-more-options-dropdown .popup-bookmark-tag-entry.active { background-color: #008; } /* line 2009, resources/main.scss */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active, body.light .popup-more-options-dropdown .popup-bookmark-tag-entry.active { background-color: #00c; color: #fff; } /* line 2013, resources/main.scss */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active:hover, .popup-more-options-dropdown .popup-bookmark-tag-entry.active:hover { background-color: #00a; } /* line 2016, resources/main.scss */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active:hover, body.light .popup-more-options-dropdown .popup-bookmark-tag-entry.active:hover { background-color: #00a; } /* line 2019, resources/main.scss */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:not(.active):hover, .popup-more-options-dropdown .popup-bookmark-tag-entry:not(.active):hover { background-color: #222; } /* line 2022, resources/main.scss */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:not(.active):hover, body.light .popup-more-options-dropdown .popup-bookmark-tag-entry:not(.active):hover { background-color: #ccc; } /* line 2028, resources/main.scss */ .button.button-bookmark .count, .button.button-like .count { color: var(--minor-text-fg-color); text-shadow: 0px 1px 1px var(--minor-text-shadow-color), 0px -1px 1px var(--minor-text-shadow-color), 1px 0px 1px var(--minor-text-shadow-color), -1px 0px 1px var(--minor-text-shadow-color); font-size: .7em; font-weight: bold; position: absolute; top: calc(100% - 14px); left: 0; width: 100%; text-align: center; } /* Nudge the public heart icon up a bit to make room for the bookmark count. * Only do this on the popup menu, not image-ui. */ /* line 2049, resources/main.scss */ .popup-context-menu .button.button-bookmark.public > svg { margin-top: -10px; } /* line 2053, resources/main.scss */ .popup-context-menu .button.button-like > svg { margin-top: -2px; } /* Bookmark buttons. These appear in image_ui and the popup menu. */ /* line 2059, resources/main.scss */ .button.button-bookmark.will-delete.enabled:hover svg.heart-image .delete { display: inline; } /* Hide the "delete" stroke over the heart icon unless clicking the button will * remove the bookmark. */ /* line 2065, resources/main.scss */ svg.heart-image .delete { display: none; } /* These are !important to override the default white coloring in the context * menu. */ /* line 2071, resources/main.scss */ .button-bookmark { color: #400 !important; } /* line 2073, resources/main.scss */ .button-bookmark.enabled { color: #800 !important; stroke: none; } /* line 2078, resources/main.scss */ .button-bookmark.bookmarked, .button-bookmark.enabled:hover { color: #f00 !important; stroke: none; } /* Add a stroke around the heart on thumbnails for visibility. Don't * change the black lock. */ /* line 2087, resources/main.scss */ .screen-search-container .thumbnails .button-bookmark svg > .heart { stroke: #000; stroke-width: .5px; } /* line 2092, resources/main.scss */ .button.button-like { /* This is a pain due to transition bugs in Firefox. It doesn't like having * transition: transform on both an SVG and on individual paths inside the * SVG and clips the image incorrectly during the animation. Work around this * by only placing transitions on the paths. */ } /* line 2097, resources/main.scss */ .button.button-like > svg { color: var(--like-button-color); } /* line 2101, resources/main.scss */ .button.button-like.liked > svg { color: var(--like-button-liked-color); } /* line 2104, resources/main.scss */ .button.button-like.enabled:hover > svg { color: var(--like-button-hover-color); } /* line 2110, resources/main.scss */ .button.button-browser-back .arrow { transition: transform ease-in-out .15s; transform: translate(-2px, 0px); } /* line 2114, resources/main.scss */ .button.button-browser-back:hover .arrow { transform: translate(1px, 0px); } /* line 2119, resources/main.scss */ .button.button-like > svg > * { transition: transform ease-in-out .15s; transform: translate(0, 0px); } /* line 2123, resources/main.scss */ .button.button-like > svg > .mouth { transform: scale(1, 0.75); } /* line 2127, resources/main.scss */ .button.button-like.liked > svg > * { transform: translate(0, -3px); } /* line 2130, resources/main.scss */ .button.button-like.liked > svg > .mouth { transform: scale(1, 1.1) translate(0, -3px); } /* line 2133, resources/main.scss */ .button.button-like.enabled:hover > svg > * { transform: translate(0, -2px); } /* line 2136, resources/main.scss */ .button.button-like.enabled:hover > svg > .mouth { transform: scale(1, 0.9) translate(0, -3px); } /* line 2139, resources/main.scss */ .button-bookmark.public svg.heart-image .lock { display: none; } /* line 2142, resources/main.scss */ .button-bookmark svg.heart-image .lock { stroke: #888; } /* line 2146, resources/main.scss */ .dialog { position: absolute; z-index: 1000; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; } /* line 2158, resources/main.scss */ .dialog .content { font-size: 18px; max-width: 800px; background-color: var(--ui-bg-color); color: var(--ui-fg-color); border-radius: 5px; position: relative; } /* line 2166, resources/main.scss */ .dialog .content > .scroll { width: 100%; height: 100%; overflow-y: auto; padding: 1em; } /* line 2174, resources/main.scss */ .dialog .header { font-size: 40px; margin-bottom: 20px; } /* line 2180, resources/main.scss */ .whats-new-box .content { width: 80%; height: 80%; } /* line 2184, resources/main.scss */ .whats-new-box .content .rev { display: inline-block; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); padding: 5px 10px; } /* line 2190, resources/main.scss */ .whats-new-box .content .text { margin: 1em 0; padding: 0 20px; /* inset horizontally a bit */ } /* line 2196, resources/main.scss */ .close-button { position: absolute; top: 5px; right: -40px; color: var(--button-color); background-color: var(--ui-bg-color); padding: 4px; border-radius: 5px; cursor: pointer; } /* line 2205, resources/main.scss */ .close-button:hover { color: var(--button-highlight-color); } /* line 2208, resources/main.scss */ .close-button > svg { display: block; } /* line 2214, resources/main.scss */ .screen-illust-container .page-change-indicator { position: absolute; height: 100%; display: flex; align-items: center; pointer-events: none; /* Hide the | portion of >| when showing last page rather than end of results. */ } /* line 2221, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="left"] { margin-left: 20px; left: 0; } /* line 2226, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="right"] { margin-right: 20px; right: 0; } /* line 2231, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="right"] svg { transform-origin: center center; transform: scale(-1, 1); } /* line 2237, resources/main.scss */ .screen-illust-container .page-change-indicator[data-icon="last-page"] svg .bar { display: none; } /* line 2240, resources/main.scss */ .screen-illust-container .page-change-indicator svg { opacity: 0; } /* line 2243, resources/main.scss */ .screen-illust-container .page-change-indicator.flash svg { animation: flash-page-change-opacity 400ms ease-out 1 forwards; } /* line 2247, resources/main.scss */ .screen-illust-container .page-change-indicator.flash svg .animated { animation: flash-page-change-part 300ms ease-out 1 forwards; } @keyframes flash-page-change-opacity { 0% { opacity: 1; } 40% { opacity: 1; } 80% { opacity: 0; } } @keyframes flash-page-change-part { 0% { transform: translate(0, 0px); } 20% { transform: translate(-4px, 0px); } 100% { transform: translate(0, 0px); } } /* line 2267, resources/main.scss */ .link-tab-popup .button { display: inline-block; cursor: pointer; background-color: #000; padding: .5em 1em; margin: .5em; border-radius: 5px; } /* line 2276, resources/main.scss */ .link-tab-popup .content { width: 400px; padding: 1em; } /* line 2281, resources/main.scss */ .link-tab-popup .buttons { display: flex; } /* line 2285, resources/main.scss */ .link-tab-popup .tutorial-monitor { width: 290px; height: 125px; margin-bottom: -20px; } /* line 2293, resources/main.scss */ .link-tab-popup .tutorial-monitor .rotating-monitor { transform-origin: 75px 30px; animation: rotate-monitor 4500ms linear infinite; } @keyframes rotate-monitor { 0% { transform: rotate(0deg); } 10% { transform: rotate(90deg); } 50% { transform: rotate(90deg); } 60% { transform: rotate(0deg); } } /* line 2310, resources/main.scss */ .send-image-popup .content { padding: 1em; } /* line 2317, resources/main.scss */ .link-this-tab-popup > .box, .send-image-here-popup > .box { border: 1px solid black; background-color: #000; color: #fff; padding: 1em; } /* line 2324, resources/main.scss */ .link-this-tab-popup .button, .send-image-here-popup .button { cursor: pointer; padding: 1em; } /* line 2330, resources/main.scss */ .tag-entry-popup { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); } /* line 2339, resources/main.scss */ .tag-entry-popup > .strip { display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: center; } /* line 2345, resources/main.scss */ .tag-entry-popup > .strip > .box { background-color: #222; padding: 10px; color: #eee; position: relative; } /* line 2351, resources/main.scss */ body.light .tag-entry-popup > .strip > .box { background-color: #ddd; color: #222; } /* line 2358, resources/main.scss */ .tag-entry-popup .close-button { position: absolute; top: 0px; right: 0px; padding: 8px; cursor: pointer; } /* line 2366, resources/main.scss */ .tag-entry-popup .tag-input-box { position: relative; display: flex; align-items: center; } /* line 2371, resources/main.scss */ .tag-entry-popup .tag-input-box > .add-tag-input { flex: 1; padding: 4px; } /* line 2375, resources/main.scss */ .tag-entry-popup .tag-input-box > .submit-button { cursor: pointer; display: inline-block; width: 20px; text-align: center; margin-left: 6px; border: 1px solid white; } /* line 2382, resources/main.scss */ body.light .tag-entry-popup .tag-input-box > .submit-button { border-color: #444; } /* line 2385, resources/main.scss */ .tag-entry-popup .tag-input-box > .submit-button:hover { background-color: #444; } /* line 2387, resources/main.scss */ body.light .tag-entry-popup .tag-input-box > .submit-button:hover { background-color: #aaa; } /* line 2392, resources/main.scss */ .years-ago { padding: .25em; margin: .25em; white-space: nowrap; /* These links are mostly the same as box-link, but since the * menu background is the same as the box-link background color, * shift it a little to make it clear these are buttons. */ } /* line 2399, resources/main.scss */ .years-ago > a { padding: 4px 10px; background-color: #444; } /* line 2402, resources/main.scss */ body.light .years-ago > a { background-color: #ccc; } /*# sourceMappingURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/output/main.scss.map */`; ppixiv.resources["resources/multi-monitor.svg"] = ` `; ppixiv.resources["resources/noise-light.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAD1BMVEXo6Ojn5+fo6Ojn5+fn5+cbG+42AAAACXBIWXMAAAsTAAALEwEAmpwYAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yNlQyMzowMjo0My0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIyIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OWM1ZTI1ZTktNmM0Ny1lMTRkLWI1NmYtMWM3MWRhOTk3NzAxIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ODcxMzVhMGQtMTYwMy1hODRhLTliZWMtNGJhMDllZDE1NGQ2IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MTEzYjRiYTgtMmYzNC0zZTQ2LTgyZmUtNTM2ZDNlZDgzYTlmIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMTNiNGJhOC0yZjM0LTNlNDYtODJmZS01MzZkM2VkODNhOWYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjZUMjM6MDI6NDMtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjljNWUyNWU5LTZjNDctZTE0ZC1iNTZmLTFjNzFkYTk5NzcwMSIgc3RFdnQ6d2hlbj0iMjAxOC0wNy0wMVQxNzoyMjozNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5BpB4MAAAWVUlEQVR4nKWb25osuXGrfyD4/k9cBHwRzDXy/rw9GvmuR+pVncWMA04cTq2C5I5wXU5HHRDgnmL11BVjMK4BMHY1cFAFTCU4OQVOjEd2ZVdIs3/JPZURwhpF2KoYUDUXMDkXJGlQVHxVixbXtqIpo5rpJArIBQtfh4FiEairACgUaaIgcHGYwQRcpVJ8B1oQnOJQdQD1BDuI1CkuwZf4GnRUVVKLW3D3b8oR/U4TI4JVFeFqptSFY4AWWWIa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`; ppixiv.resources["resources/noise.png"] = `data:image/png;base64,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`; ppixiv.resources["resources/page-icon-hover.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMC0wMS0xMlQxODo1NDowNi0wNjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjAtMDEtMTJUMTg6NTQ6MDYtMDY6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6MjE2NDcxMGYtOTM5My1hZjRmLTg3ZWUtY2NmY2YxNGIzMjYzIiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDplMDMzNGE3MS0yZDYyLWU0NGMtYWIyNS1kYmNlM2VhNzA1NjAiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjYxNjM4NWIxLTA4YjUtZDI0YS1iNDMyLTcwM2IwZjQxYzYxOSIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiBzdEV2dDp3aGVuPSIyMDIwLTAxLTEyVDE4OjU0OjA2LTA2OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+iRnHuAAAAEFJREFUOI1j/P//PwMlgIki3QwMDCxIbEJOYaSJC0g14D8Dmkvp7oLhaAByOsAaz6QYAAMkpW2qegEGSPLKwMcCANSMByRCC4aMAAAAAElFTkSuQmCC`; ppixiv.resources["resources/page-icon.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFwmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjE2Mzg1YjEtMDhiNS1kMjRhLWI0MzItNzAzYjBmNDFjNjE5IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6YmNlMzU0ZTgtMDE3Zi1iMjRkLTg4MTYtOGZkZTZlYTgyZDg5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTMwVDAyOjAxOjE3LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2MTYzODViMS0wOGI1LWQyNGEtYjQzMi03MDNiMGY0MWM2MTkiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+1Ie7qQAAADpJREFUOI1j/P//PwMlgIki3dQwgAWNT8g/jNRwAYolAx8GowZgpgOMeCbVAAYGwomJoAEkuWLgAxEAc7EGJRNwU4UAAAAASUVORK5CYII=`; ppixiv.resources["resources/pixiv-icon.svg"] = ` `; ppixiv.resources["resources/play-button.svg"] = ` `; ppixiv.resources["resources/refresh-icon.svg"] = ` image/svg+xml `; ppixiv.resources["resources/regular-pixiv-icon.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAMAAABlApw1AAAAG1BMVEX///8Alvqe1/1SuPwYoPvn9v/F5/40rPt2x/37JN9BAAAEBElEQVR42u3c23YsERAG4EJR3v+J92SSycqe0d2UYkh+F7nKEl+jHAJymycCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIBVACwhe59SjJGIbj9TSj4H4fUBfCt5ouOUfJBlAZx9pJqUsqwHCJWF/0rRh4UA3Fj6h0GWAHBIpE0pvB0gmm//sxryOwGczz/+PX5+htKTXwrvArA/ad63oP+zhctZdFUTqK/tHJZdjvv6ASLxbIAkZYCXHO26AtkWP+W671iOWppKIMO2HzN3Nj9FTyCz4jfH82Id5hmAUhNOmhE1FwR+OKD04ZJyQsCpuyOQwVfrGUlzr6AREKJZAH/k2JkhdXde6pwTS+wSUN+fMgAUBUMAR9Oe7lVJQeDtAXI4D+tfVknHeEAdvdcMUOrJYgvIJ5N5A0Ah/2gJ4NM1iwXAJW0jovZOlvwAAEdlI6LmBprdCIC6EVFrzsGNAWgb0SXg/9JGcaMAr7E0sgEgvZZ/EKAwUOZ+QCp9kkEAVlUBNYTPR36DALoqoPbyDwOIJhBRffnFDQYUAlHoADwPv+KGA4JiXk3149d4gHudp3BHE/JHE/RxAEU3prrpSXRTALm9DVFddjIHwO1tiKqigndzAIo4RDWB+WlEHAjwzatjqsnPu1mA9kB6MReKpUwGAgrL+z5AKGUyEOCaV/dU1Sin9QEXW3vxFeBzMJBpgOZ12eWKLM+tgeYwRDXfZF4nbh+LqSIwPG8Wbwa4rObBgLgXIPw+AAEAwJ/qxL8QsNk4MGAqMReQdgeYT6cnA9h8QTMZEKyXlLMB3npRPxuQjLdVZgOYbDe2pgOC8dbidECy3dydDlC0oLUAWfF/yqUAmoNPKwFUp4ZWAii68FIA3bGtdQCsO7y4DsDrzs0tAxDlOfBlAFF3XGgZgNceJlwEkNUXCdYAFCJo5I0AhQ5sfXJ3KKB0Kt787PRAQFf5FwCEvptAbwf03mR6M6B4rDy5bQDFWwltN8kMAOor2eVLIY13ugwASsLBVeTWrEwAH/UeTIofm68EWgHujyxUX/86uknt2wtjB/h6s4MrSh8Nr6XbAu7FSCcPqMjJwx9edaXRHvBgeJ9zCCLCtyQS8sWDK8oLsaMArU9LqAcTA0B8Y/FNANz5skff2yQWgHvnVD6u0v06jBHANbwsZPcwjC3gKkr2DHzTAPeaCD7Fy8Jnq0eq7AFfio8HwkoX8JNd2UcCvtuUhNsI9pHCLVm/cDYBMD4BAAAAAAAAAAAAAPC3AM+bypl3AnDpkTkv2wBytNwWnA8Ix/sLewBONn9kC8DJ+668BYD9UQq7RKHlEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADQnP4BoAuIrpag2iEAAAAASUVORK5CYII=`; ppixiv.resources["resources/related-illusts.svg"] = ` `; ppixiv.resources["resources/search-icon.svg"] = ``; ppixiv.resources["resources/search-result-icon.svg"] = ` `; ppixiv.resources["resources/send-message.svg"] = ` `; ppixiv.resources["resources/send-to-tab.svg"] = ` `; ppixiv.resources["resources/settings-icon.svg"] = ` `; ppixiv.resources["resources/shopping-cart.svg"] = ` `; ppixiv.resources["resources/tag-icon.svg"] = ` `; ppixiv.resources["resources/thumbnails-icon.svg"] = ` `; ppixiv.resources["resources/whats-new.svg"] = ` `; ppixiv.resources["resources/zoom-actual.svg"] = ` `; ppixiv.resources["resources/zoom-full.svg"] = ` `; ppixiv.resources["resources/zoom-minus.svg"] = ` `; ppixiv.resources["resources/zoom-plus.svg"] = ` `; ppixiv.resources["output/setup.js"] = { "source_files": [ "src/actions.js", "src/muting.js", "src/crc32.js", "src/helpers.js", "src/settings.js", "src/fix_chrome_clicks.js", "src/widgets.js", "src/menu_option.js", "src/main_context_menu.js", "src/create_zip.js", "src/data_sources.js", "src/encode_mkv.js", "src/hide_mouse_cursor_on_idle.js", "src/image_data.js", "src/on_click_viewer.js", "src/polyfills.js", "src/progress_bar.js", "src/seek_bar.js", "src/struct.js", "src/ugoira_downloader_mjpeg.js", "src/viewer.js", "src/viewer_images.js", "src/viewer_muted.js", "src/viewer_ugoira.js", "src/zip_image_player.js", "src/screen.js", "src/screen_illust.js", "src/screen_search.js", "src/screen_manga.js", "src/image_ui.js", "src/tag_search_dropdown_widget.js", "src/recently_seen_illusts.js", "src/tag_translations.js", "src/thumbnail_data.js", "src/manga_thumbnail_widget.js", "src/page_manager.js", "src/remove_link_interstitial.js", "src/image_preloading.js", "src/whats_new.js", "src/send_image.js", "src/main.js" ] } ; ppixiv.resources["src/actions.js"] = `"use strict"; // Global actions. ppixiv.actions = class { // Set a bookmark. Any existing bookmark will be overwritten. static async _bookmark_add_internal(illust_id, options) { let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(illust_id); if(options == null) options = {}; console.log("Add bookmark:", options); // If auto-like is enabled, like an image when we bookmark it. if(!options.disable_auto_like) { console.log("Automatically liking image with bookmark"); actions.like_image(illust_id, true /* quiet */); } // Remember whether this is a new bookmark or an edit. var was_bookmarked = illust_info.bookmarkData != null; var request = { "illust_id": illust_id, "tags": options.tags || [], "restrict": options.private? 1:0, } var result = await helpers.post_request("/ajax/illusts/bookmarks/add", request); // If this is a new bookmark, last_bookmark_id is the new bookmark ID. // If we're editing an existing bookmark, last_bookmark_id is null and the // bookmark ID doesn't change. var new_bookmark_id = result.body.last_bookmark_id; if(new_bookmark_id == null) new_bookmark_id = illust_info.bookmarkData? illust_info.bookmarkData.id:null; if(new_bookmark_id == null) throw "Didn't get a bookmark ID"; // Store the ID of the new bookmark, so the unbookmark button works. thumbnail_data.singleton().update_illust_data(illust_id, { bookmarkData: { id: new_bookmark_id, private: !!request.restrict, }, }); // Even if we weren't given tags, we still know that they're unset, so set tags so // we won't need to request bookmark details later. image_data.singleton().update_cached_bookmark_image_tags(illust_id, request.tags); console.log("Updated bookmark data:", illust_id, new_bookmark_id, request.restrict, request.tags); if(!was_bookmarked) { // If we have full illust data loaded, increase its bookmark count locally. let full_illust_info = image_data.singleton().get_image_info_sync(illust_id); if(full_illust_info) full_illust_info.bookmarkCount++; } message_widget.singleton.show( was_bookmarked? "Bookmark edited": options.private? "Bookmarked privately":"Bookmarked"); image_data.singleton().call_illust_modified_callbacks(illust_id); } // Create or edit a bookmark. // // Create or edit a bookmark. options can contain any of the fields tags or private. // Fields that aren't specified will be left unchanged on an existing bookmark. // // This is a headache. Pixiv only has APIs to create a new bookmark (overwriting all // existing data), except for public/private which can be changed in-place, and we need // to do an extra request to retrieve the tag list if we need it. We try to avoid // making the extra bookmark details request if possible. static async bookmark_add(illust_id, options) { if(options == null) options = {}; let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(illust_id); console.log("Add bookmark for", illust_id, "options:", options); // This is a mess, since Pixiv's APIs are all over the place. // // If the image isn't already bookmarked, just use bookmark_add. if(illust_info.bookmarkData == null) { console.log("Initial bookmark"); if(options.tags != null) helpers.update_recent_bookmark_tags(options.tags); return await actions._bookmark_add_internal(illust_id, options); } // Special case: If we're not setting anything, then we just want this image to // be bookmarked. Since it is, just stop. if(options.tags == null && options.private == null) { console.log("Already bookmarked"); return; } // Special case: If all we're changing is the private flag, use bookmark_set_private // so we don't fetch bookmark details. if(options.tags == null && options.private != null) { // If the image is already bookmarked, use bookmark_set_private to edit the // existing bookmark. This won't auto-like. console.log("Only editing private field", options.private); return await actions.bookmark_set_private(illust_id, options.private); } // If we're modifying tags, we need bookmark details loaded, so we can preserve // the current privacy status. This will insert the info into illust_info.bookmarkData. let bookmark_tags = await image_data.singleton().load_bookmark_details(illust_id); var bookmark_params = { // Don't auto-like if we're editing an existing bookmark. disable_auto_like: true, }; if("private" in options) bookmark_params.private = options.private; else bookmark_params.private = illust_info.bookmarkData.private; if("tags" in options) bookmark_params.tags = options.tags; else bookmark_params.tags = bookmark_tags; // Only update recent tags if we're modifying tags. if(options.tags != null) { // Only add new tags to recent tags. If a bookmark has tags "a b" and is being // changed to "a b c", only add "c" to recently-used tags, so we don't bump tags // that aren't changing. for(var tag of options.tags) { var is_new_tag = bookmark_tags.indexOf(tag) == -1; if(is_new_tag) helpers.update_recent_bookmark_tags([tag]); } } return await actions._bookmark_add_internal(illust_id, bookmark_params); } static async bookmark_remove(illust_id) { let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(illust_id); if(illust_info.bookmarkData == null) { console.log("Not bookmarked"); return; } var bookmark_id = illust_info.bookmarkData.id; console.log("Remove bookmark", bookmark_id); var result = await helpers.post_request("/ajax/illusts/bookmarks/remove", { bookmarkIds: [bookmark_id], }); console.log("Removing bookmark finished"); thumbnail_data.singleton().update_illust_data(illust_id, { bookmarkData: null }); // If we have full image data loaded, update the like count locally. let illust_data = image_data.singleton().get_image_info_sync(illust_id); if(illust_data) { illust_data.bookmarkCount--; image_data.singleton().call_illust_modified_callbacks(illust_id); } image_data.singleton().update_cached_bookmark_image_tags(illust_id, null); message_widget.singleton.show("Bookmark removed"); image_data.singleton().call_illust_modified_callbacks(illust_id); } // Change an existing bookmark to public or private. static async bookmark_set_private(illust_id, private_bookmark) { let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(illust_id); if(!illust_info.bookmarkData) { console.log(\`Illust \${illust_id} wasn't bookmarked\`); return; } let bookmark_id = illust_info.bookmarkData.id; let result = await helpers.post_request("/ajax/illusts/bookmarks/edit_restrict", { bookmarkIds: [bookmark_id], bookmarkRestrict: private_bookmark? "private":"public", }); // Update bookmark info. thumbnail_data.singleton().update_illust_data(illust_id, { bookmarkData: { id: bookmark_id, private: private_bookmark, }, }); message_widget.singleton.show(private_bookmark? "Bookmarked privately":"Bookmarked"); image_data.singleton().call_illust_modified_callbacks(illust_id); } // Show a prompt to enter tags, so the user can add tags that aren't already in the // list. Add the bookmarks to recents, and bookmark the image with the entered tags. static async add_new_tag(illust_id) { let illust_data = await image_data.singleton().get_image_info(illust_id); console.log("Show tag prompt"); // Hide the popup when we show the prompt. this.hide_temporarily = true; var prompt = new text_prompt({}); try { var tags = await prompt.result; } catch(e) { // The user cancelled the prompt. return; } // Split the new tags. tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); console.log("New tags:", tags); // This should already be loaded, since the only way to open this prompt is // in the tag dropdown. let bookmark_tags = await image_data.singleton().load_bookmark_details(illust_data.illustId); // Add each tag the user entered to the tag list to update it. let active_tags = [...bookmark_tags]; for(let tag of tags) { if(active_tags.indexOf(tag) != -1) continue; // Add this tag to recents. bookmark_add will add recents too, but this makes sure // that we add all explicitly entered tags to recents, since bookmark_add will only // add tags that are new to the image. helpers.update_recent_bookmark_tags([tag]); active_tags.push(tag); } console.log("All tags:", active_tags); // Edit the bookmark. await actions.bookmark_add(illust_id, { tags: active_tags, }); } // If quiet is true, don't print any messages. static async like_image(illust_id, quiet) { console.log("Clicked like on", illust_id); if(image_data.singleton().get_liked_recently(illust_id)) { if(!quiet) message_widget.singleton.show("Already liked this image"); return; } var result = await helpers.post_request("/ajax/illusts/like", { "illust_id": illust_id, }); // If is_liked is true, we already liked the image, so this had no effect. let was_already_liked = result.body.is_liked; // Remember that we liked this image recently. image_data.singleton().add_liked_recently(illust_id); // If we have illust data, increase the like count locally. Don't load it // if it's not loaded already. let illust_data = image_data.singleton().get_image_info_sync(illust_id); if(!was_already_liked && illust_data) { illust_data.likeCount++; image_data.singleton().call_illust_modified_callbacks(illust_id); } if(!quiet) { if(was_already_liked) message_widget.singleton.show("Already liked this image"); else message_widget.singleton.show("Illustration liked"); } } static async follow(user_id, follow_privately, tags) { var result = await helpers.rpc_post_request("/bookmark_add.php", { mode: "add", type: "user", user_id: user_id, tag: tags, restrict: follow_privately? 1:0, format: "json", }); // This doesn't return any data. Record that we're following and refresh the UI. let user_data = await image_data.singleton().get_user_info(user_id); user_data.isFollowed = true; image_data.singleton().call_user_modified_callbacks(user_data.userId); var message = "Followed " + user_data.name; if(follow_privately) message += " privately"; message_widget.singleton.show(message); } static async unfollow(user_id) { var result = await helpers.rpc_post_request("/rpc_group_setting.php", { mode: "del", type: "bookuser", id: user_id, }); // Record that we're no longer following and refresh the UI. let user_data = await image_data.singleton().get_user_info(user_id); user_data.isFollowed = false; image_data.singleton().call_user_modified_callbacks(user_data.userId); message_widget.singleton.show("Unfollowed " + user_data.name); } // Image downloading // // Download illust_data. static async download_illust(illust_id, progress_bar_controller, download_type, manga_page) { let illust_data = await image_data.singleton().get_image_info(illust_id, { load_user_info: true }); let user_info = await image_data.singleton().get_user_info(illust_data.userId); console.log("Download", illust_id, "with type", download_type); if(download_type == "MKV") { new ugoira_downloader_mjpeg(illust_data, progress_bar_controller); return; } if(download_type != "image" && download_type != "ZIP") { console.error("Unknown download type " + download_type); return; } // If we're in ZIP mode, download all images in the post. // // Pixiv's host for images changed from i.pximg.net to i-cf.pximg.net. This will fail currently for that // host, since it's not in @connect, and adding that will prompt everyone for permission. Work around that // by replacing i-cf.pixiv.net with i.pixiv.net, since that host still works fine. This only affects downloads. var images = []; for(var page of illust_data.mangaPages) { let url = page.urls.original; url = url.replace(/:\\/\\/i-cf.pximg.net/, "://i.pximg.net"); images.push(url); } // If we're in image mode for a manga post, only download the requested page. if(download_type == "image") images = [images[manga_page]]; let results = await helpers.download_urls(images); // If there's just one image, save it directly. if(images.length == 1) { var url = images[0]; var buf = results[0]; var blob = new Blob([results[0]]); var ext = helpers.get_extension(url); var filename = user_info.name + " - " + illust_data.illustId; // If this is a single page of a manga post, include the page number. if(download_type == "image" && illust_data.mangaPages.length > 1) filename += " #" + (manga_page + 1); filename += " - " + illust_data.illustTitle + "." + ext; helpers.save_blob(blob, filename); return; } // There are multiple images, and since browsers are stuck in their own little world, there's // still no way in 2018 to save a batch of files to disk, so ZIP the images. var filenames = []; for(var i = 0; i < images.length; ++i) { var url = images[i]; var blob = results[i]; var ext = helpers.get_extension(url); var filename = i.toString().padStart(3, '0') + "." + ext; filenames.push(filename); } // Create the ZIP. var zip = new create_zip(filenames, results); var filename = user_info.name + " - " + illust_data.illustId + " - " + illust_data.illustTitle + ".zip"; helpers.save_blob(zip, filename); } static is_download_type_available(download_type, illust_data) { // Single image downloading works for single images and manga pages. if(download_type == "image") return illust_data.illustType != 2; // ZIP downloading only makes sense for image sequences. if(download_type == "ZIP") return illust_data.illustType != 2 && illust_data.pageCount > 1; // MJPEG only makes sense for videos. if(download_type == "MKV") return illust_data.illustType == 2; throw "Unknown download type " + download_type; }; static get_download_type_for_image(illust_data) { var download_types = ["image", "ZIP", "MKV"]; for(var type of download_types) if(actions.is_download_type_available(type, illust_data)) return type; return null; } static async load_recent_bookmark_tags() { let url = "https://www.pixiv.net/ajax/user/" + window.global_data.user_id + "/illusts/bookmark/tags"; let result = await helpers.get_request(url, {}); let bookmark_tags = []; let add_tag = (tag) => { // Ignore "untagged". if(tag.tag == "未分類") return; if(bookmark_tags.indexOf(tag.tag) == -1) bookmark_tags.push(tag.tag); } for(let tag of result.body.public) add_tag(tag); for(let tag of result.body.private) add_tag(tag); return bookmark_tags; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/actions.js `; ppixiv.resources["src/muting.js"] = `"use strict"; // This handles querying whether a tag or a user is muted. We don't handle // editing this list currently. ppixiv.muting = class { static get singleton() { if(muting._singleton == null) muting._singleton = new muting(); return muting._singleton; }; constructor() { } set_muted_tags(muted_tags) { this.muted_tags = muted_tags; } set_muted_user_ids(muted_user_ids) { this.muted_user_ids = muted_user_ids; } is_muted_user_id(user_id) { return this.muted_user_ids.indexOf(user_id) != -1; }; // Return true if any tag in tag_list is muted. any_tag_muted(tag_list) { for(var tag of tag_list) { if(tag.tag) tag = tag.tag; if(this.muted_tags.indexOf(tag) != -1) return tag; } return null; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/muting.js `; ppixiv.resources["src/crc32.js"] = `"use strict"; /* pako/lib/zlib/crc32.js, MIT license: https://github.com/nodeca/pako/ */ ppixiv.crc32 = (function() { // Use ordinary array, since untyped makes no boost here function makeTable() { var c, table = []; for(var n =0; n < 256; n++){ c = n; for(var k =0; k < 8; k++){ c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); } table[n] = c; } return table; } // Create table on load. Just 255 signed longs. Not a problem. var crcTable = makeTable(); return function(buf) { var crc = 0; var t = crcTable, end = buf.length; crc = crc ^ (-1); for (var i = 0; i < end; i++ ) { crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF]; } return (crc ^ (-1)); // >>> 0; }; })(); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/crc32.js `; ppixiv.resources["src/helpers.js"] = `"use strict"; // This is thrown when an XHR request fails. ppixiv.APIError = class extends Error { constructor(message, url) { super(message); this.url = url; } }; // This is thrown when we disable creating blocked elements. ppixiv.ElementDisabled = class extends Error { }; ppixiv.helpers = { blank_image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", remove_array_element: function(array, element) { let idx = array.indexOf(element); if(idx != -1) array.splice(idx, 1); }, // Preload an array of images. preload_images: function(images) { // We don't need to add the element to the document for the images to load, which means // we don't need to do a bunch of extra work to figure out when we can remove them. var preload = document.createElement("div"); for(var i = 0; i < images.length; ++i) { var img = document.createElement("img"); img.src = images[i]; preload.appendChild(img); } }, move_children: function(parent, new_parent) { for(var child = parent.firstChild; child; ) { var next = child.nextSibling; new_parent.appendChild(child); child = next; } }, remove_elements: function(parent) { while(parent.firstChild !== null) parent.firstChild.remove(); }, // Return true if ancestor is one of descendant's parents, or if descendant is ancestor. is_above(ancestor, descendant) { var node = descendant; while(descendant != null && descendant != ancestor) descendant = descendant.parentNode; return descendant == ancestor; }, create_style: function(css) { var style = document.realCreateElement("style"); style.type = "text/css"; style.textContent = css; return style; }, get_template: function(type) { let template = document.body.querySelector(type); if(template == null) throw "Missing template: " + type; // Replace any inlines on the template, and remember that // we've done this so we don't redo it every time the template is used. if(!template.dataset.replacedInlines) { template.dataset.replacedInlines = true; helpers.replace_inlines(template.content); } return template; }, create_from_template: function(type) { var template; if(typeof(type) == "string") template = this.get_template(type); else template = type; var node = document.importNode(template.content, true).firstElementChild; // Make all IDs in the template we just cloned unique. for(var svg of node.querySelectorAll("svg")) helpers.make_svg_ids_unique(svg); return node; }, // Find elements inside root, and replace them with elements // from resources: // // // // Also replace with resource text. This is used for images. _resource_cache: {}, replace_inlines(root) { for(let element of root.querySelectorAll("img")) { let src = element.getAttribute("src"); if(!src || !src.startsWith("ppixiv:")) continue; let name = src.substr(7); let resource = ppixiv.resources[name]; if(resource == null) { console.error("Unknown resource \\"" + name + "\\" in", element); continue; } element.setAttribute("src", resource); // Put the original URL on the element for diagnostics. element.dataset.originalUrl = src; } for(let element of root.querySelectorAll("ppixiv-inline")) { let src = element.getAttribute("src"); // Import the cached node to make a copy, then replace the element // with it. let node = this.create_ppixiv_inline(src); element.replaceWith(node); // Copy attributes from the node to the newly created node which // is replacing it. This can be used for simple things, like setting the id. for(let attr of element.attributes) { if(attr.name == "src") continue; if(node.hasAttribute(attr.name)) { console.error("Node", node, "already has attribute", attr); continue; } node.setAttribute(attr.name, attr.value); } } }, create_ppixiv_inline(src) { // Parse this element if we haven't done so yet. if(!helpers._resource_cache[src]) { // Find the resource. let resource = resources[src]; if(resource == null) { console.error(\`Unknown resource \${src}\`); return null; } // resource is HTML. Parse it by adding it to a
. let div = document.createElement("div"); div.innerHTML = resource; let node = div.firstElementChild; node.remove(); // Stash the source path on the node. This is just for debugging to make // it easy to tell where things came from. node.dataset.ppixivResource = src; // Cache the result, so we don't re-parse the node every time we create one. helpers._resource_cache[src] = node; } let node = helpers._resource_cache[src]; return document.importNode(node, true); }, // SVG has a big problem: it uses IDs to reference its internal assets, and that // breaks if you inline the same SVG more than once in a while. Making them unique // at build time doesn't help, since they break again as soon as you clone a template. // This makes styling SVGs a nightmare, since you can only style inlined SVGs. // // doesn't help, since that's just broken with masks and gradients entirely. // Broken for over a decade and nobody cares: https://bugzilla.mozilla.org/show_bug.cgi?id=353575 // // This seems like a basic feature of SVG, and it's just broken. // // Work around it by making IDs within SVGs unique at runtime. This is called whenever // we clone SVGs. _svg_id_sequence: 0, make_svg_ids_unique(svg) { let id_map = {}; let idx = helpers._svg_id_sequence; // First, find all IDs in the SVG and change them to something unique. for(let def of svg.querySelectorAll("[id]")) { let old_id = def.id; let new_id = def.id + "_" + idx; idx++; id_map[old_id] = new_id; def.id = new_id; } // Search for all URL references within the SVG and point them at the new IDs. for(let node of svg.querySelectorAll("*")) { for(let attr of node.getAttributeNames()) { let value = node.getAttribute(attr); let new_value = value; // See if this is an ID reference. We don't try to parse all valid URLs // here. Handle url(#abcd) inside strings, and things like xlink:xref="#abcd". if(attr == "xlink:href" && value.startsWith("#")) { let old_id = value.substr(1); let new_id = id_map[old_id]; if(new_id == null) { console.warn("Unmatched SVG ID:", old_id); continue; } new_value = "#" + new_id; } var re = /url\\(#.*?\\)/; new_value = new_value.replace(re, (str) => { var re = /url\\(#(.*)\\)/; var old_id = str.match(re)[1]; let new_id = id_map[old_id]; if(new_id == null) { console.warn("Unmatched SVG ID:", old_id); return str; } // Replace the ID. return "url(#" + new_id + ")"; }); if(new_value != value) node.setAttribute(attr, new_value); } } // Store the index, so the next call will start with the next value. helpers._svg_id_sequence = idx; }, // Prompt to save a blob to disk. For some reason, the really basic FileSaver API disappeared from // the web. save_blob: function(blob, filename) { var blobUrl = URL.createObjectURL(blob); var a = document.createElement("a"); a.hidden = true; document.body.appendChild(a); a.href = blobUrl; a.download = filename; a.click(); // Clean up. // // If we revoke the URL now, or with a small timeout, Firefox sometimes just doesn't show // the save dialog, and there's no way to know when we can, so just use a large timeout. setTimeout(function() { window.URL.revokeObjectURL(blobUrl); a.parentNode.removeChild(a); }.bind(this), 1000); }, // Return a Uint8Array containing a blank (black) image with the given dimensions and type. create_blank_image: function(image_type, width, height) { var canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; var context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height); var blank_frame = canvas.toDataURL(image_type, 1); if(!blank_frame.startsWith("data:" + image_type)) throw "This browser doesn't support encoding " + image_type; var binary = atob(blank_frame.slice(13 + image_type.length)); // This is completely stupid. Why is there no good way to go from a data URL to an ArrayBuffer? var array = new Uint8Array(binary.length); for(var i = 0; i < binary.length; ++i) array[i] = binary.charCodeAt(i); return array; }, // Run func from the event loop. // // This is like setTimeout(func, 0), but avoids problems with setTimeout // throttling. yield(func) { return Promise.resolve().then(() => { func(); }); }, sleep(ms) { return new Promise((accept, reject) => { setTimeout(() => { accept(); }, ms); }); }, // setTimeout using an AbortSignal to remove the timer. timeout(callback, ms, signal) { if(signal && signal.aborted) return; let id = setTimeout(callback, ms); if(signal) { // Clear the interval when the signal is aborted. signal.addEventListener("abort", () => { clearTimeout(id); }, { once: true }); } }, // setInterval using an AbortSignal to remove the interval. // // If call_immediately is true, call callback() now, rather than waiting // for the first interval. interval(callback, ms, signal, call_immediately=true) { if(signal && signal.aborted) return; let id = setInterval(callback, ms); if(signal) { // Clear the interval when the signal is aborted. signal.addEventListener("abort", () => { clearInterval(id); }, { once: true }); } if(call_immediately) callback(); }, // A convenience wrapper for setTimeout: timer: class { constructor(func) { this.func = func; } run_func = () => { this.func(); } clear() { if(this.id == null) return; clearTimeout(this.id); this.id = null; } set(ms) { this.clear(); this.id = setTimeout(this.run_func, ms); } }, // Block until DOMContentLoaded. wait_for_content_loaded: function() { return new Promise((accept, reject) => { if(document.readyState != "loading") { accept(); return; } window.addEventListener("DOMContentLoaded", (e) => { accept(); }, { capture: true, once: true, }); }); }, // Try to stop the underlying page from doing things (it just creates unnecessary network // requests and spams errors to the console), and undo damage to the environment that it // might have done before we were able to start. cleanup_environment: function() { // Newer Pixiv pages run a bunch of stuff from deferred scripts, which install a bunch of // nastiness (like searching for installed polyfills--which we install--and adding wrappers // around them). Break this by defining a webpackJsonp property that can't be set. It // won't stop the page from running everything, but it keeps it from getting far enough // for the weirder scripts to run. // // Also, some Pixiv pages set an onerror to report errors. Disable it if it's there, // so it doesn't send errors caused by this script. Remove _send and _time, which // also send logs. It might have already been set (TamperMonkey in Chrome doesn't // implement run-at: document-start correctly), so clear it if it's there. for(let key of ["onerror", "onunhandledrejection", "_send", "_time", "webpackJsonp"]) { unsafeWindow[key] = null; // Use an empty setter instead of writable: false, so errors aren't triggered all the time. Object.defineProperty(unsafeWindow, key, { get: exportFunction(function() { return null; }, unsafeWindow), set: exportFunction(function(value) { }, unsafeWindow), }); } // Try to unwrap functions that might have been wrapped by page scripts. function unwrap_func(obj, name) { // Both prototypes and instances might be wrapped. If this is an instance, look // at the prototype to find the original. let orig_func = obj.__proto__[name]? obj.__proto__[name]:obj[name]; if(!orig_func) { console.log("Couldn't find function to unwrap:", name); return; } if(!orig_func.__sentry_original__) return; while(orig_func.__sentry_original__) orig_func = orig_func.__sentry_original__; obj[name] = orig_func; } try { unwrap_func(document, "addEventListener"); unwrap_func(document, "removeEventListener"); unwrap_func(unsafeWindow, "fetch"); unwrap_func(unsafeWindow, "setTimeout"); unwrap_func(unsafeWindow, "setInterval"); unwrap_func(unsafeWindow, "clearInterval"); unwrap_func(EventTarget.prototype, "addEventListener"); unwrap_func(EventTarget.prototype, "removeEventListener"); unwrap_func(XMLHttpRequest.prototype, "send"); // We might get here before the mangling happens, which means it might happen // in the future. Freeze the objects to prevent this. Object.freeze(EventTarget.prototype); // Remove Pixiv's wrappers from console.log, etc., and then apply our own to console.error // to silence its error spam. This will cause all error messages out of console.error // to come from this line, which is usually terrible, but our logs come from window.console // and not unsafeWindow.console, so this doesn't affect us. for(let func in window.console) unsafeWindow.console[func] = window.console[func]; Object.freeze(unsafeWindow.console); // Some Pixiv pages load jQuery and spam a bunch of error due to us stopping // their scripts. Try to replace jQuery's exception hook with an empty one to // silence these. This won't work if jQuery finishes loading after we do, but // that's not currently happening, so this is all we do for now. if("jQuery" in unsafeWindow) jQuery.Deferred.exceptionHook = () => { }; } catch(e) { console.error("Error unwrapping environment", e); } // Try to kill the React scheduler that Pixiv uses. It uses a MessageChannel to run itself, // so we can disable it by disabling MessagePort.postmessage. This seems to happen early // enough to prevent the first scheduler post from happening. // // Store the real postMessage, so we can still use it ourself. try { unsafeWindow.MessagePort.prototype.realPostMessage = unsafeWindow.MessagePort.prototype.postMessage; unsafeWindow.MessagePort.prototype.postMessage = (msg) => { }; } catch(e) { console.error("Error disabling postMessage", e); } // TamperMonkey reimplements setTimeout, etc. for some reason, which is slower // than the real versions. Grab them instead. ppixiv.setTimeout = unsafeWindow.setTimeout.bind(unsafeWindow); ppixiv.setInterval = unsafeWindow.setInterval.bind(unsafeWindow); ppixiv.clearTimeout = unsafeWindow.clearTimeout.bind(unsafeWindow); ppixiv.clearInterval = unsafeWindow.clearInterval.bind(unsafeWindow); // Disable the page's timers. This helps prevent things like GTM from running. unsafeWindow.setTimeout = (f, ms) => { return -1; }; unsafeWindow.setInterval = (f, ms) => { return -1; }; unsafeWindow.clearTimeout = () => { }; window.addEventListener = Window.prototype.addEventListener.bind(unsafeWindow); window.removeEventListener = Window.prototype.removeEventListener.bind(unsafeWindow); // Try to freeze the document. This works in Chrome but not Firefox. try { Object.freeze(document); } catch(e) { console.error("Error unwrapping environment", e); } // We have to use unsafeWindow.fetch in Firefox, since window.fetch is from a different // context and won't send requests with the site's origin, which breaks everything. In // Chrome it doesn't matter. helpers.fetch = unsafeWindow.fetch; unsafeWindow.Image = exportFunction(function() { }, unsafeWindow); // Replace window.fetch with a dummy to prevent some requests from happening. class dummy_fetch { sent() { return this; } }; dummy_fetch.prototype.ok = true; unsafeWindow.fetch = exportFunction(function() { return new dummy_fetch(); }, unsafeWindow); unsafeWindow.XMLHttpRequest = exportFunction(function() { }, exportFunction); // Similarly, prevent it from creating script and style elements. Sometimes site scripts that // we can't disable keep running and do things like loading more scripts or adding stylesheets. // Use realCreateElement to bypass this. let origCreateElement = unsafeWindow.HTMLDocument.prototype.createElement; unsafeWindow.HTMLDocument.prototype.realCreateElement = unsafeWindow.HTMLDocument.prototype.createElement; unsafeWindow.HTMLDocument.prototype.createElement = function(type, options) { // Prevent the underlying site from creating new script and style elements. if(type == "script" || type == "style") { // console.warn("Disabling createElement " + type); throw new ElementDisabled("Element disabled"); } return origCreateElement.apply(this, arguments); }; // Catch and discard ElementDisabled. // // This is crazy: the error event doesn't actually receive the unhandled exception. // We have to examine the message to guess whether an error is ours. unsafeWindow.addEventListener("error", (e) => { if(e.message && e.message.indexOf("Element disabled") == -1) return; e.preventDefault(); e.stopPropagation(); }, true); // We have to hit things with a hammer to get Pixiv's scripts to stop running, which // causes a lot of errors. Silence all errors that have a stack within Pixiv's sources, // as well as any errors from ElementDisabled. window.addEventListener("error", (e) => { let silence_error = false; if(e.filename && e.filename.indexOf("s.pximg.net") != -1) silence_error = true; if(silence_error) { e.preventDefault(); e.stopImmediatePropagation(); return; } }, true); window.addEventListener("unhandledrejection", (e) => { let silence_error = false; if(e.reason && e.reason.stack && e.reason.stack.indexOf("s.pximg.net") != -1) silence_error = true; if(e.reason && e.reason.message == "Element disabled") silence_error = true; if(silence_error) { e.preventDefault(); e.stopImmediatePropagation(); return; } }, true); }, add_style: function(name, css) { let style = helpers.create_style(css); style.id = name; document.querySelector("head").appendChild(style); return style; }, // Create a node from HTML. create_node: function(html) { var temp = document.createElement("div"); temp.innerHTML = html; return temp.firstElementChild; }, // Set or unset a class. set_class: function(element, className, enable) { if(element.classList.contains(className) == enable) return; if(enable) element.classList.add(className); else element.classList.remove(className); }, // dataset is another web API with nasty traps: if you assign false or null to // it, it assigns "false" or "null", which are true values. set_dataset: function(dataset, name, enable) { if(enable) dataset[name] = 1; else delete dataset[name]; }, date_to_string: function(date) { var date = new Date(date); var day = date.toLocaleDateString(); var time = date.toLocaleTimeString(); return day + " " + time; }, age_to_string: function(seconds) { var to_plural = function(label, places, value) { var factor = Math.pow(10, places); var plural_value = Math.round(value * factor); if(plural_value > 1) label += "s"; return value.toFixed(places) + " " + label; }; if(seconds < 60) return to_plural("sec", 0, seconds); var minutes = seconds / 60; if(minutes < 60) return to_plural("min", 0, minutes); var hours = minutes / 60; if(hours < 24) return to_plural("hour", 0, hours); var days = hours / 24; if(days < 30) return to_plural("day", 0, days); var months = days / 30; if(months < 12) return to_plural("month", 0, months); var years = months / 12; return to_plural("year", 1, years); }, get_extension: function(fn) { var parts = fn.split("."); return parts[parts.length-1]; }, encode_query: function(data) { var str = []; for (var key in data) { if(!data.hasOwnProperty(key)) continue; str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key])); } return str.join("&"); }, send_request: function(options) { if(options == null) options = {}; // Usually we'll use helpers.fetch, but fall back on window.fetch in case we haven't // called block_network_requests yet. This happens if main_controller.setup needs // to fetch the page. let fetch = helpers.fetch || window.fetch; let data = { }; // For Firefox, we need to clone data into the page context. In Chrome this do nothing. data = cloneInto(data, window); data.method = options.method || "GET"; data.signal = options.signal; data.cache = options.cache; if(options.data) data.body = cloneInto(options.data, window); // Convert options.headers to a Headers object. For Firefox, this has to be // unsafeWindow.Headers. if(options.headers) { let headers = new unsafeWindow.Headers(); for(let key in options.headers) headers.append(key, options.headers[key]); data.headers = headers; } return fetch(options.url, data); }, // Send a request with the referer, cookie and CSRF token filled in. async send_pixiv_request(options) { if(options.headers == null) options.headers = {}; // Only set x-csrf-token for requests to www.pixiv.net. It's only needed for API // calls (not things like ugoira ZIPs), and the request will fail if we're in XHR // mode and set headers, since it'll trigger CORS. var hostname = new URL(options.url, ppixiv.location).hostname; if(hostname == "www.pixiv.net" && "global_data" in window) { options.headers["x-csrf-token"] = global_data.csrf_token; options.headers["x-user-id"] = global_data.user_id; } let result = await helpers.send_request(options); // Return the requested type. If we don't know the type, just return the // request promise itself. if(options.responseType == "json") { let json = await result.json(); // In Firefox we need to use unsafeWindow.fetch, since window.fetch won't run // as the page to get the correct referer. Work around secondary brain damage: // since it comes from the apge it's in a wrapper object that we need to remove. // We shouldn't be seeing Firefox wrapper behavior at all. It's there to // protect the user from us, not us from the page. if(json.wrappedJSObject) json = json.wrappedJSObject; return json; } if(options.responseType == "document") { let text = await result.text(); return new DOMParser().parseFromString(text, 'text/html'); } return result; }, // Why does Pixiv have 300 APIs? async rpc_post_request(url, data) { var result = await helpers.send_pixiv_request({ "method": "POST", "url": url, "data": helpers.encode_query(data), "responseType": "json", "headers": { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", }, }); return result; }, async rpc_get_request(url, data, options) { if(options == null) options = {}; var params = new URLSearchParams(); for(var key in data) params.set(key, data[key]); var query = params.toString(); if(query != "") url += "?" + query; var result = await helpers.send_pixiv_request({ "method": "GET", "url": url, "responseType": "json", "signal": options.signal, "headers": { "Accept": "application/json", }, }); return result; }, async post_request(url, data) { var result = await helpers.send_pixiv_request({ "method": "POST", "url": url, "responseType": "json", "data" :JSON.stringify(data), "headers": { "Accept": "application/json", "Content-Type": "application/json; charset=utf-8", }, }); return result; }, create_search_params(data) { let params = new URLSearchParams(); for(let key in data) { // If this is an array, add each entry separately. This is used by // /ajax/user/#/profile/illusts. let value = data[key]; if(Array.isArray(value)) { for(let item of value) params.append(key, item); } else params.append(key, value); } return params; }, async get_request(url, data, options) { let params = this.create_search_params(data); var query = params.toString(); if(query != "") url += "?" + query; if(options == null) options = {}; var result = await helpers.send_pixiv_request({ "method": "GET", "url": url, "responseType": "json", "signal": options.signal, "headers": { "Accept": "application/json", }, }); // If the result isn't valid JSON, we'll get a null result. if(result == null) result = { error: true, message: "Invalid response" }; return result; }, download_url: async function(url) { return new Promise((accept, reject) => { if(url == null) { accept(null); return; } // We use i-cf for image URLs, but we don't currently have this in @connect, // so we can't use that here. Switch from i-cf back to the original URLs. url = new URL(url); if(url.hostname == "i-cf.pximg.net") url.hostname = "i.pximg.net"; GM_xmlhttpRequest({ "method": "GET", "url": url, "responseType": "arraybuffer", "headers": { "Cache-Control": "max-age=360000", "Referer": "https://www.pixiv.net/", "Origin": "https://www.pixiv.net/", }, onload: (result) => { accept(result.response); }, onerror: (e) => { reject(e); }, }); }); }, download_urls: async function(urls) { let results = []; for(let url of urls) { let result = await helpers.download_url(url); results.push(result); } return results; }, // Load a page in an iframe, and call callback on the resulting document. // Remove the iframe when the callback returns. async load_data_in_iframe(url, options={}) { // If we're in Tampermonkey, we don't need any of the iframe hijinks and we can // simply make a request with responseType: document. This is much cleaner than // the Greasemonkey workaround below. return await helpers.send_pixiv_request({ method: "GET", url: url, responseType: "document", cache: options.cache, }); }, set_recent_bookmark_tags(tags) { settings.set("recent-bookmark-tags", JSON.stringify(tags)); }, get_recent_bookmark_tags() { var recent_bookmark_tags = settings.get("recent-bookmark-tags"); if(recent_bookmark_tags == null) return []; return JSON.parse(recent_bookmark_tags); }, // Move tag_list to the beginning of the recent tag list, and prune tags at the end. update_recent_bookmark_tags: function(tag_list) { // Move the tags we're using to the top of the recent bookmark tag list. var recent_bookmark_tags = helpers.get_recent_bookmark_tags(); for(var i = 0; i < tag_list.length; ++i) { var tag = tag_list[i]; var idx = recent_bookmark_tags.indexOf(tag_list[i]); if(idx != -1) recent_bookmark_tags.splice(idx, 1); } for(var i = 0; i < tag_list.length; ++i) recent_bookmark_tags.unshift(tag_list[i]); // Remove tags that haven't been used in a long time. recent_bookmark_tags.splice(20); helpers.set_recent_bookmark_tags(recent_bookmark_tags); }, // Add tag to the recent search list, or move it to the front. add_recent_search_tag(tag) { if(this._disable_adding_search_tags) return; var recent_tags = settings.get("recent-tag-searches") || []; var idx = recent_tags.indexOf(tag); if(idx != -1) recent_tags.splice(idx, 1); recent_tags.unshift(tag); // Trim the list. recent_tags.splice(50); settings.set("recent-tag-searches", recent_tags); window.dispatchEvent(new Event("recent-tag-searches-changed")); }, // This is a hack used by tag_search_box_widget to temporarily disable adding to history. disable_adding_search_tags(value) { this._disable_adding_search_tags = value; }, remove_recent_search_tag(tag) { // Remove tag from the list. There should normally only be one. var recent_tags = settings.get("recent-tag-searches") || []; while(1) { var idx = recent_tags.indexOf(tag); if(idx == -1) break; recent_tags.splice(idx, 1); } settings.set("recent-tag-searches", recent_tags); window.dispatchEvent(new Event("recent-tag-searches-changed")); }, // Split a tag search into individual tags. split_search_tags(search) { // Replace full-width spaces with regular spaces. Pixiv treats this as a delimiter. search = search.replace(" ", " "); return search.split(" "); }, // If a tag has a modifier, return [modifier, tag]. -tag seems to be the only one, so // we return ["-", "tag"]. split_tag_prefixes(tag) { if(tag[0] == "-") return ["-", tag.substr(1)]; else return ["", tag]; }, // If this is an older page (currently everything except illustrations), the CSRF token, // etc. are stored on an object called "pixiv". We aren't actually executing scripts, so // find the script block. get_pixiv_data(doc) { // Find all script elements that set pixiv.xxx. There are two of these, and we need // both of them. var init_elements = []; for(var element of doc.querySelectorAll("script")) { if(element.innerText == null) continue; if(!element.innerText.match(/pixiv.*(token|id) = /)) continue; init_elements.push(element); } if(init_elements.length < 1) return null; // Create a stub around the scripts to let them execute as if they're initializing the // original object. var init_script = ""; init_script += "(function() {"; init_script += "var pixiv = { config: {}, context: {}, user: {} }; "; for(var element of init_elements) init_script += element.innerText; init_script += "return pixiv;"; init_script += "})();"; return eval(init_script); }, // Return true if the given illust_data.tags contains the pixel art (ドット絵) tag. tags_contain_dot(illust_data) { for(let tag of illust_data.tagList) if(tag.indexOf("ドット") != -1) return true; return false; }, // Find all links to Pixiv pages, and set a #ppixiv anchor. // // This allows links to images in things like image descriptions to be loaded // internally without a page navigation. make_pixiv_links_internal(root) { for(var a of root.querySelectorAll("A")) { var url = new URL(a.href, ppixiv.location); if(url.hostname != "pixiv.net" && url.hostname != "www.pixiv.net" || url.hash != "") continue; url.hash = "#ppixiv"; a.href = url.toString(); } }, // Find the real link inside Pixiv's silly jump.php links. fix_pixiv_link: function(link) { // These can either be /jump.php?url or /jump.php?url=url. let url = new URL(link); if(url.pathname != "/jump.php") return link; if(url.searchParams.has("url")) return url.searchParams.get("url"); else { var target = url.search.substr(1); // remove "?" target = decodeURIComponent(target); return target; } }, fix_pixiv_links: function(root) { for(var a of root.querySelectorAll("A[target='_blank']")) a.target = ""; for(var a of root.querySelectorAll("A")) { if(a.relList == null) a.rel += " noreferrer noopener"; // stupid Edge else { a.relList.add("noreferrer"); a.relList.add("noopener"); } } for(var a of root.querySelectorAll("A[href*='jump.php']")) a.href = helpers.fix_pixiv_link(a.href); }, // Some of Pixiv's URLs have languages prefixed and some don't. Ignore these and remove // them to make them simpler to parse. get_url_without_language: function(url) { if(/^\\/..\\//.exec(url.pathname)) url.pathname = url.pathname.substr(3); return url; }, // From a URL like "/en/tags/abcd", return "tags". get_page_type_from_url: function(url) { url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); return parts[1]; }, set_page_title: function(title) { let title_element = document.querySelector("title"); if(title_element.textContent == title) return; title_element.textContent = title; document.dispatchEvent(new Event("windowtitlechanged")); }, set_page_icon: function(url) { document.querySelector("link[rel='icon']").href = url; }, // Get the search tags from an "/en/tags/TAG" search URL. _get_search_tags_from_url: function(url) { url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); // ["", "tags", tag string, "search type"] let tags = parts[2] || ""; return decodeURIComponent(tags); }, // Watch for clicks on links inside node. If a search link is clicked, add it to the // recent search list. add_clicks_to_search_history: function(node) { node.addEventListener("click", function(e) { if(e.defaultPrevented) return; if(e.target.tagName != "A") return; // Only look at "/tags/TAG" URLs. var url = new URL(e.target.href); url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); let first_part = parts[1]; if(first_part != "tags") return; let tag = helpers._get_search_tags_from_url(url); console.log("Adding to tag search history:", tag); helpers.add_recent_search_tag(tag); }); }, // Add a basic event handler for an input: // // - When enter is pressed, submit will be called. // - Event propagation will be stopped, so global hotkeys don't trigger. // // Note that other event handlers on the input will still be called. input_handler: function(input, submit) { input.addEventListener("keydown", function(e) { // Always stopPropagation, so inputs aren't handled by main input handling. e.stopPropagation(); if(e.keyCode == 13) // enter submit(e); }); }, // Parse the hash portion of our URL. For example, // // #ppixiv?a=1&b=2 // // returns { a: "1", b: "2" }. // // If this isn't one of our URLs, return null. parse_hash: function(url) { var ppixiv_url = url.hash.startsWith("#ppixiv"); if(!ppixiv_url) return null; // Parse the hash of the current page as a path. For example, if // the hash is #ppixiv/foo/bar?baz, parse it as /ppixiv/foo/bar?baz. var adjusted_url = url.hash.replace(/#/, "/"); return new URL(adjusted_url, url); }, get_hash_args: function(url) { var hash_url = helpers.parse_hash(url); if(hash_url == null) return new unsafeWindow.URLSearchParams(); var query = hash_url.search; if(!query.startsWith("?")) return new unsafeWindow.URLSearchParams(); query = query.substr(1); // Use unsafeWindow.URLSearchParams to work around https://bugzilla.mozilla.org/show_bug.cgi?id=1414602. var params = new unsafeWindow.URLSearchParams(query); return params; }, // Replace the given field in the URL path. // // If the path is "/a/b/c/d", "a" is 0 and "d" is 4. set_path_part: function(url, index, value) { url = new URL(url); // Split the path, and extend it if needed. let parts = url.pathname.split("/"); // The path always begins with a slash, so the first entry in parts is always empty. // Skip it. index++; // Hack: If this URL has a language prefixed, like "/en/users", add 1 to the index. This way // the caller doesn't need to check, since URLs can have these or omit them. if(parts.length > 1 && parts[1].length == 2) index++; // Extend the path if needed. while(parts.length < index) parts.push(""); parts[index] = value; // If the value is empty and this was the last path component, remove it. This way, we // remove the trailing slash from "/users/12345/". if(value == "" && parts.length == index+1) parts = parts.slice(0, index); url.pathname = parts.join("/"); return url; }, get_path_part: function(url, index, value) { // The path always begins with a slash, so the first entry in parts is always empty. // Skip it. index++; let parts = url.pathname.split("/"); if(parts.length > 1 && parts[1].length == 2) index++; return parts[index] || ""; }, // Given a URLSearchParams, return a new URLSearchParams with keys sorted alphabetically. sort_query_parameters(search) { var search_keys = unsafeWindow.Array.from(search.keys()); // GreaseMonkey encapsulation is bad search_keys.sort(); var result = new URLSearchParams(); for(var key of search_keys) result.set(key, search.get(key)); return result; }, // This is incremented whenever we navigate forwards, so we can tell in onpopstate // whether we're navigating forwards or backwards. current_history_state_index() { return (history.state && history.state.index != null)? history.state.index: 0; }, args: class { constructor(url) { url = new URL(url, ppixiv.location); this.path = url.pathname; this.query = url.searchParams; this.hash = helpers.get_hash_args(url); // History state is only available when we come from the current history state, // since URLs don't have state. this.state = { }; } // Return the args for the current page. static get location() { let result = new this(ppixiv.location); // Include history state as well. Make a deep copy, so changing this doesn't // modify history.state. result.state = JSON.parse(JSON.stringify(history.state)) || { }; return result; } get url() { let url = new URL(ppixiv.location); url.pathname = this.path; url.search = this.query.toString(); // Set the hash portion of url to args, as a ppixiv url. // // For example, given { a: "1", b: "2" }, set the hash to #ppixiv?a=1&b=2. url.hash = "#ppixiv"; let hash_string = this.hash.toString(); if(hash_string != "") url.hash += "?" + hash_string; return url; } }, // Set document.href, either adding or replacing the current history state. // // window.onpopstate will be synthesized if the URL is changing. // // If cause is set, it'll be included in the popstate event as navigationCause. // This can be used in event listeners to determine what caused a navigation. // For browser forwards/back, this won't be present. // // args can be a helpers.args object, or a URL object. set_page_url(args, add_to_history, cause) { if(args instanceof URL) args = new helpers.args(args); var old_url = ppixiv.location.toString(); // If the state wouldn't change at all, don't set it, so we don't add junk to // history if the same link is clicked repeatedly. Comparing state via JSON // is OK here since JS will maintain key order. if(args.url.toString() == old_url && JSON.stringify(args.state) == JSON.stringify(history.state)) return; // Use the history state from args if it exists. let history_data = { ...args.state }; // history.state.index is incremented whenever we navigate forwards, so we can // tell in onpopstate whether we're navigating forwards or backwards. history_data.index = helpers.current_history_state_index(); if(add_to_history) history_data.index++; // console.log("Changing state to", args.url.toString()); if(add_to_history) ppixiv.history.pushState(history_data, "", args.url.toString()); else ppixiv.history.replaceState(history_data, "", args.url.toString()); // Chrome is broken. After replacing state for a while, it starts logging // // "Throttling history state changes to prevent the browser from hanging." // // This is completely broken: it triggers with state changes no faster than the // user can move the mousewheel (much too sensitive), and it happens on replaceState // and not just pushState (which you should be able to call as fast as you want). // // People don't think things through. // console.log("Set URL to", ppixiv.location.toString(), add_to_history); if(ppixiv.location.toString() != old_url) { // Browsers don't send onpopstate for history changes, but we want them, so // send a synthetic one. // console.log("Dispatching popstate:", ppixiv.location.toString()); var event = new PopStateEvent("popstate"); // Set initialNavigation to true. This indicates that this event is for a new // navigation, and not from browser forwards/back. event.navigationCause = cause; window.dispatchEvent(event); } }, setup_popups(container, selectors) { var setup_popup = function(box) { box.addEventListener("mouseover", function(e) { helpers.set_class(box, "popup-visible", true); }); box.addEventListener("mouseout", function(e) { helpers.set_class(box, "popup-visible", false); }); } for(var selector of selectors) { var box = container.querySelector(selector); if(box == null) { console.warn("Couldn't find", selector); continue; } setup_popup(box); } }, // Return the offset of element relative to an ancestor. get_relative_pos(element, ancestor) { var x = 0, y = 0; while(element != null && element != ancestor) { x += element.offsetLeft; y += element.offsetTop; // Advance through parents until we reach the offsetParent or the ancestor // that we're stopping at. We do this rather than advancing to offsetParent, // in case ancestor isn't an offsetParent. var search_for = element.offsetParent; while(element != ancestor && element != search_for) element = element.parentNode; } return [x, y]; }, clamp(value, min, max) { return Math.min(Math.max(value, min), max); }, distance([x1,y1], [x2,y2]) { let distance = Math.pow(x1-x2, 2) + Math.pow(y1-y2, 2); return Math.pow(distance, 0.5); }, // Return a promise that waits for img to load. // // If img loads successfully, resolve with null. If abort_signal is aborted, // resolve with "aborted". Otherwise, reject with "failed". This never // rejects. // // If we're aborted, img.src will be set to helpers.blank_image. Otherwise, // the image will load anyway. This is a little invasive, but it's what we // need to do any time we have a cancellable image load, so we might as well // do it in one place. wait_for_image_load(img, abort_signal) { return new Promise((resolve, reject) => { let src = img.src; // Resolve immediately if the image is already loaded. if(img.complete) { resolve(null); return; } if(abort_signal && abort_signal.aborted) { img.src = helpers.blank_image; resolve("aborted"); return; } // Cancelling this controller will remove all of our event listeners. let remove_listeners_signal = new AbortController(); img.addEventListener("error", (e) => { // We kept a reference to src in case in changes, so this log should // always point to the right URL. console.log("Error loading image:", src); remove_listeners_signal.abort(); resolve("failed"); }, { signal: remove_listeners_signal.signal }); img.addEventListener("load", (e) => { remove_listeners_signal.abort(); resolve(null); }, { signal: remove_listeners_signal.signal }); if(abort_signal) { abort_signal.addEventListener("abort",(e) => { img.src = helpers.blank_image; remove_listeners_signal.abort(); resolve("aborted"); }, { signal: remove_listeners_signal.signal }); } }); }, // Wait until img.naturalWidth/naturalHeight are available. // // There's no event to tell us that img.naturalWidth/naturalHeight are // available, so we have to jump hoops. Loop using requestAnimationFrame, // since this lets us check quickly at a rate that makes sense for the // user's system, and won't be throttled as badly as setTimeout. async wait_for_image_dimensions(img, abort_signal) { return new Promise((resolve, reject) => { let src = img.src; if(abort_signal && abort_signal.aborted) resolve(false); if(img.naturalWidth != 0) resolve(true); let frame_id = null; // If abort_signal is aborted, cancel our frame request. let abort = () => { abort_signal.removeEventListener("aborted", abort); if(frame_id != null) cancelAnimationFrame(frame_id); resolve(false); }; if(abort_signal) abort_signal.addEventListener("aborted", abort); let check = () => { if(img.naturalWidth != 0) { resolve(true); if(abort_signal) abort_signal.removeEventListener("aborted", abort); return; } frame_id = requestAnimationFrame(check); }; check(); }); }, // Wait up to ms for promise to complete. If the promise completes, return its // result, otherwise return "timed-out". async await_with_timeout(promise, ms) { let sleep = new Promise((accept, reject) => { setTimeout(() => { accept("timed-out"); }, ms); }); // Wait for whichever finishes first. return await Promise.any([promise, sleep]); }, // Return a CSS style to specify thumbnail resolutions. // // Based on the dimensions of the container and a desired pixel size of thumbnails, // figure out how many columns to display to bring us as close as possible to the // desired size. // // container is the containing block (eg. ul.thumbnails). // top_selector is a CSS selector for the thumbnail block. We should be able to // simply create a scoped stylesheet, but browsers don't understand the importance // of encapsulation. make_thumbnail_sizing_style(container, top_selector, options) { // The total pixel size we want each thumbnail to have: var desired_size = options.size || 300; var ratio = options.ratio || 1; var max_columns = options.max_columns || 5; var desired_pixels = desired_size*desired_size / window.devicePixelRatio; var container_width = container.parentNode.clientWidth; var padding = container_width / 100; padding = Math.min(padding, 10); padding = Math.round(padding); if(options.min_padding) padding = Math.max(padding, options.min_padding); var closest_error_to_desired_pixels = -1; var best_size = [0,0]; var best_columns = 0; for(var columns = max_columns; columns >= 1; --columns) { // The amount of space in the container remaining for images, after subtracting // the padding around each image. var remaining_width = container_width - padding*columns*2; var max_width = remaining_width / columns; var max_height = max_width; if(ratio < 1) max_width *= ratio; else if(ratio > 1) max_height /= ratio; max_width = Math.floor(max_width); max_height = Math.floor(max_height); var pixels = max_width * max_height; var error = Math.abs(pixels - desired_pixels); if(closest_error_to_desired_pixels == -1 || error < closest_error_to_desired_pixels) { closest_error_to_desired_pixels = error; best_size = [max_width, max_height]; best_columns = columns; } } max_width = best_size[0]; max_height = best_size[1]; // If we want a smaller thumbnail size than we can reach within the max column // count, we won't have reached desired_pixels. In this case, just clamp to it. // This will cause us to use too many columns, which we'll correct below with // container_width. if(max_width * max_height > desired_pixels) { max_height = max_width = Math.round(Math.sqrt(desired_pixels)); if(ratio < 1) max_width *= ratio; else if(ratio > 1) max_height /= ratio; } // Clamp the width of the container to the number of columns we expect. var container_width = max_columns * (max_width+padding*2); var css = \` \${top_selector} .thumbnail-inner { width: \${max_width}px; height: \${max_height}px; contain-intrinsic-size: \${max_width}px \${max_height}px; } \${top_selector} .thumbnails { gap: \${padding}px; } \${top_selector} .last-viewed-image-marker { width: \${max_width/4}px; } \`; if(container_width != null) css += \`\${top_selector} > .thumbnails { max-width: \${container_width}px; }\`; return css; }, // If the aspect ratio is very narrow, don't use any panning, since it becomes too spastic. // If the aspect ratio is portrait, use vertical panning. // If the aspect ratio is landscape, use horizontal panning. // // If it's in between, don't pan at all, since we don't have anywhere to move and it can just // make the thumbnail jitter in place. // // Don't pan muted images. // // container_aspect_ratio is the aspect ratio of the box the thumbnail is in. If the // thumb is in a 2:1 landscape box, we'll adjust the min and max aspect ratio accordingly. set_thumbnail_panning_direction(thumb, width, height, container_aspect_ratio) { var aspect_ratio = width / height; aspect_ratio /= container_aspect_ratio; var min_aspect_for_pan = 1.1; var max_aspect_for_pan = 4; var vertical_panning = aspect_ratio > (1/max_aspect_for_pan) && aspect_ratio < 1/min_aspect_for_pan; var horizontal_panning = aspect_ratio > min_aspect_for_pan && aspect_ratio < max_aspect_for_pan; helpers.set_class(thumb, "vertical-panning", vertical_panning); helpers.set_class(thumb, "horizontal-panning", horizontal_panning); }, set_title(illust_data) { if(illust_data == null) { helpers.set_page_title("Loading..."); return; } var page_title = ""; if(illust_data.bookmarkData) page_title += "★"; page_title += illust_data.userName + " - " + illust_data.illustTitle; helpers.set_page_title(page_title); }, set_icon(illust_data) { helpers.set_page_icon(resources['resources/regular-pixiv-icon.png']); }, set_title_and_icon(illust_data) { helpers.set_title(illust_data) helpers.set_icon(illust_data) }, // Return 1 if the given keydown event should zoom in, -1 if it should zoom // out, or null if it's not a zoom keypress. is_zoom_hotkey(e) { if(!e.ctrlKey) return null; if(e.code == "NumpadAdd" || e.code == "Equal") /* = */ return +1; if(e.code == "NumpadSubtract" || e.code == "Minus") /* - */ return -1; return null; }, // https://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas/3368118#3368118 /* * Draws a rounded rectangle using the current state of the canvas. * If you omit the last three params, it will draw a rectangle * outline with a 5 pixel border radius */ draw_round_rect(ctx, x, y, width, height, radius) { if(typeof radius === 'undefined') radius = 5; if(typeof radius === 'number') { radius = {tl: radius, tr: radius, br: radius, bl: radius}; } else { var defaultRadius = {tl: 0, tr: 0, br: 0, bl: 0}; for(var side in defaultRadius) radius[side] = radius[side] || defaultRadius[side]; } ctx.beginPath(); ctx.moveTo(x + radius.tl, y); ctx.lineTo(x + width - radius.tr, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); ctx.lineTo(x + width, y + height - radius.br); ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); ctx.lineTo(x + radius.bl, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); ctx.lineTo(x, y + radius.tl); ctx.quadraticCurveTo(x, y, x + radius.tl, y); ctx.closePath(); }, // Helpers for IDs in the illustration list. // // Most things we show in thumbs are illustration IDs, and we pass them around normally. // If we need to show something else in a thumbnail, we encode it. We can show a user // thumbnail by adding "user:12345" as an ID. // // Return the type of the ID and the underlying illust or user ID. parse_id(id) { let parts = id.split(":"); let type = parts.length < 2? "illust": parts[0]; let actual_id = parts.length < 2? id: parts[1]; return { type: type, id: actual_id, } }, // Generate a UUID. create_uuid() { let data = new Uint8Array(32); crypto.getRandomValues(data); // variant 1 data[8] &= 0b00111111; data[8] |= 0b10000000; // version 4 data[6] &= 0b00001111; data[6] |= 4 << 4; let result = ""; for(let i = 0; i < 4; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 4; i < 6; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 6; i < 8; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 8; i < 10; ++i) result += data[i].toString(16).padStart(2, "0"); result += "-"; for(let i = 10; i < 16; ++i) result += data[i].toString(16).padStart(2, "0"); return result; }, shuffle_array(array) { for(let idx = 0; idx < array.length; ++idx) { let swap_with = Math.floor(Math.random() * array.length); [array[idx], array[swap_with]] = [array[swap_with], array[idx]]; } }, adjust_image_url_hostname(url) { if(url.hostname == "i.pximg.net") url.hostname = "i-cf.pximg.net"; }, // Given a low-res thumbnail URL from thumbnail data, return a high-res thumbnail URL. // If page isn't 0, return a URL for the given manga page. get_high_res_thumbnail_url(url, page=0) { // Some random results on the user recommendations page also return this: // // /c/540x540_70/custom-thumb/img/.../12345678_custom1200.jpg // // Replace /custom-thumb/' with /img-master/ first, since it makes matching below simpler. url = url.replace("/custom-thumb/", "/img-master/"); // path should look like // // /c/250x250_80_a2/img-master/img/.../12345678_square1200.jpg // // where 250x250_80_a2 is the resolution and probably JPEG quality. We want // the higher-res thumbnail (which is "small" in the full image data), which // looks like: // // /c/540x540_70/img-master/img/.../12345678_master1200.jpg // // The resolution field is changed, and "square1200" is changed to "master1200". var url = new URL(url, ppixiv.location); var path = url.pathname; var re = /(\\/c\\/)([^\\/]+)(.*)(square1200|master1200|custom1200).jpg/; var match = re.exec(path); if(match == null) { console.warn("Couldn't parse thumbnail URL:", path); return url.toString(); } url.pathname = match[1] + "540x540_70" + match[3] + "master1200.jpg"; if(page != 0) { // Manga URLs end with: // // /c/540x540_70/custom-thumb/img/.../12345678_p0_master1200.jpg // // p0 is the page number. url.pathname = url.pathname.replace("_p0_master1200", "_p" + page + "_master1200"); } this.adjust_image_url_hostname(url); return url.toString(); }, }; // Handle maintaining and calling a list of callbacks. ppixiv.callback_list = class { constructor() { this.callbacks = []; } // Call all callbacks, passing all arguments to the callback. call() { for(var callback of this.callbacks.slice()) { try { callback.apply(null, arguments); } catch(e) { console.error(e); } } } register(callback) { if(callback == null) throw "callback can't be null"; if(this.callbacks.indexOf(callback) != -1) return; this.callbacks.push(callback); } unregister(callback) { if(callback == null) throw "callback can't be null"; var idx = this.callbacks.indexOf(callback); if(idx == -1) return; this.callbacks.splice(idx, 1); } } // Listen to viewhidden on element and each of element's parents. // // When a view is hidden (eg. a top-level view or a UI popup), we send // viewhidden to it so dropdowns, etc. inside it can close. ppixiv.view_hidden_listener = class { static send_viewhidden(element) { var event = new Event("viewhidden", { bubbles: false }); element.dispatchEvent(event); } constructor(element, callback) { this.onviewhidden = this.onviewhidden.bind(this); this.callback = callback; // There's no way to listen on events on any parent, so we have to add listeners // to each parent in the tree. this.listening_on_elements = []; while(element != null) { this.listening_on_elements.push(element); element.addEventListener("viewhidden", this.onviewhidden); element = element.parentNode; } } // Remove listeners. shutdown() { for(var element of this.listening_on_elements) element.removeEventListener("viewhidden", this.onviewhidden); this.listening_on_elements = []; } onviewhidden(e) { this.callback(e); } }; // Filter an image to a canvas. // // When an image loads, draw it to a canvas of the same size, optionally applying filter // effects. // // If base_filter is supplied, it's a filter to apply to the top copy of the image. // If overlay(ctx, img) is supplied, it's a function to draw to the canvas. This can // be used to mask the top copy. ppixiv.image_canvas_filter = class { constructor(img, canvas, base_filter, overlay) { this.img = img; this.canvas = canvas; this.base_filter = base_filter || ""; this.overlay = overlay; this.ctx = this.canvas.getContext("2d"); this.img.addEventListener("load", this.update_canvas.bind(this)); // For some reason, browsers can't be bothered to implement onloadstart, a seemingly // fundamental progress event. So, we have to use a mutation observer to tell when // the image is changed, to make sure we clear it as soon as the main image changes. this.observer = new MutationObserver((mutations) => { for(var mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "src") { this.update_canvas(); } } } }); this.observer.observe(this.img, { attributes: true }); this.update_canvas(); } clear() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.current_url = helpers.blank_image; } update_canvas() { // The URL for the image we're rendering. If the image isn't complete, use the blank image // URL instead, since we're just going to clear. let current_url = this.img.src; if(!this.img.complete) current_url = helpers.blank_image; if(current_url == this.current_url) return; this.canvas.width = this.img.naturalWidth; this.canvas.height = this.img.naturalHeight; this.clear(); this.current_url = current_url; // If we're rendering the blank image (or an incomplete image), stop. if(current_url == helpers.blank_image) return; // Draw the image onto the canvas. this.ctx.save(); this.ctx.filter = this.base_filter; this.ctx.drawImage(this.img, 0, 0); this.ctx.restore(); // Composite on top of the base image. this.ctx.save(); if(this.overlay) this.overlay(this.ctx, this.img); this.ctx.restore(); // Use destination-over to draw the image underneath the overlay we just drew. this.ctx.globalCompositeOperation = "destination-over"; this.ctx.drawImage(this.img, 0, 0); } } // Add delays to hovering and unhovering. The class "hover" will be set when the mouse // is over the element (equivalent to the :hover selector), with a given delay before the // state changes. // // This is used when hovering the top bar when in ui-on-hover mode, to delay the transition // before the UI disappears. transition-delay isn't useful for this, since it causes weird // hitches when the mouse enters and leaves the area quickly. ppixiv.hover_with_delay = class { constructor(element, delay_enter, delay_exit) { this.element = element; this.delay_enter = delay_enter * 1000.0; this.delay_exit = delay_exit * 1000.0; this.timer = -1; this.pending_hover = null; element.addEventListener("mouseenter", (e) => { this.real_hover_changed(true); }); element.addEventListener("mouseleave", (e) => { this.real_hover_changed(false); }); } real_hover_changed(hovering) { // If we already have this event queued, just let it continue. if(this.pending_hover != null && this.pending_hover == hovering) return; // If the opposite event is pending, cancel it. if(this.hover_timeout != null) { clearTimeout(this.hover_timeout); this.hover_timeout = null; } this.real_hover_state = hovering; this.pending_hover = hovering; let delay = hovering? this.delay_enter:this.delay_exit; this.hover_timeout = setTimeout(() => { this.pending_hover = null; this.hover_timeout = null; helpers.set_class(this.element, "hover", this.real_hover_state); }, delay); } } // Originally from https://gist.github.com/wilsonpage/01d2eb139959c79e0d9a ppixiv.key_storage = class { constructor(store_name, {db_upgrade=null, version=1}={}) { this.db_name = store_name; this.db_upgrade = db_upgrade; this.store_name = store_name; this.version = version; } // Open the database, run func, then close the database. // // If you open a database with IndexedDB and then leave it open, like you would with // any other database, any attempts to add stores (which you can do seamlessly with // any other database) will permanently wedge the database. We have to open it and // close it around every op. async db_op(func) { let db = await this.open_database(); try { return await func(db); } finally { db.close(); } } async get_db_version() { let dbs = await indexedDB.databases(); for(let db of dbs) { if(db.name == this.db_name) return db.version; } return 0; } open_database() { return new Promise((resolve, reject) => { let request = indexedDB.open(this.db_name, this.version); // If this happens, another tab has the database open. request.onblocked = e => { console.error("Database blocked:", e); }; request.onupgradeneeded = e => { // If we have a db_upgrade function, let it handle the upgrade. Otherwise, we're // just creating the initial database and we're not doing anything special with it. let db = e.target.result; if(this.db_upgrade) this.db_upgrade(e); else db.createObjectStore(this.store_name); }; request.onsuccess = e => { let db = e.target.result; resolve(db); }; request.onerror = e => { console.log(\`Error opening database: \${request.error}\`); reject(e); }; }); } get_store(db, mode="readwrite") { let transaction = db.transaction(this.store_name, mode); return transaction.objectStore(this.store_name); } static await_request(request) { return new Promise((resolve, reject) => { let abort = new AbortController; request.addEventListener("success", (e) => { abort.abort(); resolve(request.result); }, { signal: abort.signal }); request.addEventListener("error", (e) => { abort.abort(); reject(request.result); }, { signal: abort.signal }); }); } static async_store_get(store, key) { return new Promise((resolve, reject) => { var request = store.get(key); request.onsuccess = e => resolve(e.target.result); request.onerror = reject; }); } async get(key, store) { return await this.db_op(async (db) => { return await key_storage.async_store_get(this.get_store(db), key); }); } // Given a list of keys, return known translations. Tags that we don't have data for are null. async multi_get(keys) { return await this.db_op(async (db) => { let store = this.get_store(db, "readonly"); let promises = []; for(let key of keys) promises.push(key_storage.async_store_get(store, key)); return await Promise.all(promises); }); } static async_store_set(store, key, value) { return new Promise((resolve, reject) => { var request = store.put(value, key); request.onsuccess = resolve; request.onerror = reject; }); } async set(key, value) { return await this.db_op(async (db) => { return key_storage.async_store_set(this.get_store(db), key, value); }); } // Given a dictionary, set all key/value pairs. async multi_set(data) { return await this.db_op(async (db) => { let store = this.get_store(db); let promises = []; for(let [key, value] of Object.entries(data)) { let request = store.put(value, key); promises.push(key_storage.await_request(request)); } await Promise.all(promises); }); } async multi_set_values(data) { return await this.db_op(async (db) => { let store = this.get_store(db); let promises = []; for(let item of data) { let request = store.put(item); promises.push(key_storage.await_request(request)); } return Promise.all(promises); }); } // Delete a list of keys. async multi_delete(keys) { return await this.db_op(async (db) => { let store = this.get_store(db); let promises = []; for(let key of keys) { let request = store.delete(key); promises.push(key_storage.await_request(request)); } return Promise.all(promises); }); } // Delete all keys. async clear() { return await this.db_op(async (db) => { let store = this.get_store(db); await store.clear(); }); } } ppixiv.SaveScrollPosition = class { constructor(node) { this.node = node; this.child = null; this.original_scroll_top = this.node.scrollTop; } // Instead of saving the top-level scroll position, store the scroll position of a given child. save_relative_to(child) { this.child = child; this.original_offset_top = child.offsetTop; } restore() { let scroll_top = this.original_scroll_top; if(this.child) { let offset = this.child.offsetTop - this.original_offset_top; scroll_top += offset; } this.node.scrollTop = scroll_top; } }; // VirtualHistory is a wrapper for document.location and window.history to allow // setting a virtual, temporary document location. These are ppixiv.location and // ppixiv.history, and have roughly the same interface. // // This can be used to preview another page without changing browser history, and // works around a really painful problem with the history API: while history.pushState // and replaceState are sync, history.back() is async. That makes it very hard to // work with reliably. ppixiv.VirtualHistory = class { constructor() { this.virtual_url = null; // ppixiv.location can be accessed like document.location. Object.defineProperty(ppixiv, "location", { get: () => { // If we're not using a virtual location, return document.location. // Otherwise, return virtual_url. Always return a copy of virtual_url, // since the caller can modify it and it should only change through // explicit history changes. if(this.virtual_url == null) return new URL(document.location); else return new URL(this.virtual_url); }, set: (value) => { // We could support assigning ppixiv.location, but we always explicitly // pushState. Just throw an exception if we get here accidentally. throw Error("Can't assign to ppixiv.location"); /* if(!this.virtual) { document.location = value; return; } // If we're virtual, replace the virtual URL. this.virtual_url = new URL(value, this.virtual_url); this.broadcastPopstate(); */ }, }); } get virtual() { return this.virtual_url != null; } url_is_virtual(url) { // Push a virtual URL by putting #virtual=1 in the hash. let args = new helpers.args(url); return args.hash.get("virtual"); } pushState(data, title, url) { url = new URL(url, document.location); let virtual = this.url_is_virtual(url); // We don't support a history of virtual locations. Once we're virtual, we // can only replaceState or back out to the real location. if(virtual && this.virtual_url) throw Error("Can't push a second virtual location"); // If we're not pushing a virtual location, just use a real one. if(!virtual) { this.virtual_url = null; // no longer virtual return window.history.pushState(data, title, url); } // Note that browsers don't dispatch popstate on pushState (which makes no sense at all), // so we don't here either to match. this.virtual_data = data; this.virtual_title = title; this.virtual_url = url; } replaceState(data, title, url) { url = new URL(url, document.location); let virtual = this.url_is_virtual(url); if(!virtual) { // If we're replacing a virtual location with a real one, pop the virtual location // and push the new state instead of replacing. Otherwise, replace normally. if(this.virtual_url != null) { this.virtual_url = null; return window.history.pushState(data, title, url); } else { return window.history.replaceState(data, title, url); } } // We can only replace a virtual location with a virtual location. // We can't replace a real one with a virtual one, since we can't edit // history like that. if(this.virtual_url == null) throw Error("Can't replace a real history entry with a virtual one"); this.virtual_url = url; } get state() { if(this.virtual) return this.virtual_data; // Use unsafeWindow.history instead of window.history to avoid unnecessary // TamperMonkey wrappers. return unsafeWindow.history.state; } set state(value) { if(this.virtual) this.virtual_data = value; else unsafeWindow.history.state = value; } back() { // If we're backing out of a virtual URL, clear it to return to the real one. if(this.virtual_url) { this.virtual_url = null; this.broadcastPopstate(); } else { window.history.back(); } } broadcastPopstate() { let e = new PopStateEvent("popstate"); e.navigationCause = "leaving-virtual"; window.dispatchEvent(e); } }; ppixiv.history = new VirtualHistory; // The pointer API is sadistically awful. Only the first pointer press is sent by pointerdown. // To get others, you have to register pointermove and get spammed with all mouse movement. // You have to register pointermove when a button is pressed in order to see other buttons // without keeping a pointermove event running all the time. You also have to use e.buttons // instead of e.button, because pointermove doesn't tell you what buttons changed, making e.button // meaningless. // // Who designed this? This isn't some ancient IE6 legacy API. How do you screw up a mouse // event API this badly? ppixiv.pointer_listener = class { // The global handler is used to track button presses and mouse movement globally, // primarily to implement pointer_listener.check(). // The latest mouse position seen by install_global_handler. static latest_mouse_position = [window.innerWidth/2, window.innerHeight/2]; static buttons = 0; static button_pointer_ids = new Map(); static pointer_type = "mouse"; static install_global_handler() { window.addEventListener("pointermove", (e) => { pointer_listener.latest_mouse_position = [e.pageX, e.pageY]; this.pointer_type = e.pointerType; }, { passive: true, capture: true }); new pointer_listener({ element: window, button_mask: 0xFFFF, // everything capture: true, callback: (e) => { if(e.pressed) { pointer_listener.buttons |= 1 << e.mouseButton; pointer_listener.button_pointer_ids.set(e.mouseButton, e.pointerId); } else { pointer_listener.buttons &= ~(1 << e.mouseButton); pointer_listener.button_pointer_ids.delete(e.mouseButton); } } }); } // callback(event) will be called each time buttons change. The event will be the event // that actually triggered the state change, and can be preventDefaulted, etc. // // To disable, include {signal: AbortSignal} in options. constructor({element, callback, button_mask=1, ...options}={}) { this.element = element; this.button_mask = button_mask; this.pointermove_registered = false; this.buttons_down = 0; this.callback = callback; this.event_options = options; let handling_right_click = (button_mask & 2) != 0; this.blocking_context_menu_until_timer = false; if(handling_right_click) window.addEventListener("contextmenu", this.oncontextmenu, this.event_options); if(options.signal) { options.signal.addEventListener("abort", (e) => { // If we have a block_contextmenu_timer timer running when we're cancelled, remove it. if(this.block_contextmenu_timer != null) clearTimeout(this.block_contextmenu_timer); }); } this.element.addEventListener("pointerdown", this.onpointerevent, this.event_options); } // Register events that we only register while one or more buttons are pressed. // // We only register pointermove as needed, so we don't get called for every mouse // movement, and we only register pointerup as needed so we don't register a ton // of events on window. register_events_while_pressed(enable) { if(this.pointermove_registered) return; this.pointermove_registered = true; this.element.addEventListener("pointermove", this.onpointermove, this.event_options); // These need to go on window, so if a mouse button is pressed and that causes // the element to be hidden, we still get the pointerup. window.addEventListener("pointerup", this.onpointerevent, this.event_options); window.addEventListener("pointercancel", this.onpointerup, this.event_options); } unregister_events_while_pressed(enable) { if(!this.pointermove_registered) return; this.pointermove_registered = false; this.element.removeEventListener("pointermove", this.onpointermove, this.event_options); window.removeEventListener("pointerup", this.onpointerevent, this.event_options); window.removeEventListener("pointercancel", this.onpointerup, this.event_options); } button_changed(buttons, event) { // We need to register pointermove to see presses past the first. if(buttons) this.register_events_while_pressed(); else this.unregister_events_while_pressed(); let old_buttons_down = this.buttons_down; this.buttons_down = buttons; for(let button = 0; button < 5; ++button) { let mask = 1 << button; // Ignore this if it's not a button change for a button in our mask. if(!(mask & this.button_mask)) continue; let was_pressed = old_buttons_down & mask; let is_pressed = this.buttons_down & mask; if(was_pressed == is_pressed) continue; // Pass the button in event.mouseButton, and whether it was pressed or released in event.pressed. // Don't use e.button, since it's in a different order than e.buttons. event.mouseButton = button; event.pressed = is_pressed; this.callback(event); // Remove event.mouseButton so it doesn't appear for unrelated event listeners. delete event.mouseButton; delete event.pressed; // Right-click handling if(button == 1) { // If this is a right-click press and the user prevented the event, block the context // menu when this button is released. if(is_pressed && event.defaultPrevented) this.block_context_menu_on_release = true; // If this is a right-click release and the user prevented the event (or the corresponding // press earlier), block the context menu briefly. There seems to be no other way to do // this: cancelling pointerdown or pointerup don't prevent actions like they should, // contextmenu happens afterwards, and there's no way to know if a contextmenu event // is coming other than waiting for an arbitrary amount of time. if(!is_pressed && (event.defaultPrevented || this.block_context_menu_on_release)) { this.block_context_menu_on_release = false; this.block_context_menu_until_timer(); } } } } onpointerevent = (e) => { this.button_changed(e.buttons, e); } onpointermove = (e) => { // Short-circuit processing pointermove if button is -1, which means it's just // a move (the only thing this event should even be used for). if(e.button == -1) return; this.button_changed(e.buttons, e); } oncontextmenu = (e) => { if(this.blocking_context_menu_until_timer) { // console.log("stop context menu (waiting for timer)"); e.preventDefault(); e.stopPropagation(); } } // Block contextmenu for a while. block_context_menu_until_timer() { // console.log("Waiting for timer before releasing context menu"); this.blocking_context_menu_until_timer = true; if(this.block_contextmenu_timer != null) { clearTimeout(this.block_contextmenu_timer); this.block_contextmenu_timer = null; } this.block_contextmenu_timer = setTimeout(() => { this.block_contextmenu_timer = null; // console.log("Releasing context menu after timer"); this.blocking_context_menu_until_timer = false; }, 50); } // Check if any buttons are pressed that were missed while the element wasn't visible. // // This can be used if the element becomes visible, and we want to see any presses // already happening that are over the element. // // This requires install_global_handler. check() { // If no buttons are pressed that this listener cares about, stop. if(!(this.button_mask & pointer_listener.buttons)) return; // See if the cursor is over our element. let node_under_cursor = document.elementFromPoint(pointer_listener.latest_mouse_position[0], pointer_listener.latest_mouse_position[1]); if(node_under_cursor == null || !helpers.is_above(this.element, node_under_cursor)) return; // Simulate a pointerdown on this element for each button that's down, so we can // send the corresponding pointerId for each button. for(let button = 0; button < 8; ++button) { // Skip this button if it's not down. let mask = 1 << button; if(!(mask & pointer_listener.buttons)) continue; // Add this button's mask to the listener's last seen mask, so it only sees this // button being added. This way, each button event is sent with the correct // pointerId. let new_button_mask = this.buttons_down; new_button_mask |= mask; let e = new MouseEvent("simulatedpointerdown", { buttons: pointer_listener.buttons, currentTarget: node_under_cursor, target: node_under_cursor, pageX: pointer_listener.latest_mouse_position[0], pageY: pointer_listener.latest_mouse_position[1], timestamp: performance.now(), }); e.pointerId = pointer_listener.button_pointer_ids.get(button); this.button_changed(new_button_mask, e); } } } // This is an attempt to make it easier to handle a common problem with // asyncs: checking whether what we're doing should continue after awaiting. // The wrapped function will be passed an AbortSignal. It can be used normally // for aborting async calls. It also has signal.cancel(), which will throw // SentinelAborted if another call to the guarded function has been made. class SentinelAborted extends Error { }; ppixiv.SentinelGuard = function(func, self) { if(self) func = func.bind(self); let sentinel = null; let abort = () => { // Abort the current sentinel. if(sentinel) { sentinel.abort(); sentinel = null; } }; async function wrapped(...args) { // If another call is running, abort it. abort(); sentinel = new AbortController(); let our_sentinel = sentinel; let signal = sentinel.signal; signal.check = () => { // If we're signalled, another guarded function was started, so this one should abort. if(our_sentinel.signal.aborted) throw new SentinelAborted; }; try { return await func(signal, ...args); } catch(e) { if(!(e instanceof SentinelAborted)) throw e; // console.warn("Guarded function cancelled"); return null; } finally { if(our_sentinel === sentinel) sentinel = null; } }; wrapped.abort = abort; return wrapped; }; // Try to guess the full URL for an image from its preview image and user ID. // // The most annoying thing about Pixiv's API is that thumbnail info doesn't include // image URLs. This means you have to wait for image data to load before you can // start loading the image at all, and the API call to get image data often takes // as long as the image load itself. This makes loading images take much longer // than it needs to. // // We can mostly guess the image URL from the thumbnail URL, but we don't know the // extension. Try to guess. Keep track of which formats we've seen from each user // as we see them. If we've seen a few posts from a user and they have a consistent // file type, guess that the user always uses that format. // // This tries to let us start loading images earlier, without causing a ton of 404s // from wrong guesses. ppixiv.guess_image_url = class { static _singleton = null; static get get() { if(!this._singleton) this._singleton = new this(); return this._singleton; } constructor() { this.db = new key_storage("ppixiv-file-types", { db_upgrade: this.db_upgrade }); } db_upgrade = (e) => { let db = e.target.result; let store = db.createObjectStore("ppixiv-file-types", { keyPath: "illust_id_and_page", }); // This index lets us look up the number of entries for a given user and filetype // quickly. // // page is included in this so we can limit the search to just page 1. This is so // a single 100-page post doesn't overwhelm every other post a user makes: we only // use page 1 when guessing a user's preferred file type. store.createIndex("user_id_and_filetype", ["user_id", "page", "ext"]); } // Use the illust ID and page together as the primary key. get_key(illust_id, page) { return \`\${illust_id}_\${page}\`; } // Store info about an image that we've loaded data for. add_info(image_info) { let illust_id = image_info.id; // Store one record per page. let pages = []; for(let page = 0; page < image_info.pageCount; ++page) { let url = image_info.mangaPages[page].urls.original; let parts = url.split("."); let ext = parts[parts.length-1]; pages.push({ illust_id_and_page: this.get_key(illust_id, page), illust_id: illust_id, page: page, user_id: image_info.userId, url: url, ext: ext, }); } // We don't need to wait for this to finish, but return the promise in case // the caller wants to. return this.db.multi_set_values(pages); } // Return the number of images by the given user that have the given file type, // eg. "jpg". // // We have a dedicated index for this, so retrieving the count is fast. async get_filetype_count_for_user(store, user_id, filetype) { let index = store.index("user_id_and_filetype"); let query = IDBKeyRange.only([user_id, 0 /* page */, filetype]); return await key_storage.await_request(index.count(query)); } // Try to guess the user's preferred file type. Returns "jpg", "png" or null. guess_filetype_for_user_id(user_id) { return this.db.db_op(async (db) => { let store = this.db.get_store(db); // Get the number of posts by this user with both file types. let jpg = await this.get_filetype_count_for_user(store, user_id, "jpg"); let png = await this.get_filetype_count_for_user(store, user_id, "png"); // Wait until we've seen a few images from this user before we start guessing. if(jpg+png < 3) return null; // If a user's posts are at least 90% one file type, use that type. let jpg_fraction = jpg / (jpg+png); if(jpg_fraction > 0.9) { console.debug(\`User \${user_id} posts mostly JPEGs\`); return "jpg"; } else if(jpg_fraction < 0.1) { console.debug(\`User \${user_id} posts mostly PNGs\`); return "png"; } else { console.debug(\`Not guessing file types for \${user_id} due to too much variance\`); return null; } }); } async get_stored_record(illust_id, page) { return this.db.db_op(async (db) => { let key = this.get_key(illust_id, page); let store = this.db.get_store(db); let record = await key_storage.async_store_get(store, key); if(record == null) return null; else return record.url; }); } async guess_url(illust_id, page) { // If we already have illust info, use it. let illust_info = image_data.singleton().get_image_info_sync(illust_id); if(illust_info != null) return illust_info.mangaPages[page].urls.original; // If we've stored this URL, use it. let stored_url = await this.get_stored_record(illust_id, page); if(stored_url != null) return stored_url; // Get thumbnail data. We need the thumbnail URL to figure out the image URL. let thumb = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumb == null) return null; // Try to make a guess at the file type. let guessed_filetype = await this.guess_filetype_for_user_id(thumb.userId); if(guessed_filetype == null) return null; // Convert the thumbnail URL to the equivalent original URL: // https://i.pximg.net /img-original/img/2021/01/01/01/00/02/12345678_p0.jpg // https://i.pximg.net/c/540x540_70 /img-master/img/2021/01/01/01/00/02/12345678_p0_master1200.jpg let url = thumb.previewUrls[page]; url = url.replace("/c/540x540_70/", "/"); url = url.replace("/img-master/", "/img-original/"); url = url.replace("_master1200.", "."); url = url.replace(/jpg\$/, guessed_filetype); return url; } // This is called if a guessed preload fails to load. This either means we // guessed wrong, or if we came from a cached URL in the database, that the // user reuploaded the image with a different file type. async guessed_url_incorrect(illust_id, page) { // If this was a stored URL, remove it from the database. let key = this.get_key(illust_id, page); await this.db.multi_delete([key]); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/helpers.js `; ppixiv.resources["src/settings.js"] = `"use strict"; // Get and set values in localStorage. ppixiv.settings = class { static sticky_settings = { }; static session_settings = { }; static defaults = { }; static get_change_callback_list(key) { if(settings._callbacks == null) settings._callbacks = {}; var callbacks = settings._callbacks[key]; if(callbacks == null) callbacks = settings._callbacks[key] = new callback_list(); return callbacks; } // Configure settings. This is used for properties of settings that we need to // know at startup, so we know where to find them. // // Sticky settings are saved and loaded like other settings, but once a setting is loaded, // changes made by other tabs won't affect this instance. This is used for things like zoom // settings, where we want to store the setting, but we don't want each tab to clobber every // other tab every time it's changed. // // Session settings are stored in sessionStorage instead of localStorage. These are // local to the tab. They'll be copied into new tabs if a tab is duplicated, but they're // otherwise isolated, and lost when the tab is closed. static configure(key, {sticky=false, session=false, default_value=null}) { if(sticky) { // Create the key if it doesn't exist. if(settings.sticky_settings[key] === undefined) settings.sticky_settings[key] = null; } if(session) this.session_settings[key] = true; if(default_value != null) this.defaults[key] = default_value; } static _get_storage_for_key(key) { if(this.session_settings[key]) return sessionStorage; else return localStorage; } static _get_from_storage(key, default_value) { let storage = this._get_storage_for_key(key); key = "_ppixiv_" + key; if(!(key in storage)) return default_value; let result = storage[key]; try { return JSON.parse(result); } catch(e) { // Recover from invalid values in storage. console.warn(e); console.log("Removing invalid setting:", result); delete storage.storage_key; return default_value; } } static get(key, default_value) { if(key in this.defaults) default_value = this.defaults[key]; // If this is a sticky setting and we've already read it, use our loaded value. if(settings.sticky_settings[key]) return settings.sticky_settings[key]; let result = settings._get_from_storage(key, default_value); // If this is a sticky setting, remember it for reuse. This will store the default value // if there's no stored setting. if(settings.sticky_settings[key] !== undefined) settings.sticky_settings[key] = result; return result; } // Handle migrating settings that have changed. static migrate() { } static set(key, value) { let storage = this._get_storage_for_key(key); // JSON.stringify incorrectly serializes undefined as "undefined", which isn't // valid JSON. We shouldn't be doing this anyway. if(value === undefined) throw "Key can't be set to undefined: " + key; // If this is a sticky setting, replace its value. if(settings.sticky_settings[key] !== undefined) settings.sticky_settings[key] = value; var setting_key = "_ppixiv_" + key; var value = JSON.stringify(value); storage[setting_key] = value; // Call change listeners for this key. settings.get_change_callback_list(key).call(key); } static register_change_callback(key, callback) { settings.get_change_callback_list(key).register(callback); } static unregister_change_callback(key, callback) { settings.get_change_callback_list(key).unregister(callback); } } // Register settings. ppixiv.settings.configure("zoom-mode", { sticky: true }); ppixiv.settings.configure("zoom-level", { sticky: true }); ppixiv.settings.configure("linked_tabs", { session: true }); ppixiv.settings.configure("linked_tabs_enabled", { session: true, default_value: true }); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/settings.js `; ppixiv.resources["src/fix_chrome_clicks.js"] = `"use strict"; // Fix Chrome's click behavior. // // Work around odd, obscure click behavior in Chrome: releasing the right mouse // button while the left mouse button is held prevents clicks from being generated // when the left mouse button is released (even if the context menu is cancelled). // This causes lost inputs when quickly right-left clicking our context menu. // // Unfortunately, we have to reimplement the click event in order to do this. // We only attach this handler where it's really needed (the popup menu). // // We mimic Chrome's click detection behavior: an element is counted as a click if // the mouseup event is an ancestor of the element that was clicked, or vice versa. // This is different from Firefox which uses the distance the mouse has moved. ppixiv.fix_chrome_clicks = class { constructor(container) { // Don't do anything if we're not in Chrome. this.enabled = navigator.userAgent.indexOf("Chrome") != -1; if(!this.enabled) return; this.onpointer = this.onpointer.bind(this); this.onclick = this.onclick.bind(this); this.oncontextmenu = this.oncontextmenu.bind(this); this.container = container; this.pressed_node = null; // Since the pointer events API is ridiculous and doesn't send separate pointerdown // events for each mouse button, we have to listen to all clicks in window in order // to find out if button 0 is pressed. If the user presses button 2 outside of our // container we still want to know about button 0, but that button 0 event might happen // in another element that we don't care about, // for each this.container.addEventListener("pointerdown", this.onpointer, true); this.container.addEventListener("pointerup", this.onpointer, true); this.container.addEventListener("pointermove", this.onpointer, true); this.container.addEventListener("contextmenu", this.oncontextmenu); this.container.addEventListener("click", this.onclick, true); } // We have to listen on window as well as our container for events, since a // mouse up might happen on another node after the mouse down happened in our // node. We only register these while a button is pressed in our node, so we // don't have global pointer event handlers installed all the time. start_waiting_for_release() { if(this.pressed_node != null) { console.warn("Unexpected call to start_waiting_for_release"); return; } window.addEventListener("pointerup", this.onpointer, true); window.addEventListener("pointermove", this.onpointer, true); } stop_waiting_for_release() { if(this.pressed_node == null) return; window.removeEventListener("pointerup", this.onpointer, true); window.removeEventListener("pointermove", this.onpointer, true); this.pressed_node = null; } // The pointer events API is nonsensical: button presses generate pointermove // instead of pointerdown or pointerup if another button is already pressed. That's // completely useless, so we have to just listen to all of them the same way and // deduce what's happening from the button mask. onpointer(e) { if(e.pointerType != "mouse") return; if(e.type == "pointerdown") { // Start listening to move events. We only need this while a button // is pressed. this.start_waiting_for_release(); } if(e.buttons & 1) { // The primary button is pressed, so remember what element we were on. if(this.pressed_node == null) { // console.log("mousedown", e.target.id); this.pressed_node = e.target; } return; } if(this.pressed_node == null) return; var pressed_node = this.pressed_node; // The button was released. Unregister our temporary event listeners. this.stop_waiting_for_release(); // console.log("released:", e.target.id, "after click on", pressed_node.id); var released_node = e.target; var click_target = null; if(helpers.is_above(released_node, pressed_node)) click_target = released_node; else if(helpers.is_above(pressed_node, released_node)) click_target = pressed_node; if(click_target == null) { // console.log("No target for", pressed_node, "and", released_node); return; } // If the click target is above our container, stop. if(helpers.is_above(click_target, this.container)) return; // Why is cancelling the event not preventing mouse events and click events? e.preventDefault(); // console.log("do click on", click_target.id, e.defaultPrevented, e.type); this.send_click_event(click_target, e); } oncontextmenu(e) { if(this.pressed_node != null && !e.defaultPrevented) { console.log("Not sending click because the context menu was opened"); this.pressed_node = null; } } // Cancel regular mouse clicks. // // Pointer events is a broken API. It sends mouse button presses as pointermove // if another button is already pressed, which already doesn't make sense and // makes it a headache to use. But, to make things worse, pointermove is defined // as having the same default event behavior as mousemove, despite the fact that it // can correspond to a mouse press or release. Also, preventDefault just seems to // be broken in Chrome and has no effect. // // So, we just cancel all button 0 click events that weren't sent by us. onclick(e) { if(e.button != 0) return; // Ignore synthetic events. if(!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); } send_click_event(target, source_event) { var e = new MouseEvent("click", source_event); e.synthetic = true; target.dispatchEvent(e); } shutdown() { if(!this.enabled) return; this.stop_waiting_for_release(); this.pressed_node = null; this.container.removeEventListener("pointerup", this.onpointer, true); this.container.removeEventListener("pointerdown", this.onpointer, true); this.container.removeEventListener("pointermove", this.onpointer, true); this.container.removeEventListener("contextmenu", this.oncontextmenu); this.container.removeEventListener("click", this.onclick, true); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r116/src/fix_chrome_clicks.js `; ppixiv.resources["src/widgets.js"] = `"use strict"; // A basic widget base class. ppixiv.widget = class { constructor({ container, template=null, parent=null, visible=true, ...options}={}) { console.assert(container != null); this.options = options; // template is the HTML template for this element. if(template) { template = this.create_template(template); let contents = helpers.create_from_template(template); container.appendChild(contents); container = contents; } container.classList.add("widget"); container.widget = this; this.parent = parent; this.container = container; // If we're visible, we'll unhide below. // grr // this.container.hidden = true; this.have_set_initial_visibility = false; // Let the caller finish, then refresh. helpers.yield(() => { this.refresh(); // If we're initially visible, set ourselves visible now. Skip this if something // else modifies visible first. if(visible && !this.have_set_initial_visibility) { this.have_set_initial_visibility = true; this.visible = true; } }); } // Create a