// ==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 108 // @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/circlems-icon.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+LpZUDAAABwxJREFUeJzlm29sG2cdxz93tnOOHedP4yZuq7QpibsSBeKWsVWZyqAQaWKTJmBVEjQoSBOVKAiYqkkVlYbGEO94AWKiiGkvOmnrRMUfsSpNUatVtEQg1JCkLWFZk5JiO2qSJk5jJznfHS98h52ssc/2+W6wr2RZ57t7nu/zfb6/3/NPFjj+FhvgA14GngMCG2/+jyIF/B74NnA394a44cGjwCLwPf5/Gg9QDfQCM8DPAcG4kSvAb4FfAG47mdkMAfgm8A/0thsCHAWedoiUE9gD/A4yAviBVxyl4wyeAlpF4Je8Pxd8WPCmCHzRaRYOYr8IeJ1m4SA8jmR8yS1T5UojudOspt2sKW5W0x4nqDgz5LXUz9HeGKc9GGdiNsTEXIiJ2ZATVOwXwONSONQ+xtEDF9i/Y4q/R3dyaqiH6YVGR1xge/YP+hN8q3uA/TsmARdd22/znYPn2OpP2E0FcECAjuY7fGzbv8jORgUe2hqlMzRtNxXAZgE8LoWe8Ih+Jaz7/mx4FI9LsZMOYLMAQX+CIw+/s6HqzPdXP3GZoANhYKsAHc13CAUWyFmM6RBoqlmko/mOnXQAGwV4sP1Zd90THrE9DGwT4MH2X0/jyMPv2B4Gtgmwuf0NCIQCC7aHgS0C5Lc/6363OwxsEaDeu0z/visFqnQmDGwRYM/WGDvrZ9m89w3YHwYVF8AlqhzcfVO/KiwAwKG2MVyiWlFeBiouQJ03SW/kqn5VSIAMncNdQ9R5kxXlZaDgatDjUvBXrdDou0+tN0m1Z61gM3IR2T5FZPuUfmXuzXAwRn/kCsPRVtP1aEBKriKx4mMuWcPymhdZcRV8T+D4W1q+BwJSisj2KXojV3nukYtIbtk0qfUQMWc4Vf8Uj9W0h1/95RBnhrsZjraytFptilVeeN0ynaFpjnUPIrnTZHqx1I8ZlF6+5E5zrHuQztA0XpMdVTAENARqpBUyJnMV0ZBSIej1lAINUKiRVtBM8izogJRcxWhsJ/GlekChVHtWHiqgEF+qZzS2k5RcZeqtggKspD2MxVt46cIz6yr6YCHbMS9deIaxeAsrJrfXCiZBAwEpxSdb3uNHT7zBgV3v6r/aERL5kLE8wNDtMN8f6Oev022mkp8BF92Hf2DmwTXFQzTRwOVbHSRlicdax3UC4IwI2dHiJ5ef4sXBXkZiu0jKUlGlmHaAAVHQaPQt0bNnhNd6X6HKZYwMpSauUqAAGpomcOTMMQb/2cXd+7WoWvEdYdoBBjQEkrLErflmzo9H2FE3T3swTqY3ihnuSkHW8ufHu/jamWNceq+Teym/6ay/EUULYEBW3MSXGrh6+yHmkgE+03ZdJ6hRmRl2NtH98I9f4seXvsCNGfPJbjMUHQIbIQoaW3z3+XTbdX769Gtsq72n37EqQWpkGq6xuOLj6K+/wcWJTuaSgZIsvxFlnwypmsDscoBzN/cxOd/E6/0/Y2/Tv8n0mBUHTxnLT8430ff6dxmLtxSd6PLBMq8mZYmxeAvnx7ssLjpTzvnxLssbny3dIvirVjn4EbNrf7MwDk7GqKu2folsqQDtjXH9zA+sFUAgHIxVZKfIMgFcosrjbTesLlZH5TZMLWNa503SFzE2Pq2eC1Ruw9QyASpj/1xUZsPUEgFKs78KpDG/vK5MGFgiQHH2N6azRsON5XWh+VhlwsASAczb32i8xsWJTo7/4StcnOhc93t+WB8GZU/VzNs/28CTA328MfwY8UQ9vxl7hP7IFV5+4k39mXwrSwHQ6AmPcPlWh6ld30Io2wGF7Z/t3dnlAIdPP8+poR6m5ptIyhJT802cGurh8OnnmV0OkN8N1odB2QLkt3+2MWdHH+XJV09w7uY+ZpezC5nctcSTr57g7Oij5BfB2jAoS4D89lcwFjInB/p44e1nuRbdvelcPilLXIvu5oW3n+XkQN/7ysjC2tGgLAECUorPhUf1K6P3N7d8oZiVFZeJkMhQ/vzea2zx3S+Hfk5pJaLOm9RPfcFIUIUsXwj5Q8IYOgV2Ndwl6Cs/D5QlgNctMznfRKbxxVn+AUjr3yqg5obEiXNfzrmVGSkm5kL4qtbKoQ+UsSUGUO1ZQ9FEtviWcQkqF979OC8O9nJ29ACxRAOKWpS+xsP/3VhUNZHFFT/XZ1q4MrUXl6gS9Ce4vdDE6b99iuFoK/PJmlLp65WVsSUWkFI0BxZprllE0URmluqYWaqzfNMCMm7bVnuPbbX38LplYokGYkv1LKT8ZZVblgCioCEIGqKgoWoCmiZYsk9nZ31lzQRVTQBNsO2grBL1iXzwDvrshCICf3aahYOYFMn8o/LDiq+LQBQ44zQTB3AL+JMx9vYDzvxjwRksAx+F7ORDA1qBSw4RshMTQCOwBuunwipwCOhwgFSloQA3gMeBMLBq3PgPsH6q+iD6RQEAAAAASUVORK5CYII=`; ppixiv.resources["resources/followed-users-eye.svg"] = ` image/svg+xml `; ppixiv.resources["resources/fullscreen.svg"] = ` `; ppixiv.resources["resources/heart-icon.svg"] = ` `; ppixiv.resources["resources/last-page.svg"] = ` `; ppixiv.resources["resources/like-button.svg"] = ` `; ppixiv.resources["resources/link-icon.svg"] = ` `; ppixiv.resources["resources/main.css"] = `/* line 1, resources/main.css */ * { box-sizing: border-box; } /* line 2, resources/main.css */ html { overflow: hidden; } /* line 5, resources/main.css */ body { font-family: "Helvetica Neue", arial, sans-serif; } /* line 9, resources/main.css */ a { text-decoration: none; /*color: #fff;*/ color: inherit; } /* Theme colors: */ /* line 16, resources/main.css */ 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.css */ 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.css */ ul { padding: 0; margin: 0; } /* line 96, resources/main.css */ .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.css */ .screen-illust-container { width: 100%; height: 100%; } /* line 107, resources/main.css */ .image-container, .preview-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none; -moz-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 120, resources/main.css */ .image-container > img { will-change: width, height, transform; } /* line 128, resources/main.css */ [hidden] { display: none !important; } /* line 132, resources/main.css */ 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 138, resources/main.css */ .grecaptcha-badge { display: none !important; } /* line 142, resources/main.css */ .main-container { position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; overflow: hidden; } /* line 150, resources/main.css */ .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 160, resources/main.css */ .progress-bar.hide { animation: flash-progress-bar 500ms linear 1 forwards; } /* line 164, resources/main.css */ .loading-progress-bar .progress-bar { z-index: 100; } /* .seek-bar is the outer seek bar area, which is what can be dragged. */ /* line 169, resources/main.css */ .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 181, resources/main.css */ .seek-bar .seek-empty { height: 100%; background-color: rgba(0, 0, 0, 0.25); } /* line 186, resources/main.css */ .seek-bar .seek-fill { background-color: #F00; height: 100%; } /* line 191, resources/main.css */ .seek-bar .seek-empty { transition: transform .25s; transform: translate(0, 12px); } /* line 198, resources/main.css */ .mouse-hidden-box.show-cursor .seek-bar .seek-empty, .seek-bar.visible .seek-empty { transform: translate(0, 6px); } /* line 204, resources/main.css */ .seek-bar.dragging .seek-empty { transform: translate(0, 0); } /* line 209, resources/main.css */ .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 216, resources/main.css */ .small-font { font-size: 0.8em; } /* line 220, resources/main.css */ .hover-message, .search-results > .no-results { width: 100%; position: absolute; bottom: 0px; display: flex; justify-content: center; } /* line 228, resources/main.css */ .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 241, resources/main.css */ .hover-message { transition: opacity .25s; opacity: 0; pointer-events: none; z-index: 100; } /* line 247, resources/main.css */ .hover-message.show { opacity: 1; } /* The version in the search container is always centered. */ /* line 253, resources/main.css */ .search-results > .no-results { bottom: 50%; } /* line 258, resources/main.css */ .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 272, resources/main.css */ .screen-illust-container .ui .disabled { display: none; } /* line 277, resources/main.css */ .screen-illust-container .ui-box { pointer-events: 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 298, resources/main.css */ .hover-box { width: 400px; height: 200px; position: absolute; top: 0; left: 0; pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } /* line 307, resources/main.css */ .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 319, resources/main.css */ .hover-sphere circle { pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } /* line 323, resources/main.css */ .hover-sphere > svg { width: 100%; height: 100%; transform: translate(-50%, -50%); } /* line 330, resources/main.css */ .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; } /* line 338, resources/main.css */ .ui-box .author { vertical-align: top; } /* line 343, resources/main.css */ .screen-illust-container .ui-box { margin: .5em; } /* line 347, resources/main.css */ .screen-manga-container .ui-container { width: 600px; max-width: 90%; pointer-events: auto; } /* 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 357, resources/main.css */ .screen-manga-container .ui-box > .description, .screen-manga-container .ui-box > .tag-list { display: none; } /* line 364, resources/main.css */ .screen-illust-container .ui-box { transition: transform .25s, opacity .25s; opacity: 0; transform: translate(-50px, 0); pointer-events: none; } /* Debugging: */ /* line 372, resources/main.css */ body.force-ui .screen-illust-container .ui > .ui-box { opacity: 1; transform: translate(0, 0); pointer-events: inherit; } /* Show the UI on hover when hide-ui isn't set. */ /* line 380, resources/main.css */ body:not(.hide-ui) .screen-illust-container .ui-box.hovering-over-box, body:not(.hide-ui) .screen-illust-container .ui-box.hovering-over-sphere { opacity: 1; transform: translate(0, 0); pointer-events: auto; } /* line 388, resources/main.css */ .button-row { display: flex; flex-direction: row; align-items: center; height: 32px; margin-top: 5px; margin-bottom: 4px; } /* line 396, resources/main.css */ .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 403, resources/main.css */ .title-with-button-row { display: flex; flex-direction: row; align-items: start; } /* An icon in a button strip. */ /* line 410, resources/main.css */ .icon-button { display: block; width: 32px; height: auto; /* If this is an icon-button with an svg inside, set the svg to block. */ } /* line 416, resources/main.css */ .icon-button svg { display: block; } /* line 421, resources/main.css */ .disable-ui-button:hover > .icon-button { color: #0096FA; } /* line 424, resources/main.css */ .whats-new-button.updates > svg { color: #cc0; } /* line 427, resources/main.css */ body.light .whats-new-button.updates > svg { color: #0aa; /* yellow doesn't work in a light theme */ } /* line 431, resources/main.css */ .navigate-out-button { cursor: pointer; } /* line 435, resources/main.css */ .popup-menu-box .menu-toggle { display: block; } /* line 438, resources/main.css */ .menu-slider input { vertical-align: middle; width: 100%; padding: 0; margin: 0; } /* line 445, resources/main.css */ .popup.avatar-popup:hover:after { left: auto; bottom: auto; top: 60px; right: -10px; } /* line 452, resources/main.css */ .follow-container { /* For the avatar in the popup menu, use the same size as the other popup menu buttons. */ } /* line 453, resources/main.css */ .follow-container .avatar { transition: filter .25s; display: block; position: relative; /* .avatar contains an image, and a canvas overlaid on top for hover effects. */ } /* line 459, resources/main.css */ .follow-container .avatar > canvas { border-radius: 5px; object-fit: cover; width: 100%; height: 100%; position: absolute; top: 0; left: 0; } /* line 469, resources/main.css */ .follow-container .avatar > canvas.highlight { opacity: 0; transition: opacity .25s; } /* line 474, resources/main.css */ .follow-container .avatar:hover > canvas.highlight { opacity: 1; } /* line 479, resources/main.css */ .follow-container:not(.big) .avatar { width: 50px; height: 50px; } /* line 484, resources/main.css */ .follow-container.big .avatar { width: 170px; height: 170px; } /* line 490, resources/main.css */ .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 498, resources/main.css */ .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 507, resources/main.css */ .follow-icon .lock { stroke: #888; } /* line 511, resources/main.css */ .follow-icon:not(.private) .lock { display: none !important; } /* line 516, resources/main.css */ .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 517, resources/main.css */ .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 525, resources/main.css */ .follow-container .follow-icon.bottom-left { left: 0; } /* line 529, resources/main.css */ .follow-container .follow-icon.bottom-right { right: 0; } /* line 534, resources/main.css */ .follow-container .follow-icon:not(:hover) .outline1 { stroke: none !important; } /* line 539, resources/main.css */ .follow-container:not(.followed) .follow-icon.following-icon { display: none; } /* line 544, resources/main.css */ .follow-container.followed .follow-icon.follow-button { display: none; } /* line 549, resources/main.css */ .follow-container:not(:hover) .follow-icon.follow-button { display: none; } /* line 555, resources/main.css */ .follow-container[data-mode="dropdown"] .follow-icon.follow-button { display: none; } /* line 560, resources/main.css */ .follow-container.self .follow-icon, .follow-container.self .follow-popup { display: none; } /* line 571, resources/main.css */ .follow-container:not(.big) .follow-button { top: calc(100% - 5px); } /* line 575, resources/main.css */ .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 586, resources/main.css */ .follow-container:not(:hover) .follow-icon > svg { opacity: 0.5; } /* line 592, resources/main.css */ .popup-context-menu .follow-container .follow-icon > svg { opacity: 1; } /* line 596, resources/main.css */ .follow-container .follow-icon > svg .middle { transition: transform .1s ease-in-out; transform: translate(0px, -2px); } /* line 601, resources/main.css */ .follow-container .follow-icon.unfollow-button > svg .middle { transform: translate(-2px, -5px); } /* line 605, resources/main.css */ .follow-container .follow-icon.unfollow-button:hover > svg .middle { transform: translate(2px, 5px); } /* line 609, resources/main.css */ .follow-container .follow-popup { margin-top: 10px; right: 0px; } /* line 613, resources/main.css */ .follow-container .follow-popup .folder { display: block; } /* line 617, resources/main.css */ .follow-container.followed .follow-container .follow-popup .not-following { display: none; } /* line 618, resources/main.css */ .follow-container:not(.followed) .follow-container .follow-popup .following { display: none; } /* line 620, resources/main.css */ .follow-container .follow-popup input { margin: .25em; padding: .25em; } /* line 626, resources/main.css */ .follow-container .hover-area { top: -12px; } /* line 630, resources/main.css */ .follow-container .avatar-link { display: block; } /* Hide the follow dropdown when following, since there's nothing in it. */ /* line 636, resources/main.css */ .follow-container.followed.popup-visible .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 640, resources/main.css */ .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 648, resources/main.css */ .title-block.popup:hover:after { top: 40px; bottom: auto; } /* When .dot is set, show images with nearest neighbor filtering. */ /* line 655, resources/main.css */ body.dot img.filtering, body.dot canvas.filtering { image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; image-rendering: pixelated; } /* line 661, resources/main.css */ .bulb-button:hover > .icon-button { color: #FF0 !important; /* override grey-icon hover color */ } /* line 665, resources/main.css */ body.light .bulb-button:hover > .icon-button { stroke: #000; } /* line 669, resources/main.css */ .bulb-button > .icon-button { margin-top: -3px; } /* line 674, resources/main.css */ .extra-profile-link-button .default-icon svg { transform: translate(0, 2px); } /* line 679, resources/main.css */ .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 689, resources/main.css */ .description { border: solid 1px var(--ui-border-color); padding: .35em; background-color: var(--ui-bg-section-color); max-height: 10em; overflow-y: auto; } /* line 696, resources/main.css */ body.light .description { border: none; } /* Override obnoxious colors in descriptions. Why would you allow this? */ /* line 700, resources/main.css */ .description * { color: var(--ui-fg-color); } /* line 704, resources/main.css */ .popup { position: relative; } /* line 708, resources/main.css */ .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 723, resources/main.css */ .popup-bottom:hover:after { top: auto; bottom: -2em; } /* line 728, resources/main.css */ body:not(.premium) .premium-only { display: none; } /* line 729, resources/main.css */ body.hide-r18 .r18 { display: none; } /* line 730, resources/main.css */ body.hide-r18g .r18g { display: none; } /* line 732, resources/main.css */ .popup-menu-box { position: absolute; min-width: 10em; background-color: var(--frame-bg-color); border: 1px solid var(--frame-border-color); padding: .25em .5em; z-index: 2; } /* line 741, resources/main.css */ .menu-button { cursor: pointer; } /* line 745, resources/main.css */ .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 748, resources/main.css */ .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 753, resources/main.css */ .hover-area { position: absolute; top: -50%; left: -33%; width: 150%; height: 200%; z-index: -1; } /* This one is under the bookmark popup. Extend over the bottom, so the list doesn\\'t disappear * when deleting a recent bookmark at the bottom of the list, but don\\'t extend over the top, so * we don\\'t block the mouse hovering over other things. * * Note that the positioning of this is important: we want to fully close the gap between the * popup and the bottom that opened it, but we don't want to overlap the button and block it. */ /* line 767, resources/main.css */ .navigation-menu-box .hover-area, .settings-menu-box .hover-area, .image-settings-menu-box .hover-area { top: -2px; height: 125%; } /* line 775, resources/main.css */ .popup-menu-box .button { padding: .25em; cursor: pointer; width: 100%; } /* line 781, resources/main.css */ .popup-menu-box .button:hover { background-color: var(--dropdown-menu-hover-color); } /* line 785, resources/main.css */ .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 803, resources/main.css */ 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 819, resources/main.css */ 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 827, resources/main.css */ body.ui-on-hover .top-ui-box.hover, body.ui-on-hover .top-ui-box.force-open { transform: translateY(100%); } /* line 833, resources/main.css */ 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 842, resources/main.css */ 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 848, resources/main.css */ .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 859, resources/main.css */ .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 871, resources/main.css */ .search-results .thumbnail-ui-box .displaying { padding-bottom: 4px; } /* line 874, resources/main.css */ .search-results .thumbnail-ui-box .displaying .word { padding: 0px 5px; } /* line 878, resources/main.css */ .search-results .thumbnail-ui-box .displaying .word:first-child { padding-left: 0px; /* remove left padding from the first item */ } /* line 882, resources/main.css */ .search-results .thumbnail-ui-box .displaying .word.or { font-size: 12px; padding: 0; color: #bbb; } /* line 888, resources/main.css */ .search-results .thumbnail-ui-box .bookmarks-link, .search-results .thumbnail-ui-box .following-link { display: block; } /* line 893, resources/main.css */ .search-results .thumbnail-ui-box .following-link { width: 32px; height: 32px; } /* line 898, resources/main.css */ .search-results .thumbnail-ui-box .contact-link { display: block; width: 31px; height: 31px; margin: 0 3px; } /* line 905, resources/main.css */ .search-results .thumbnail-ui-box .webpage-link { display: block; margin: 0 2px; width: 26px; height: 26px; } /* line 912, resources/main.css */ .search-results .thumbnail-ui-box .twitter-icon, .search-results .thumbnail-ui-box .pawoo-icon { display: block; width: 32px; height: 32px; margin: 0 0px; } /* line 921, resources/main.css */ .search-results .thumbnail-ui-box .circlems-icon { display: block; margin: 4px 0 0 0; } /* line 929, resources/main.css */ .search-results .thumbnails { user-select: none; -moz-user-select: none; padding: 0; text-align: center; } /* line 936, resources/main.css */ .search-results .thumbnails { display: flex; flex-wrap: wrap; justify-content: center; margin: 0 auto; /* center */ } /* line 946, resources/main.css */ .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 960, resources/main.css */ .thumbnail-load-previous { width: 100%; } /* line 963, resources/main.css */ .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 974, resources/main.css */ .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 987, resources/main.css */ .thumbnail-load-previous > .load-previous-buttons > .load-previous-button:hover { background-color: var(--box-link-hover-color); } /* line 994, resources/main.css */ .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 996, resources/main.css */ .thumbnail-box[data-pending] { visibility: hidden; } /* line 999, resources/main.css */ .thumbnail-box .thumbnail-inner { position: relative; } /* line 1011, resources/main.css */ .thumbnail-box:not([data-nearby]) .thumbnail-inner { content-visibility: hidden; } /* line 1015, resources/main.css */ .thumbnail-box a.thumbnail-link { display: block; width: 100%; height: 100%; border-radius: 4px; overflow: hidden; position: relative; text-decoration: none; color: #fff; } /* line 1028, resources/main.css */ .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 1037, resources/main.css */ .page-count-box .page-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; } /* line 1044, resources/main.css */ .page-count-box:hover .regular { display: none; } /* line 1047, resources/main.css */ .page-count-box:not(:hover) .hover { display: none; } /* line 1051, resources/main.css */ .page-count-box .page-count { vertical-align: middle; margin-left: -4px; } /* The similar illusts button on top of thumbnails. */ /* line 1060, resources/main.css */ .screen-search-container .thumbnail-box .similar-illusts-button { display: block; width: 32px; height: 32px; margin-top: -2px; } /* line 1067, resources/main.css */ .screen-search-container .thumbnail-box:not(:hover) .similar-illusts-button { visibility: hidden; } /* line 1071, resources/main.css */ .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 1080, resources/main.css */ .screen-search-container .thumbnail-box .similar-illusts-button:hover { opacity: 1; stroke: #000; } /* line 1085, resources/main.css */ .screen-search-container .thumbnail-box .thumbnail-bottom-left { position: absolute; display: flex; left: 0px; bottom: 0px; } /* line 1091, resources/main.css */ .screen-search-container .thumbnail-box .heart { pointer-events: none; width: 32px; height: 32px; } /* line 1096, resources/main.css */ .screen-search-container .thumbnail-box .heart > svg { transition: opacity .5s; } /* line 1100, resources/main.css */ .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 1112, resources/main.css */ .screen-search-container .thumbnail-inner:hover .heart > svg { opacity: 0.5; } /* line 1115, resources/main.css */ .screen-search-container .thumbnail-inner:hover .ugoira-icon { opacity: 0.5; } /* line 1122, resources/main.css */ .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 1133, resources/main.css */ .thumbnail-box[data-pending] .thumb { display: none; } /* line 1138, resources/main.css */ .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 1156, resources/main.css */ .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 1164, resources/main.css */ .thumbnail-box .thumb { transition: transform .5s; transform: scale(1, 1); } /* line 1174, resources/main.css */ 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 1182, resources/main.css */ .thumbnail-box.vertical-panning .thumb, .thumbnail-box.horizontal-panning .thumb { animation-duration: 4s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; } /* line 1193, resources/main.css */ .thumbnail-box .thumbnail-inner:not(:hover) .thumb, body.pause-thumbnail-animation .thumbnail-box .thumb { animation-play-state: paused; } /* line 1198, resources/main.css */ 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 1209, resources/main.css */ 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 1231, resources/main.css */ .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 1232, resources/main.css */ .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 1247, resources/main.css */ .thumbnail-box.muted .thumb { filter: blur(10px); transform: scale(1.25, 1.25); } /* line 1252, resources/main.css */ body:not(.disable-thumbnail-zooming) .thumbnail-box.muted .thumb:hover { transform: scale(1, 1); } /* line 1257, resources/main.css */ .thumbnail-box:not(.muted) .muted-text { display: none; } /* line 1263, resources/main.css */ .screen-search-container .following-tag { text-decoration: none; } /* line 1267, resources/main.css */ .screen-search-container .search-options-row { display: flex; flex-direction: row; flex-wrap: wrap; } /* line 1272, resources/main.css */ .screen-search-container .search-options-row .hover-area { top: -10px; height: 150%; } /* line 1278, resources/main.css */ .screen-search-container .right-side-button { display: inline-block; vertical-align: middle; cursor: pointer; user-select: none; } /* line 1284, resources/main.css */ .screen-search-container .right-side-button > svg { vertical-align: middle; } /* line 1290, resources/main.css */ .screen-search-container .edit-search-button { margin-left: -58px; /* overlap the input */ } /* line 1295, resources/main.css */ .box-link { display: inline-block; cursor: pointer; text-decoration: none; padding: .25em .5em; margin: .25em .25em; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); user-select: none; -moz-user-select: none; white-space: nowrap; } /* line 1307, resources/main.css */ .box-link.disabled { color: var(--box-link-disabled-color); } /* line 1311, resources/main.css */ .box-link:hover { background-color: var(--box-link-hover-color); } /* line 1315, resources/main.css */ .box-link.selected { background-color: var(--box-link-selected-color); } /* line 1319, resources/main.css */ .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 1328, resources/main.css */ .option-list { display: flex; flex-direction: column; } /* line 1332, resources/main.css */ .search-options-row > div.active > .box-link { background-color: var(--box-link-selected-color); } /* line 1335, resources/main.css */ .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 1343, resources/main.css */ .tag-search-box { display: inline-block; position: relative; } /* line 1348, resources/main.css */ input.search-users { font-size: 1.2em; padding: 6px 10px; vertical-align: middle; padding-right: 30px; /* extra space for the submit button */ } /* line 1354, resources/main.css */ .user-search-box .search-submit-button { margin-left: -30px; /* overlap the input */ } /* line 1357, resources/main.css */ input.search-tags { font-size: 1.2em; padding: 6px 10px; vertical-align: middle; } /* Search box in the search page: */ /* line 1364, resources/main.css */ .tag-search-box input.search-tags { padding-right: 60px; /* extra space for the submit button */ } /* line 1368, resources/main.css */ .search-submit-button { /* Work around HTML's stupid whitespace handling */ font-size: 0; display: inline-block; } /* Search box in the menu: */ /* line 1376, resources/main.css */ .navigation-search-box .search-submit-button { vertical-align: middle; margin-left: -30px; /* overlap the search box */ } /* line 1380, resources/main.css */ .navigation-search-box input.search-tags { padding-right: 30px; /* extra space for the submit button */ } /* line 1385, resources/main.css */ .thumbnail-ui-box .avatar-container { float: right; position: relative; margin-left: 25px; } /* line 1391, resources/main.css */ .image-for-suggestions { float: right; margin-left: 25px; } /* line 1395, resources/main.css */ .image-for-suggestions > img { display: block; max-height: 150px; border-radius: 5px; /* matches the avatar display */ } /* line 1402, resources/main.css */ .grey-icon { color: var(--button-color); /* If a grey-icon is directly inside a visible popup menu, eg. the navigation icon: */ } /* line 1405, resources/main.css */ :hover > .grey-icon { color: var(--button-highlight-color); } /* line 1410, resources/main.css */ .popup-visible > .grey-icon { color: var(--button-highlight-color); } /* line 1416, resources/main.css */ .mute-display .muted-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(20px); opacity: .75; } /* line 1427, resources/main.css */ .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 1440, resources/main.css */ .member-tags-box .post-tag-list, .search-tags-box .related-tag-list { max-height: 300px; display: block; overflow-x: hidden; overflow-y: auto; white-space: nowrap; } /* line 1448, resources/main.css */ .member-tags-box .post-tag-list .following-tag, .search-tags-box .related-tag-list .tag { display: block; } /* line 1453, resources/main.css */ .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 1460, resources/main.css */ .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 1480, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list { display: flex; flex-direction: column; white-space: normal; } /* line 1486, resources/main.css */ .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 1495, resources/main.css */ .input-dropdown .input-dropdown-list > .entry .search { color: #000; flex: 1; padding-left: 7px; height: 100%; } /* line 1502, resources/main.css */ .input-dropdown .input-dropdown-list > .entry .search .word { display: inline-flex; align-items: center; height: 100%; padding: 0px 5px; } /* line 1508, resources/main.css */ .input-dropdown .input-dropdown-list > .entry .search .word.or { font-size: 12px; padding: 0; color: #333; } /* line 1517, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list { /* Hide the button to remove history entries from non-history entries. */ } /* line 1518, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry .suggestion-icon { margin: 2px -2px 0 2px; } /* line 1521, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.autocomplete) .suggestion-icon { display: none; } /* line 1525, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry.selected { background-color: #ffa; } /* line 1529, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover { background-color: #ddd; } /* line 1533, resources/main.css */ .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 1549, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry { display: none; } /* line 1553, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover .remove-history-entry { visibility: visible; } /* line 1556, resources/main.css */ .search-history > .input-dropdown > .input-dropdown-list .remove-history-entry:hover { color: #000; background-color: #c0c0c0; } /* line 1563, resources/main.css */ .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 1567, resources/main.css */ .edit-search > .input-dropdown > .input-dropdown-list { white-space: normal; max-width: 100%; } /* line 1571, resources/main.css */ .edit-search > .input-dropdown > .input-dropdown-list > .entry { display: inline-flex; } /* line 1574, resources/main.css */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight { background-color: #eeee00; } /* line 1575, resources/main.css */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag:hover { background-color: #0099FF; } /* line 1576, resources/main.css */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight:hover { background-color: #00CCFF; } /* line 1583, resources/main.css */ .manga-thumbnail-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 240px; max-height: 30%; user-select: none; -moz-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 1594, resources/main.css */ body.hide-ui .manga-thumbnail-container { display: none; } /* line 1602, resources/main.css */ .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 1613, resources/main.css */ .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 1626, resources/main.css */ .manga-thumbnail-container.visible > .strip { opacity: 1; transform: translate(0, 0); } /* line 1632, resources/main.css */ .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 1642, resources/main.css */ .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 1657, resources/main.css */ .manga-thumbnail-arrow { height: 100%; width: 30px; margin: 0 6px; } /* line 1663, resources/main.css */ .manga-thumbnail-arrow > svg { fill: #888; } /* line 1667, resources/main.css */ .manga-thumbnail-arrow:hover > svg { fill: #ff0; } /* line 1672, resources/main.css */ body.light .manga-thumbnail-arrow { stroke: #aa0; } /* line 1677, resources/main.css */ .manga-thumbnail-arrow > svg { display: block; height: 100%; width: 100%; padding: 4px; } /* line 1686, resources/main.css */ .thumb-list-cursor { position: absolute; left: 0; bottom: -6px; width: 40px; height: 4px; background-color: var(--ui-fg-color); border-radius: 2px; } /* The right click context menu for the image view: */ /* line 1698, resources/main.css */ .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; -moz-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 1715, resources/main.css */ .popup-context-menu.visible { opacity: 1; } /* line 1719, resources/main.css */ .popup-context-menu:not(.visible) { opacity: 0; pointer-events: none; transform: scale(0.85); } /* line 1725, resources/main.css */ .popup-context-menu > * { transform-origin: unset; } /* line 1730, resources/main.css */ .popup-context-menu .popup:hover:after { display: none; } /* line 1734, resources/main.css */ .popup-context-menu .tooltip-display { display: flex; align-items: stretch; padding: 10px 0 0 8px; pointer-events: none; } /* line 1741, resources/main.css */ .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 1748, resources/main.css */ .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 1752, resources/main.css */ .popup-context-menu .button-strip > .button-block { display: inline-block; background-color: var(--frame-bg-color); padding: 12px; } /* line 1759, resources/main.css */ .popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; } /* line 1763, resources/main.css */ .popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; } /* line 1766, resources/main.css */ .popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; } /* line 1767, resources/main.css */ .popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; } /* line 1769, resources/main.css */ .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 1782, resources/main.css */ .popup-context-menu .button-strip .button:not(.enabled) { cursor: inherit; color: var(--toggle-button-fg-disabled-color); } /* line 1788, resources/main.css */ .popup-context-menu .button-strip .button > * { min-width: 32px; } /* line 1791, resources/main.css */ .popup-context-menu .button-strip .button > svg { width: 32px; height: 32px; } /* line 1796, resources/main.css */ .popup-context-menu .button-strip .button.enabled:hover { color: var(--toggle-button-fg-color); } /* line 1800, resources/main.css */ .popup-context-menu .button-strip .button.enabled.selected { background-color: var(--toggle-button-bg-color); color: var(--toggle-button-fg-color); } /* line 1807, resources/main.css */ .popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; } /* line 1808, resources/main.css */ .popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; } /* line 1811, resources/main.css */ .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 1819, resources/main.css */ body.light .popup-context-menu .button-strip .button .tag-dropdown-arrow { border-top-color: #ccc; } /* line 1826, resources/main.css */ .popup-context-menu .button-strip > .button-block.shift-left { margin-left: -56px; } /* line 1831, resources/main.css */ .popup-context-menu .button-strip .button-send-image svg .arrow { transition: transform ease-in-out .15s; } /* line 1835, resources/main.css */ .popup-context-menu .button-strip .button-send-image.enabled:hover svg .arrow { transform: translate(2px, -2px); } /* line 1841, resources/main.css */ .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 1850, resources/main.css */ .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 1860, resources/main.css */ .popup-bookmark-tag-dropdown { background-color: var(--frame-bg-color); color: var(--frame-fg-color); position: absolute; padding: 6px 8px; top: calc(100%); left: 0; 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 1874, resources/main.css */ .popup-context-menu .popup-bookmark-tag-dropdown { top: calc(100% - 4px); } /* line 1878, resources/main.css */ .popup-bookmark-tag-dropdown > .tag-list { display: flex; flex-direction: column; min-width: 200px; overflow-x: hidden; overflow-y: auto; } /* line 1886, resources/main.css */ .popup-bookmark-tag-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 1896, resources/main.css */ .popup-bookmark-tag-dropdown > .tag-right-button-strip .tag-button { cursor: pointer; } /* line 1902, resources/main.css */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry { display: flex; flex-direction: row; align-items: center; padding: 4px 0px; display: flex; cursor: pointer; } /* line 1909, resources/main.css */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry > .tag-name { flex: 1; } /* line 1912, resources/main.css */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active { background-color: #008; } /* line 1915, resources/main.css */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active { background-color: #00c; color: #fff; } /* line 1919, resources/main.css */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active:hover { background-color: #00a; } /* line 1922, resources/main.css */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active:hover { background-color: #00a; } /* line 1925, resources/main.css */ .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:not(.active):hover { background-color: #222; } /* line 1928, resources/main.css */ body.light .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:not(.active):hover { background-color: #ccc; } /* line 1934, resources/main.css */ .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; } /* line 1954, resources/main.css */ .ui-box .button > svg { display: block; } /* line 1958, resources/main.css */ .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; } /* 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 1970, resources/main.css */ .popup-context-menu .button.button-bookmark.public > svg { margin-top: -10px; } /* line 1974, resources/main.css */ .popup-context-menu .button.button-like > svg { margin-top: -2px; } /* Bookmark buttons. These appear in image_ui and the popup menu. */ /* line 1980, resources/main.css */ .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 1986, resources/main.css */ svg.heart-image .delete { display: none; } /* These are !important to override the default white coloring in the context * menu. */ /* line 1992, resources/main.css */ .button-bookmark { color: #400 !important; } /* line 1994, resources/main.css */ .button-bookmark.enabled { color: #800 !important; stroke: none; } /* line 1999, resources/main.css */ .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 2008, resources/main.css */ .screen-search-container .thumbnails .button-bookmark svg > .heart { stroke: #000; stroke-width: .5px; } /* line 2013, resources/main.css */ .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 2018, resources/main.css */ .button.button-like > svg { color: var(--like-button-color); } /* line 2022, resources/main.css */ .button.button-like.liked > svg { color: var(--like-button-liked-color); } /* line 2025, resources/main.css */ .button.button-like.enabled:hover > svg { color: var(--like-button-hover-color); } /* line 2031, resources/main.css */ .button.button-browser-back .arrow { transition: transform ease-in-out .15s; transform: translate(-2px, 0px); } /* line 2035, resources/main.css */ .button.button-browser-back:hover .arrow { transform: translate(1px, 0px); } /* line 2040, resources/main.css */ .button.button-like > svg > * { transition: transform ease-in-out .15s; transform: translate(0, 0px); } /* line 2044, resources/main.css */ .button.button-like > svg > .mouth { transform: scale(1, 0.75); } /* line 2048, resources/main.css */ .button.button-like.liked > svg > * { transform: translate(0, -3px); } /* line 2051, resources/main.css */ .button.button-like.liked > svg > .mouth { transform: scale(1, 1.1) translate(0, -3px); } /* line 2054, resources/main.css */ .button.button-like.enabled:hover > svg > * { transform: translate(0, -2px); } /* line 2057, resources/main.css */ .button.button-like.enabled:hover > svg > .mouth { transform: scale(1, 0.9) translate(0, -3px); } /* line 2060, resources/main.css */ .button-bookmark.public svg.heart-image .lock { display: none; } /* line 2063, resources/main.css */ .button-bookmark svg.heart-image .lock { stroke: #888; } /* line 2067, resources/main.css */ .whats-new-box { 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 2079, resources/main.css */ .whats-new-box .content { font-size: 18px; width: 80%; max-width: 800px; height: 80%; background-color: var(--ui-bg-color); color: var(--ui-fg-color); border-radius: 5px; position: relative; } /* line 2089, resources/main.css */ .whats-new-box .content > .scroll { width: 100%; height: 100%; overflow-y: auto; padding: 1em; } /* line 2097, resources/main.css */ .whats-new-box .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 2106, resources/main.css */ .whats-new-box .close-button:hover { color: var(--button-highlight-color); } /* line 2109, resources/main.css */ .whats-new-box .close-button > svg { display: block; } /* line 2114, resources/main.css */ .whats-new-box .header { font-size: 40px; margin-bottom: 20px; } /* line 2119, resources/main.css */ .whats-new-box .rev { display: inline-block; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); padding: 5px 10px; } /* line 2125, resources/main.css */ .whats-new-box .text { margin: 1em 0; padding: 0 20px; /* inset horizontally a bit */ } /* line 2132, resources/main.css */ .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 2139, resources/main.css */ .screen-illust-container .page-change-indicator[data-side="left"] { margin-left: 20px; left: 0; } /* line 2144, resources/main.css */ .screen-illust-container .page-change-indicator[data-side="right"] { margin-right: 20px; right: 0; } /* line 2149, resources/main.css */ .screen-illust-container .page-change-indicator[data-side="right"] svg { transform-origin: center center; transform: scale(-1, 1); } /* line 2155, resources/main.css */ .screen-illust-container .page-change-indicator[data-icon="last-page"] svg .bar { display: none; } /* line 2158, resources/main.css */ .screen-illust-container .page-change-indicator svg { opacity: 0; } /* line 2161, resources/main.css */ .screen-illust-container .page-change-indicator.flash svg { animation: flash-page-change-opacity 400ms ease-out 1 forwards; } /* line 2165, resources/main.css */ .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 2183, resources/main.css */ .popup-send-image-dropdown { position: absolute; top: calc(100%); /* Center this to the center of the button. The list below will shift itself left by * 50% of its width to center relative to this. */ left: 50%; /* 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. */ top: calc(100% - 4px); transition: transform .15s, opacity .15s; transform: scale(0.85); transform-origin: 0 0; opacity: 0; } /* line 2203, resources/main.css */ .popup-send-image-dropdown.visible { display: block; transform: scale(1); opacity: 1; } /* line 2208, resources/main.css */ .popup-send-image-dropdown:not(.visible) { pointer-events: none; } /* line 2212, resources/main.css */ .popup-send-image-dropdown .tutorial-monitor { width: 290px; height: 125px; margin-bottom: -20px; } /* line 2220, resources/main.css */ .popup-send-image-dropdown .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 2234, resources/main.css */ .popup-send-image-dropdown .list-box { background-color: var(--frame-bg-color); color: var(--frame-fg-color); padding: 15px; border-radius: 15px; border: 1px solid var(--minor-text-fg-color); position: relative; left: -50%; } /* line 2243, resources/main.css */ .popup-send-image-dropdown .list-box .list { position: relative; } /* line 2246, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry { display: flex; flex-direction: row; align-items: center; border-radius: 3px; border: 2px solid black; display: flex; } /* line 2254, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.tab-entry { background-color: #880; position: absolute; } /* line 2259, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.tab-entry .shown-image-container { width: 100%; height: 100%; position: relative; overflow: hidden; } /* line 2264, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.tab-entry .shown-image-container img.shown-image { position: absolute; } /* line 2270, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.tab-entry:not(.self) { background-color: var(--ui-bg-section-color); cursor: pointer; filter: brightness(70%); } /* line 2276, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.tab-entry:not(.self):hover { z-index: 1; filter: brightness(100%); } /* line 2279, resources/main.css */ body.light .popup-send-image-dropdown .list-box .list .entry.tab-entry:not(.self):hover { background-color: #ccc; } /* line 2288, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry.self { background-color: #222 !important; filter: brightness(60%); } /* line 2294, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry .search-tab { background-color: var(--ui-bg-color); width: 100%; height: 100%; position: relative; } /* line 2300, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry .search-tab .fake-header { display: flex; position: sticky; top: 5px; height: 20%; justify-content: center; } /* line 2307, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry .search-tab .fake-header .bar { width: 50%; height: 100%; background-color: var(--ui-bg-section-color); } /* line 2314, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry .search-tab .fake-thumbnails { display: flex; flex-wrap: wrap; width: 100%; justify-content: center; gap: 4px; } /* line 2320, resources/main.css */ .popup-send-image-dropdown .list-box .list .entry .search-tab .fake-thumbnails .fake-thumb { background-color: #008288; width: 40px; height: 40px; } /*# sourceMappingURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/output/main.css.map */`; ppixiv.resources["resources/main.html"] = `
`; 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/U4TI4JVFeFqptSFY4AWWWIaWRSpakdWc3yd/TVMcPfPnKjnJ1edVgj3KGIouqjAAe+HqaLaf23gICRG+HCMZIE0GGGMtO/nSIPH2n+h8QFsg3QkAYP2B/ZLwwgbhJGNYYyNbQbPSTFVXAqRjXMqCjiyHKjVUtx0VE6mIZLUuqAOlba0wENlSVWx+ypILWoBOSrqpGiPEUmZtOn5TTsaA9l2KALTURVMqxNX1GDNJUK0ruL6FlH0ykHKRCChCrtBljqcgFQNvoD39RRRSY50OeK4pexfYFrV6WSoahXrO8aqCtLUrZCDwlwxouIYRZMKCXSExmZ0wAySYeSzvT6MhNH7b8bnGDiy9pcN1tbDjL3vX8egM0doOOcwgGUADbOfDZ067lxnsDtVPIVMrVIBQo2RKmV+mSCk8grS2WptVPA4JoCTFjA3THnnJYAJYgRVpHbiyk2mkMKooMl+MuhPO++LFVuolagclxqQ0mjocVUGcZRTh/0R1QgyyghTqZ07glSn0sBMKAd9dTsUJKzgClUxp1Lp1y8FE4EoEeHcKY1KVZeGU9nBJh6oTnHltlQEo7szxkmsjF6/CITjUEGNrqrKilt3j1ej8o1cEU8OlUQR01ahvn++0X1FX80P1fse20NaDck+TanrH3KrbJtJ9Q8p7Ai45oLA15kG0PwEVIqcb5JWFN/ZF3myc7MnuEyl6iRIRCoqhsJ+pNlPUutk6gBWdKGcOurUmVhOTyVhRzlSjAmKqo7ZAqJ4+9Z2K4brSEHtUN4pYUup7lRGqk1OxeDtDIFy3nccoNRhl9RcIlWxcqg9Ffb9Xo7rzmVrIqKFE2e6q97OG9Fon2eboxUEGEXApLP/g1qd/cVR5hqo+rpf7wl337qMqqoj9M56h1/anTbdXqxOWw5OsV9rjhr2wd2myBS1mrhzK1oLy9TxPQo+eKzDSJakebPqwIA5oBECwegIbAnNTkCLGWSwvasUfHaOImxjjkYWx9qpysEgM1Rz6+CIanbrML5vukVvpg2TsmevutOpUdyiTmdxTU8sXVxLeC49+Hr3a9Td9tFErsbSnVZwMlSyKeyr4VX6qE5E0dmBt02k6g1H6I4KFCngqKLzU9VGdWSKd2tVFMUahbPzNciivpEmeQB1ZLXjeyK2m/e9TtS66sQunYKMenzVgTr1fWDIivOeuy1GU1yxy07iSHPA2jU2u7J4+GKX5lbDQeDRgH12D75jsDzIfigLS2Mk7z9hmP3tEUcM8qnuzlW4hSwwPtcj7giEnDM/sLmiYZdRx9lvrd3NGrVFinx2eGhadkeRuLrahpPTqTstB8k7hrC3fXvuOG4NkpMYLyivOiUs3pF/b0qq6GrBcB1fMcH0hOgBI1ev7luGRz/a1pfKjibeuZc4O5BAi2Vn31mRuyBouGh3duc14aH6s61irovd6kTZHVod4UtnBjmuqSWglWs7HPBc1WMyMfY9F5yFcaNUurPfsKi6UaU8hNdt83cm+1zu7GANZrI46mExDDOIgcEAC6MljgZpZA72oCPbmoPwyDrIW5dnxhrBGe/QGo4k6Wwt6+N1wPHIleIuq7tConPhtbw9Fu2ohKpCZVql1uVkFBGDmLu7pzW+yjyoT/Ww0u567SjTbBHD7hn/IRPgAzPLHpdEnMVWgw+ah0W3wf6WvYwl9jwY2yOQhOShHueo+Cvr2iUmk/HioHQuiKlR5QtGO7XF6zjFThbtqUYd5BsmOEg4Lpbve4CeB5P/Iaz2v8DqJXLI5y9Ib0seYYZz9il3ohxxRpJ1xhZ4Oq7DeaiQ1B9UW3SYpSKNYEKncjHQxU7AqHVMVCHoYzHq6Go69KEw0J0FD1NpMpM6Ol2gqTeN6sE5KeVIi7MkMYsnXVpFc+UIV+e3OGrPunYeglcz8UTFW3o7JAQmnaIwDe/hVSuTLhfV4LT7XLWUolEcuYhOJpq4mTK06kKewpgeYOIbI4OUg9z9rwTN/mFxsvoJjhVX5XTIfJT2FPVOVToswzc51512qcX++U+puVPSiWAkheXZ6DpbvFKRfQb8n8oEXi7+j2QCPZnA9hlVpW9gQj03s8ChrveUC6N48oDKcqHiaZHqwr4KYEeVzIQRO4K8xzbd/1ucKAvtPfvFJcvbNeMHAoBjefwwwUeG5bO/jg4HYx8L8XrO3wfY9qJFvKoYswcJOhiGuXi/B1ugXtFoSfmur1KerjRxK+osyXeYqz1COWeXjFTPwkUj5ROPZimOKpg7SIM8gikMFUyWzanAtMY90XTKFBHmsaIF9+RkVv960luYi2+dk21FIpieu3tge6hsPfMpX/CHhDV4+X8LRNNWBzdMx84UjB/DUlbsfPLP/FxX+GJ9xGHuIaKVaMVUsxDYl0Sm3Up+4BWuaqP6rDz32xPlJpMBe7/JrJYxT3OZ3jHXtMeJm8uEq1+o0SwWvYWr0U4OVE8Lzk4QkacvTVucCkftMMrCZGgVk9nfoJqqjhXhrh6V4yizdeNAEHbrZHVCm2NmjhGzB/Znq4A80kicA7IPeFYh1EHWnHNYLKJz/ix0rRy1FOu8PsIzKwBjbM74IiBzkagCEj2d69E92h1FEffjpnM1ecIKarqPHvlSjqba0rNKpJjWGFDExGjuguX5C1udGSx/j2pkSwcWPyxpGCR0LD1WiN+e/c+w3QzU+LcIvkuZ7DhIO8hh3AKn5YlM9UJZcDQ7IirFqiZP1dru0An0bcd0TKtxqlaeaknWmLhLM9ppVfVcHOTbbvMP8+tTPhdev2Y1HRelqEdxtiNWSvNb8kcOuqCmUM0d11eKdFc6hakcTRZ1zS4unU5otbBVYMbyLe08XTSr5Omu9lqzo292+lFuaHZX6HRhCWtJlAktJ07L/LztcGVnP00FrMl+rYp8mMik84Nd6aNS90SlE6ZlSIcueGaqukJnJF+e47Dz56Kjxde6XmVCdWM/DZJR5oNdO1JnRaBljXnKk1uvPKeni89O6waGCGWW8lVOdEIj47s+kpXOKocdlNmd4mnnQu0+OT7qrMI99Ox48lUFPa2dKmgavPPqUx+mOJ46g6/1SOSD2eu21O1qUqVGbVlXqNOjavXwZRgUeu4+WDhlhfX3xXWuXDxasYgRdSP79pHlDmsquVMvsDEr5IWKo8xnAqw67SC20QTjUM37N/LCiMAIXXZIL1EUs7tn7lZWyig5iOl6Ifqg5Eqpku7iBoYy+6aEJp9R9cDXbmJTvxpXpR46nczVIvsxq6xvP295+3ZVPLQlhlJ/taZ6xfotr6KcPBOM8+nAXn1OE+HMcK2KpudqPQ8LMdLrdc4nz0nDxJfXvKomR9e8/r/B69p0TaF1EjCzJiNUK5kpEpmw63HE+aES3xLnDfn/A0H9t+21fyGoswYNSIxpUE/rHcp/+bMKtEa+D+2ESk5x8ErCde31lvypz2u+rsxYYdU1q6d3aKqwGM8rDZYn98M8fFnOBXHu6VJZq+jUF2ahVs/qSS1/RPb1PJ7KU1ti0l3Pa1xanXDIGDK7BJ8Wzmk0azZU6GRlbqdvA67GV7eu59da6ro/O63AJSqtZFLmd6qwf5NOaz+3YoWlrnBHp1LMM2CZorksQtwZwGRFtrpMVgxd8X+hiq58/VxGvEhldzxq54qa9IPUVy4dHYidOicLfxA/UyB1m9G+daAOw5mUnj080zWLLsSyTRHRocnxQss5Qu19iF7zgzXcD+C1r0dG0nB8pPPooQaxTvt26443JHnA0gouRzMWg89iIq8ywXbCaqrbOzrPsZytT5iVGKh2xLLWEXr1uCSYiSLhHKZM5yLJ7XSyOFmV7uiyooef27OYakSZeH4jFY8f+D7p5guwXbnzQ0vSVrd/dja71DupxV0nMlLrSC4WbzwP9Oh2eao9IaYT27RKXC+BhMPsPFobYfmjtKrQ2Z/1x7bgIdOPgO6bWHrO+hMW5jt2znoWyJbPcvdPmJqFxKs+LWIXsl85DDrzHmGfbvDxWSd5qfE5o+eqsMLfCltbOyOdJXpzQNYrKTzyDCqZWA/Q5T3znjsdJwsN7blgeq6MWte6GyTJAxhGu49OeJjMeYhQvmU7WBOrkzrMDm79p7bBdyb/xDaYv2wDpshXs/mEdRa5u9yI1i4HnbwdtwXpiOyDSSmNgzivrS8KTkdV5+I8V7neuVFFT1zQSLFCV7VyfFeHUMbKjjp1xYEqp3UrR40D7qHPLn58ZT3mmviZNOJoxX6JNoA3EjPnl0W8VfdEL9N9oifoPZd5pdcdBUc5jzCohMO1/lKBopVByfa/1EwjP1txVhd/esg20pXLobPCRdYJG6JOmJy8MrWXlfeL8WjqW5W56yWLUe8ynHoCltde1uq3ux1qPRb6f+hjvzIU1urG6AyWNOKMeZ23liWrOOuNAMYjVWo/SzYSE99VEfsEyfd+Jd/ZLNLiK6VaULxt63Jq3tIUUWA6VBvxmcRmm6xm5eysvvmyPutosYB5nYVPfWWBhbLTX4TpuZ6rahfKIj7f56q7aKqG05Wkim7t0E5GS/3poAW7fcKtkHW3qZYTDmXhGCvXLpBYU0prsk+cw0Yb5PskTVqRNcczEiszK3LKvI2uWb8FxH4b/zaaJnry8B99UsD04Oo8NLCqXwJvnWb8o2ZzWAtXQS9w0toZJ7irCfsN55cg+kfLwXDMmLE48j9YDp7z6KpAPc8vWGlwBYKVIVhcZVFXUkL8MkZeqX4jAwp1TgI9fyHsVV7ZCMLKJWxd7YT/M8T/A+HbOg8dnXlgwsPMvLU+u2l8vNSQ7xN98LGZdTHY5CUTh7ZH2oSELDaj4udS6dzjq2/ppBtzdGJnG1qkZnzVPpakCCp9UVGnh8w9+H8YLv8MpOxw8RsuYvx3IIU33ODM7AjZLTIRj1EoONZdMqHlNp2VHLSiVKdBJwq2LehRlb70Wa0FhIFzfd866uam9gwZ14tD62zcbIHoFgJz+zQL7wR8PGIWIw6TWtM0jnXrPmTYzyxvOf6xoUPZyTOSvFmIvzMG52/yfn9jDOp/yPstwGEXk/53+Pa3E+J47GH9jech6byB8P+ZEP6XCbEBrvdOVJp92K6fVfL2wVy0uAa341KOadfPq7LJhKr7NiVpfhUvcDEZtWlxVzoETQbNek53y1LpEJN9IvXsys/h8+SA+J42mTCz3HDFiLuDmpc7M9KGIH23KjV5/qOnWycvY/sfmnf/yQzTXxlfjeTY4m0GmBiGrH1K2eyzY/Ey0kV9yVrtpujTLLrrfEebor5gpb9gZc/GkXoUJmYcvXCgVtRi1geCHadtbMh52bAvX2TTtftWf91FsSrHpqyvDVk2vsZJMxv7dFNO1ozmKMv4KqEdGCAn80boGi0ncwvnuh7gytwnhn2Opstm7iPq+wppLp1esDdaMHWVSQ/+x6P3v/PDf2f0/r+4kr9Gv6YsvqGzj73+6BqUqx1K/aTd4jVkn5K5K3gVybmzynR4uLLez+70j0a7ijLZEeDsNhzf7dcHwCRfevoBJMkr70J7VoqOMFaZNVRWydQfdadeLrSjQXMhO5+fa952cp7c2tY9KOdBWEvzY5umJ5IUtUcmTntQnxi/guPMD4EOZTOZL/8E1bRdqLt81Z3WmktlwqBeDr/zM6uMb1/N3dUN6w39QD2/8+ewNjwNnFTL1TaXyIaPfdmg+ZCufKzSuSuxlxkZhZS5VZh7ursKjXAkRVt+G/ibu2yvUqbestdl7oZf682FcqK0z/J3v04DvuiqcGYvVVgtkuauQRKrc1+uzeswa1nHnWUxzjTrBQ5ZmztIfuXTuhyosqztSyK8INhb2zM9fdWyt1AsK2xO5A9M7+banuUNrmaFi6servCmatxmLcx9/+EZGsb7QrtC/+Z1dKfncurPKDbxtdXQ002uul6UyLPhN1SCnc2raDR3avotVufr4cq0ozC/NQPFuZtgXovp2cN/LgvIHCGGnT3S5mfXh9Bo5PFZ/IdXBuRdPxCHY0v2LqqjYeO56z7LDz4uKPTMZn6QMnEXILQnCnlMAHUT/tXm3ojSWU13T86bBDroktbDH/PdjlbDENv6QXAWY2aEwkkzbZG7A6aqmPRQHdrN7PNeyNnp9ycSqX3I5wMJS5sOr1/846m5j1xKutLjzm+gstswK55H+3JEJ0G8q1R1IxHQsApNDh27pRPpFeutRfa+yhMst4ex3Nsd9uPOTPZ2FNoc9zzkY9OU8jiGZjOHoqenNFqTd82AwGHDvu65GoLd9il68buGBekibM39TuBlRF/zqNKUjaOCNmrsqw3sQ96lqZy+tFhXBjzNOhPVRgS80DNvaedp9wpTm5ZhzbEsRvczZew4G4ZdmCJdqemmT5yK6vF47bqrzm/olv9r1ufD8crBK1Ku3KeUkxESxe38uQ9QdDdu2ZPz4tsmXYu8866rnRfD4l8HTbrpccKm4KdrI7/LVcM4HGVjjujf06Q5e60Qc9D4TQDr39Sy/hvm+RRWxJmHp81fSs6KpgNmwmNkC2u/Y98ImvZKhJ3SqXXRlzehq95PxfjueATrRzGTsVAz4+tai+BTabD1QgkpY69X/wxOhxO9KykKPbfro06+oVW5frmRPMVn11Mcyx3q3ijvx3PrTkTHum4Ydc0ipZnVZDekeHGH+SOtrZWy9ANtkMPvXtJLeGpFtMqh+3WuVgnsG+G23yvX+J+xvaWoswvDMoPm3ciwxdHB53BmS+dbISvOnJVNFmPKjnxrMgrvHhd0qHJ+uBPfvbElEa3084I2ed/7vmmoeWCyLkr2Ok4eKe6GTO/eS6xTc5PxZYJu3IO6CDHaRCbgeuVlZaVSdVZxuHvn48PAr3qHW1+6+Yr44mqUtlPszKxBr7VlhXgpVd8lFmWVRsXvh1ZsUDlF5GX8PgS6cbGKCefXF9+803f8SyWzeRZ1mNa7za4gbodrYslKPXl/tHtlDMynz+kj2n97J09eaXUiSLMtvXkJaaPKnVLHtLNXEBRNYi30GoWuGcHpXNs5dJu/wg6n8ypnLWXvTdOza3tmc8ov4tuONJZkgqZf6MK0Q+1rglOmeYBA9to1u+kdaflQnYyky9krZo+DrEm1958TNr0IFScGTxV8v9TgshKmXNXtahbNvNzWovnpQr2hnT7gJ1Suqmbk7pN5XZLK8KL8epqF74R3qcvuidDegdxbgXvPokFEk8cUSF7AYPNheyGG2ZqYOzF9tsleQ9nrRSJPPpOUlkOO4jAWpI3bg3S196c6lZq6rufuLYhnia/Jc3PYO4yZWzfuM2fEO5U+knO+MLk252/pMpFw59McVCTTHmXuPibfXbUiVpCql+2TvXJVGOULa3K+fAMraX6qSzdvw/FGcjaEVaJZGBktDF189N2+sNRzV+CZTvTdl37T0xuhWqZpFiytSWE+T4bvJmxd3+WZok/t4r8AFfaHiXZkyrwAAAAASUVORK5CYII=`; ppixiv.resources["resources/noise.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA9QTFRFIiIiISEhIyMjHx8fICAgSEHIqwAAC3FJREFUeNrEWwmS4zgM46H/v3lFALKdtA9ZqdrZ2eruOLYOigTBw2bNI8zMPSyj/+gfmmf/4Ob9evRPFvUz+ufsV3FTfVV/pXm/1CJwc//Uf7V6pH7171tmv1i39a8yMVMN5uE1Pp7jF+59lOxP1MD9oYZF1dVW9wTurgvh2Vq/ln2KrK/rEQ2Me2p5juVn7UibqhtqkX1Ex68aAHtyzI4d9IVm7dxwvdaZfbC61P/Dx5qrj9Oidmu1in5nTeh9MRinFuv4xrDzaBQXpUmhjY2XxEq0/ck+ZotaRq2iL7rVOI0rq8my1YZbiRXDY5Kxijqd/kU6lu0YsGRUR4pJa/utpg5+dNyF1eDEuBivxTbtwrD5Einur7XWRHWxRsUTXXQ4r1IhCHZoAf7Qpg0D1x01UHA7UVfquZqjBaXKhfUffbONmolN9mkSCseF1+JKlRqVFDqBLZY61OJrV9gM5tOMdeyRLl0ZKoARjbpGlcWaalgM3+XeSqz4QY0MN4kREtGia9pmQ6JUuuRMVMw6lJCosWytpizEptW9SbA1IY6qnky7VffQXmvU3M2tydyitMKgcFmbd9hTUkFkkbiQPBx97kJodXDQX2mTUdW7DDMJK0Zb6daB8a32khST0X5SgqoVZP0ri6s11sKjbKJugUJCIJAu9Le23uoyBInvqNMewKnSgEbF0k8YpBGfiD51KoVwFFYJiDYfsHA+BN0pOQSUiVrNfVIJeBXajbOrcQsyaOWtbJGy9PqrCfwa4LKG7EKAygk76mMQZaIEkHi6JOhcZ0qFMbfzWAoxpu0Fplb3AtJKF1LncWrgCVk6BqSKDHG3zZoi4tTAC7biGmBcAKMjTYEz7Y8DQlus0BEYiVtLU7k2T6Injo9X5M/6uSYtEHae2iDRSUdNdYLZlIUBO2pi7pBgj2NygHN9gh3VzriJ2h1NBCdj0GfKnstqtDkidHnCOqc6VKgAdI2QTPMpmUPRqUZd9yCvhEXKkulPAqBS5+H8sgZKumPYEUEJqNPtsQZNInUOJUqTvuIeaiFNBMCBk2lE1+HGahfcUw0tadKTBCSZmx7D7HFctSg5f4nS4cxyc42FqWMGcAo4RsBuf6alnN4zytTTKXQBrkH83LI8s8GWB8JBQrVoK3SCspb+l7G4VI2jAWhr81yg88BLTIBqI6GwRyWhQ2o4ZqMsiONh44RfK95xzAXFkwU0I5isuMADC5p2gSEX6JnaOFE51smQbzt+SYbokxpvt3vKZzJWOk1qQyN/hBKcUT7fKR8M8ZJykhOSDjl8xvCOL7bVSMbqofKlMPLi6hCZwatA5WgUIXdA/5Yh39CVphWI4hTcpRF0qU98JykPkXHAPOAJHLl0ETSDdE80eVALKOdmrHc0+k6mvygwlphECXoxuOPSrBoXgjFAM+iLK1xJMekMEZtB6hRqkHEkhsEJiJg0Wm9NnIiCAEGUOmMb6DyW+Mq9FMErxQzqZStgLFVrSWdefGxz46UxJI7SpDqA3TAXuPGsrhyBwQQMACI/kosRTAChFYnW6gJPJglVEMJk1lPRyxe5GVzSBD+FMqkztWFHODXLLfZJ+eVWaJVGqic8u2BrPqh1Uj2CTjxpOLBJOM/3tDoOtPrC4fnB4fmNw2XcNEM+r7ZzRz5d5BOzH8iv7+TXOd4pXLQdLvwarhihQ8VJMMWcUsSSxPwcrhoW9kGWXGTJ5CCeyZo12jx2MnyI4oonsubiRjDl1UxNY8blVaZGCZUQr35i8feG9hEmTaYJ5OgLeZv4zQIAQK7NXwGAuH8IFsW6llIrg034nduNEVbCC/x1+zbI57sc12fQ37bTg/qD0kCfEiBjigxIZ6BIJhNJ41cF9jR3xtYMXhtDpood7jV9hVKHjRzdWPZ8GPAHFSfCgD+oGELFICMZ7A4ZAaRGsIwGo0Mupt8YDFv/et7phOKBXTaxy7K3c8V6QZNX+GTsig2HPuLEJsK0OdqRoUlkPhBI0emHvCSkMZXLajbCkgJ2HJkwN45+0nc/WUYy46eXsiq7YwNFu4qgbCp1QM/XfJHb0Vt8pz2SaUyJ2CjYGKYw9sS1n6c9btMutqddQCiecEPpBcRBf3LzoNLURMJ0yQqL46qc1gONhv2G/L7cOaznK5H5qm5ws7ZHTJW6vix3xF7uALSNVA3oS5JkUlfluuFPcHIZYi2xOfEG5Xo653sZv0+vpfRMudSLsHcSGFcrLTsBgD/zCpaSCZE2IN1HdjbodZzRBSkAoK38SxpTV4J4rnaEktJmMEbaWMMtUPkgOf3fDwDBDAw9XCrxjkp+5v2+clZwjbMOfk83vxA5U4iDD0bMiDwlcmpWY4GgwduDTE3x/6sQdIb/f6crlOp0AvV39vslP50urx34ae78dLCfp/qsjWwhnTNJtsONTwLWMeowRR3yWd+JN6UPGKsVMoJVbQWmZJIkVAhZYTQmRtOo0NJ+2Cc535uA7zZ2DxF3RVooICRWioQ5uJUvINgxceg/JLnaoOe/VO5WyLTtKQZBF/UwRJIY9Eajx4T5cKlJ5QlW8lS5JVwRFRtS9wY5ZI48ECsCm2i2FDllmGd3hO5g9Hq7wq2AmFtKCiIhaXSW0i5WCL7DlLuKivIBoCTtkK+4qYmsMOl+fiPcTAaQRp4bPODBIyB9uCj+7VuMZWKmIwBdL/vYPH5e5G8wTUt/Y86K5IpO5JoMUzKE7CajEVM0gv8ZDcH4c8n6d/60kuLOnT9hS/cFnofw5JD6nQ5PQuEJnaC4+sscwxYrrNAoU6wQAqHpvN6ZXn7BvHB/Wi8JNK/D26NC3bRqXEU4sUc4qTreevn2Gar+lm9t8BBLFjjOG2x8qsHnO2tXYMvWi+sGH1eDjyvJ/G+zZH5Odp2w81yYPpDd6fgmRbYbe0p+coa24gxN7lpK/IsR0hBWjZBSPjBcHrkahtJEdEP82Qf+i0iHVOdtB6SGNHJW1Tb+9gfN9sdd9Adl3PQnmfqT4M9zoUryESW9rJKwYLMdZGMT0ElvyDxJ2XtDQr0hTyTFBG7ixctlY/rHhbKxq/uTe1WD5yozXMmxtp3V+HNh8ClqeCgMnkUNOTp5MNj7aOxDsSajsQ+EiCNC2E2X10yXmfRODXxAYkHndZeZH7rMlGE/Ted5TNTk3nZ+4RiVThzdjFMteFdJiBUM870F0JnBuQeu28bKkRB+01hpAk6gx11FbqZwclGRuy2cuOp1Q76vz/2YlZk996s0MpX+h/hwBnq/eaXt0O/DOT40jl6nzlZKzq7UGZpMXrQB0+N8tSF/9zmrRBlUo2DfjMswtD3xXQe2+utK4WePzm99aNzfY8fcXeJutsZ9ZlGIOeO3gkksFEy2rgcI0yezm1fcbwm/tTusP992MH6dx0rPgek8mnT9b5/Ni3LoyksZivBteF+Y3fayAASIw5vqknubWw7RR7VOsIzw1AF534rB8nNjYlTpkpbi7YFivSPmCOrf3oFJRHupwX90bLbh6OArmnzFaHD9iZaulP5zT1gyS/aTGLeUpTX1WqKGlGFbI/GoMquPmYV33wLopfzkHjktlBtMBJfSnn5D7DxGWXk9yfYYxfSu1HLFJlaoVBPQ2Whnm8hJXx7wi1cRNs7jO+dpQ7DLOfuV7hdTZDYq5L90Fdv7ruLqpOCJjszty+TsZ7PEZI37o5HDlVqzrQ61UnvThfO+2ft+ICXqYrwxNv9q3Um+9vHVupN8bagjhWfwz3vJXhfdvrOIM+/kfWYRc88ixu07oVPx6tQ7eZ/xqo6X7MhftGSfqtZ9D/Mpt8qdW9H+n7kLI8oz7rRCJ00oHwSn/52PROyxK0O3y/chZ9IGtcoWzHwyX6hA7DptYEobqCx2ipa+o+X9OyAPJ3TKbkNozXfOliMCOuqXXcgfjppt5f+4m+4/AQYA7rFTSAXDOQYAAAAASUVORK5CYII=`; 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-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 image_data.singleton().get_early_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. // image_data.singleton().update_early_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 image_data.singleton().get_early_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 image_data.singleton().get_early_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"); image_data.singleton().update_early_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_info.bookmarkCount--; image_data.singleton().call_illust_modified_callbacks(illust_id); } image_data.singleton().update_cached_bookmark_image_tags(illust_id, null); var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) thumbnail_info.bookmarkData = 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 image_data.singleton().get_early_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. image_data.singleton().update_early_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") { if(illust_data.illustType != 2) return false; // All of these seem to be JPEGs, but if any are PNG, disable MJPEG exporting. // We could encode to JPEG, but if there are PNGs we should probably add support // for APNG. if(illust_data.ugoiraMetadata.mime_type != "image/jpeg") return false; return true; } 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/r108/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/r108/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/r108/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"); // Find the resource. let resource = resources[src]; if(resource == null) { console.error("Unknown resource \\"" + src + "\\" in", element); continue; } // Parse this element if we haven't done so yet. // If we haven't parsed this if(!helpers._resource_cache[src]) { // 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; } // Import the cached node to make a copy, then replace the element // with it. let node = helpers._resource_cache[src]; node = document.importNode(node, true); 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); } } }, // 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); // 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; } value = "#" + new_id; } var re = /url\\(#.*?\\)/; var new_value = 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(); }); }, // 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(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); } // 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; } 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); }, get_tags_from_illust_data(illust_data) { // illust_data might contain a list of dictionaries (data.tags.tags[].tag), or // a simple list (data.tags[]), depending on the source. if(illust_data.tags.tags == null) return illust_data.tags; var result = []; for(var tag_data of illust_data.tags.tags) result.push(tag_data.tag); return result; }, // Return true if the given illust_data.tags contains the pixel art (ドット絵) tag. tags_contain_dot(illust_data) { var tags = helpers.get_tags_from_illust_data(illust_data); for(var tag of tags) 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 resolves when img finishes loading, or rejects if it // fails to load. wait_for_image_load(img, abort_signal) { return new Promise((resolve, reject) => { // Resolve immediately if the image is already loaded. if(img.complete) { resolve(); return; } if(abort_signal && abort_signal.aborted) { reject("Aborted"); return; } // Cancelling this controller will remove all of our event listeners. let remove_listeners_signal = new AbortController(); img.addEventListener("error", (e) => { remove_listeners_signal.abort(); reject("Error loading image"); }, { signal: remove_listeners_signal.signal }); img.addEventListener("load", (e) => { remove_listeners_signal.abort(); resolve(); }, { signal: remove_listeners_signal.signal }); if(abort_signal) { abort_signal.addEventListener("abort",(e) => { remove_listeners_signal.abort(); reject("Aborted"); }, { signal: remove_listeners_signal.signal }); } }); }, // If image.decode is available, asynchronously decode url. async decode_image(url, abort_signal) { if(url == null) return; var img = document.createElement("img"); img.src = url; var onabort = (e) => { // If we're aborted, set the image to a small PNG, which cancels the previous load // in Firefox and Chrome. img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; }; if(abort_signal) abort_signal.addEventListener("abort", onabort); try { await helpers.wait_for_image_load(img, abort_signal); } catch(e) { // Ignore load errors, since this is just a load optimization. // console.error("Ignoring error in decode:", e); return; } finally { // Remove the abort listener. if(abort_signal) abort_signal.removeEventListener("abort", onabort); } // If we finished by aborting, don't bother decoding the blank PNG we changed the // image to. if(abort_signal && abort_signal.aborted) return; if(HTMLImageElement.prototype.decode == null) { // If we don't have img.decode, fake it by drawing the image into an offscreen canvas // to force the browser to decode it. var canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; var context = canvas.getContext('2d'); context.drawImage(img, 0, 0); } else { try { await img.decode(); } catch(e) { // console.error("Ignoring error in decode:", e); } } }, // 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; }\`; 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]]; } }, }; // 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) { let transaction = db.transaction(this.store_name, "readwrite"); return transaction.objectStore(this.store_name); } 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); 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); }); } // Internal helper: batch set all keys[n] to values[n]. static async_store_multi_set(store, keys, values) { if(keys.length != values.length) throw "key and value arrays have different lengths"; return new Promise((resolve, reject) => { // Only wait for onsuccess on the final put, for performance. for(let i = 0; i < keys.length; ++i) { var request = store.put(values[i], keys[i]); request.onerror = reject; if(i == keys.length - 1) request.onsuccess = resolve; } }); } // 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 keys = Object.keys(data); let values = []; for(let key of keys) values.push(data[key]); await key_storage.async_store_multi_set(store, keys, values); }); } static async_multi_delete(store, keys) { return new Promise((resolve, reject) => { // Only wait for onsuccess on the final put, for performance. for(let i = 0; i < keys.length; ++i) { var request = store.delete(keys[i]); request.onerror = reject; if(i == keys.length - 1) request.onsuccess = resolve; } }); } // Delete a list of keys. async multi_delete(keys) { return await this.db_op(async (db) => { let store = this.get_store(db); await key_storage.async_multi_delete(store, keys); }); } // 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; else return window.history.state; } set state(value) { if(this.virtual) this.virtual_data = value; else window.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 { // 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); this.element.addEventListener("pointerup", this.onpointerevent, this.event_options); this.element.addEventListener("pointercancel", this.onpointerup, this.event_options); } register_mousemove(enable) { if(this.pointermove_registered) return; this.pointermove_registered = true; this.element.addEventListener("pointermove", this.onpointermove, this.event_options); } unregister_mousemove(enable) { if(!this.pointermove_registered) return; this.pointermove_registered = false; this.element.removeEventListener("pointermove", this.onpointermove, { ...this.event_options }); } button_changed(buttons, event) { // We need to register pointermove to see presses past the first. if(buttons) this.register_mousemove(); else this.unregister_mousemove(); 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); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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 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}) { 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; } 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 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("no_receive_quick_view", { session: true }); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/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/r108/src/fix_chrome_clicks.js `; ppixiv.resources["src/widgets.js"] = `"use strict"; // A basic widget base class. ppixiv.widget = class { constructor(container) { console.assert(container != null); this.container = container; // Let the caller finish, then refresh. helpers.yield(() => { this.refresh(); }); } async refresh() { } } // A widget that shows info for a particular illust_id. // // An illust_id can be set, and we'll refresh when it changes. ppixiv.illust_widget = class extends ppixiv.widget { constructor(container) { super(container); // Refresh when the image data changes. image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } // The data this widget needs. This can be illust_id (nothing but the ID), illust_info, // or early_info. // // This can change dynamically. Some widgets need illust_info only when viewing a manga // page. get needed_data() { return "illust_info"; } set_illust_id(illust_id, page=null) { console.assert(page != -1); if(this._illust_id == illust_id && this._page == page) return; this._illust_id = illust_id; this._page = page; this.refresh(); } set illust_id(illust_id) { this.set_illust_id(illust_id); } get illust_id() { return this._illust_id; } get visible() { return !this.container.hidden; } async refresh() { // Grab the illust info. var illust_id = this._illust_id; var illust_data = null; let info = { illust_id: this._illust_id }; if(this._illust_id != null) { if(this.needed_data == "illust_id") illust_data = illust_id; else if(this.needed_data == "early_info") info.early_info = await image_data.singleton().get_early_illust_data(this._illust_id); else info.illust_data = await image_data.singleton().get_image_info(this._illust_id); } // Stop if the ID changed while we were async. if(this._illust_id != illust_id) return; await this.refresh_internal(info); } async refresh_internal({ illust_id, illust_data, early_info}) { throw "Not implemented"; } } // Display messages in the popup widget. This is a singleton. ppixiv.message_widget = class { static get singleton() { if(message_widget._singleton == null) message_widget._singleton = new message_widget(); return message_widget._singleton; } constructor() { this.container = document.body.querySelector(".hover-message"); this.timer = null; } show(message) { this.clear_timer(); this.container.querySelector(".message").innerHTML = message; this.container.classList.add("show"); this.container.classList.remove("centered"); this.timer = setTimeout(() => { this.container.classList.remove("show"); }, 3000); } clear_timer() { if(this.timer != null) { clearTimeout(this.timer); this.timer = null; } } hide() { this.clear_timer(); this.container.classList.remove("show"); } } // Call a callback on any click not inside a list of nodes. // // This is used to close dropdown menus. ppixiv.click_outside_listener = class { constructor(node_list, callback) { this.window_onmousedown = this.window_onmousedown.bind(this); this.node_list = node_list; this.callback = callback; window.addEventListener("mousedown", this.window_onmousedown, true); } // Return true if node is below any node in node_list. is_node_in_list(node) { for(let ancestor of this.node_list) { if(helpers.is_above(ancestor, node)) return true; } return false; } window_onmousedown(e) { // Close the popup if anything outside the dropdown is clicked. Don't // prevent the click event, so the click still happens. // // If this is a click inside the box or our button, ignore it. if(this.is_node_in_list(e.target)) return; this.callback(); } shutdown() { window.removeEventListener("mousedown", this.window_onmousedown, true); } } // Show popup menus when a button is clicked. ppixiv.dropdown_menu_opener = class { static create_handlers(container, selectors) { for(let selector of selectors) { let item = container.querySelector(selector); if(item == null) { console.warn("Couldn't find", selector); continue; } dropdown_menu_opener.create_handler(item); } } // A shortcut for creating an opener for our common button/popup layout. static create_handler(container) { let button = container.querySelector(".menu-button"); let box = container.querySelector(".popup-menu-box"); if(button == null) { console.error("Couldn't find menu button for " + container); return; } if(box == null) { console.error("Couldn't find menu box for " + container); return; } new dropdown_menu_opener(button, box); } constructor(button, box) { this.box_onclick = this.box_onclick.bind(this); this.button = button; this.box = box; this.visible = false; this.button.addEventListener("click", (e) => { this.button_onclick(e); }); // Hide popups when any containing view is hidden. new view_hidden_listener(this.button, (e) => { this.visible = false; }); } // The viewhidden event is sent when the enclosing view is no longer visible, and // all menus in it should be hidden. onviewhidden(e) { this.visible = false; } get visible() { return !this.box.hidden; } set visible(value) { if(this.box.hidden == !value) return; this.box.hidden = !value; helpers.set_class(this.box, "popup-visible", value); if(value) { this.listener = new click_outside_listener([this.button, this.box], () => { this.visible = false; }); if(this.close_on_click_inside) this.box.addEventListener("click", this.box_onclick, true); } else { if(this.listener) { this.listener.shutdown(); this.listener = null; } this.box.removeEventListener("click", this.box_onclick, true); } // If we're inside a .top-ui-box container (the UI that sits at the top of the screen), set // .force-open on that element while we're open. let top_ui_box = this.box.closest(".top-ui-box"); if(top_ui_box) helpers.set_class(top_ui_box, "force-open", value); // Let the widget know its visibility has changed. this.box.dispatchEvent(new Event(value? "popupshown":"popuphidden")); } // Return true if this popup should close when clicking inside it. If false, // the menu will stay open until something else closes it. get close_on_click_inside() { return true; } box_onclick(e) { if(e.target.closest(".keep-menu-open")) return; this.visible = false; } // Toggle the popup when the button is clicked. button_onclick(e) { e.preventDefault(); e.stopPropagation(); this.visible = !this.visible; } }; // A pointless creepy eye. Looks away from the mouse cursor when hovering over // the unfollow button. ppixiv.creepy_eye_widget = class { constructor(eye) { this.onevent = this.onevent.bind(this); this.eye = eye; this.eye.addEventListener("mouseenter", this.onevent); this.eye.addEventListener("mouseleave", this.onevent); this.eye.addEventListener("mousemove", this.onevent); this.eye_middle = this.eye.querySelector(".middle"); } onevent(e) { if(e.type == "mouseenter") this.hover = true; if(e.type == "mouseleave") this.hover = false; if(!this.hover) { this.eye_middle.style.transform = ""; return; } var mouse = [e.pageX, e.pageY]; var bounds = this.eye.getBoundingClientRect(); var eye = [bounds.x + bounds.width/2, bounds.y + bounds.height/2]; var vector_length = function(vec) { return Math.sqrt(vec[0]*vec[0] + vec[1]*vec[1]); } // Normalize to get a direction vector. var normalize_vector = function(vec) { var length = vector_length(vec); if(length < 0.0001) return [0,0]; return [vec[0]/length, vec[1]/length]; }; var pos = [mouse[0] - eye[0], mouse[1] - eye[1]]; pos = normalize_vector(pos); if(Math.abs(pos[0]) < 0.5) { var negative = pos[0] < 0; pos[0] = 0.5; if(negative) pos[0] *= -1; } // pos[0] = 1 - ((1-pos[0]) * (1-pos[0])); pos[0] *= -3; pos[1] *= -6; this.eye_middle.style.transform = "translate(" + pos[0] + "px, " + pos[1] + "px)"; } } ppixiv.avatar_widget = class { // options: // parent: node to add ourself to (required) // changed_callback: called when a follow or unfollow completes // big: if true, show the big avatar instead of the small one constructor(options) { this.options = options; if(this.options.mode != "dropdown" && this.options.mode != "overlay") throw "Invalid avatar widget mode"; this.clicked_follow = this.clicked_follow.bind(this); this.user_changed = this.user_changed.bind(this); this._visible = false; this.root = helpers.create_from_template(".template-avatar"); helpers.set_class(this.root, "big", this.options.big); image_data.singleton().user_modified_callbacks.register(this.user_changed); let element_author_avatar = this.root.querySelector(".avatar"); this.img = document.createElement("img"); // A canvas filter for the avatar. This has no actual filters. This is just to kill off any // annoying GIF animations in people's avatars. this.base_filter = new image_canvas_filter(this.img, element_author_avatar.querySelector("canvas.main")); // The actual highlight filter: this.highlight_filter = new image_canvas_filter(this.img, element_author_avatar.querySelector("canvas.highlight"), "brightness(150%)", (ctx, img) => { ctx.globalCompositeOperation = "destination-in"; let feather = 25; let radius = 15; ctx.filter = "blur(" + feather + "px)"; helpers.draw_round_rect(ctx, feather, feather + this.img.naturalHeight/2, this.img.naturalWidth - feather*2, this.img.naturalHeight - feather*2, radius); ctx.fill(); }); this.root.dataset.mode = this.options.mode; // Show the favorite UI when hovering over the avatar icon. let avatar_popup = this.root; //container.querySelector(".avatar-popup"); if(this.options.mode == "dropdown") { avatar_popup.addEventListener("mouseover", function(e) { helpers.set_class(avatar_popup, "popup-visible", true); }.bind(this)); avatar_popup.addEventListener("mouseout", function(e) { helpers.set_class(avatar_popup, "popup-visible", false); }.bind(this)); } new creepy_eye_widget(this.root.querySelector(".unfollow-button .eye-image")); for(let button of avatar_popup.querySelectorAll(".follow-button.public")) button.addEventListener("click", this.clicked_follow.bind(this, false), false); for(let button of avatar_popup.querySelectorAll(".follow-button.private")) button.addEventListener("click", this.clicked_follow.bind(this, true), false); for(let button of avatar_popup.querySelectorAll(".unfollow-button")) button.addEventListener("click", this.clicked_follow.bind(this, true), false); this.element_follow_folder = avatar_popup.querySelector(".folder"); // Follow publically when enter is pressed on the follow folder input. helpers.input_handler(avatar_popup.querySelector(".folder"), this.clicked_follow.bind(this, false)); this.options.parent.appendChild(this.root); } shutdown() { image_data.singleton().user_modified_callbacks.unregister(this.user_changed); } set visible(value) { if(this._visible == value) return; this._visible = value; this.refresh(); } get visible() { return this._visible; } // Refresh when the user changes. user_changed(user_id) { if(this.user_id == null || this.user_id != user_id) return; this.set_user_id(this.user_id); } async set_user_id(user_id) { this.user_id = user_id; this.refresh(); } async refresh() { // Only update when we're visible, so we don't load user info until it's needed. if(!this._visible) return; if(this.user_id == null) { this.user_data = null; this.root.classList.add("loading"); // Set the avatar image to a blank image, so it doesn't flash the previous image // the next time we display it. It should never do this, since we set a new image // before displaying it, but Chrome doesn't do this correctly at least with Canvas. this.img.src = helpers.blank_image; return; } // If we've seen this user's profile image URL from thumbnail data, start loading it // now. Otherwise, we'll have to wait until user info finishes loading. let cached_profile_url = thumbnail_data.singleton().user_profile_urls[this.user_id]; if(cached_profile_url) this.img.src = cached_profile_url; // Set up stuff that we don't need user info for. this.root.querySelector(".avatar-link").href = \`/users/\${this.user_id}/artworks#ppixiv\`; // Hide the popup in dropdown mode, since it covers the dropdown. if(this.options.mode == "dropdown") this.root.querySelector(".avatar").classList.remove("popup"); // Clear stuff we need user info for, so we don't show old data while loading. helpers.set_class(this.root, "followed", false); this.root.querySelector(".avatar").dataset.popup = ""; this.root.querySelector(".follow-buttons").hidden = true; this.root.querySelector(".follow-popup").hidden = true; this.root.classList.remove("loading"); let user_data = await image_data.singleton().get_user_info(this.user_id); this.user_data = user_data; if(user_data == null) { console.log("Couldn't load user:", this.user_id); return; } helpers.set_class(this.root, "self", this.user_id == global_data.user_id); // We can't tell if we're followed privately or not, only that we're following. helpers.set_class(this.root, "followed", this.user_data.isFollowed); this.root.querySelector(".avatar").dataset.popup = "View " + this.user_data.name + "'s posts"; // If we don't have an image because we're loaded from a source that doesn't give us them, // just hide the avatar image. let key = "imageBig"; if(this.user_data[key]) this.img.src = this.user_data[key]; else this.img.src = helpers.blank_image; this.root.querySelector(".follow-buttons").hidden = false; this.root.querySelector(".follow-popup").hidden = false; } async follow(follow_privately) { if(this.user_id == null) return; var tags = this.element_follow_folder.value; await actions.follow(this.user_id, follow_privately, tags); } async unfollow() { if(this.user_id == null) return; await actions.unfollow(this.user_id); } // Note that in some cases we'll only have the user's ID and name, so we won't be able // to tell if we're following. clicked_follow(follow_privately, e) { e.preventDefault(); e.stopPropagation(); if(this.user_data == null) return; if(this.user_data.isFollowed) { // Unfollow the user. this.unfollow(); return; } // Follow the user. this.follow(follow_privately); } }; // A list of tags, with translations in popups where available. ppixiv.tag_widget = class { // options: // parent: node to add ourself to (required) // format_link: a function to format a tag to a URL constructor(options) { this.options = options; this.container = this.options.parent; this.tag_list_container = this.options.parent.appendChild(document.createElement("div")); this.tag_list_container.classList.add("tag-list-widget"); // Refresh when we're opened, in case translations have been turned on or off. this.container.addEventListener("popupshown", (e) => { this.refresh(); }); }; format_tag_link(tag) { if(this.options.format_link) return this.options.format_link(tag); let search_url = new URL("/tags/" + encodeURIComponent(tag) + "/artworks", ppixiv.location.href); search_url.hash = "#ppixiv"; return search_url.toString(); }; async set(tags) { this.tags = tags; // Register any tag translations. Not all sources will have this. tag_translations.get().add_translations(this.tags.tags); this.refresh(); } async refresh() { if(this.tags == null) return; // Look up tag translations. let tag_list = []; for(let tag of this.tags.tags) tag_list.push(tag.tag); let translated_tags = await tag_translations.get().get_translations(tag_list, "en"); // Remove any old tag list and create a new one. helpers.remove_elements(this.tag_list_container); for(let tag of tag_list) { let a = this.tag_list_container.appendChild(document.createElement("a")); a.classList.add("tag"); a.classList.add("box-link"); let popup = null; let translated_tag = tag; if(translated_tags[tag]) translated_tag = translated_tags[tag]; a.dataset.tag = tag; a.textContent = translated_tag; a.href = this.format_tag_link(tag); } } }; // A popup for inputting text. // // This is currently special purpose for the add tag prompt. ppixiv.text_prompt = class { constructor() { this.submit = this.submit.bind(this); this.close = this.close.bind(this); this.onkeydown = this.onkeydown.bind(this); this.result = new Promise((completed, cancelled) => { this._completed = completed; this._cancelled = cancelled; }); this.root = helpers.create_from_template(".template-add-tag-prompt"); document.body.appendChild(this.root); this.input = this.root.querySelector("input.add-tag-input"); this.input.value = ""; this.input.focus(); this.root.querySelector(".close-button").addEventListener("click", this.close); this.root.querySelector(".submit-button").addEventListener("click", this.submit); this.root.addEventListener("click", (e) => { // Clicks that aren't inside the box close the dialog. if(e.target.closest(".box") != null) return; e.preventDefault(); e.stopPropagation(); this.close(); }); window.addEventListener("keydown", this.onkeydown); // This disables global key handling and hotkeys. document.body.dataset.popupOpen = "1"; } onkeydown(e) { if(e.key == "Escape") { e.preventDefault(); e.stopPropagation(); this.close(); } if(e.key == "Enter") { e.preventDefault(); e.stopPropagation(); this.submit(); } } // Close the popup and call the completion callback with the result. submit(e) { let result = this.input.value; console.log("submit", result); this._remove(); this._completed(result); } close() { this._remove(); // Cancel the promise. If we're actually submitting a result, this._cancelled("Cancelled by user"); } _remove() { window.removeEventListener("keydown", this.onkeydown); delete document.body.dataset.popupOpen; this.root.remove(); } } // Widget for editing bookmark tags. ppixiv.bookmark_tag_list_widget = class extends ppixiv.illust_widget { get needed_data() { return "illust_id"; } constructor(container) { super(container); this.container.hidden = true; this.displaying_illust_id = null; this.container.appendChild(helpers.create_from_template(".template-popup-bookmark-tag-dropdown")); this.container.addEventListener("click", this.clicked_bookmark_tag.bind(this), true); this.container.querySelector(".add-tag").addEventListener("click", async (e) => { await actions.add_new_tag(this._illust_id); }); this.container.querySelector(".sync-tags").addEventListener("click", async (e) => { var bookmark_tags = await actions.load_recent_bookmark_tags(); helpers.set_recent_bookmark_tags(bookmark_tags); }); // Close if our containing widget is closed. new view_hidden_listener(this.container, (e) => { this.visible = false; }); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); settings.register_change_callback("recent-bookmark-tags", this.refresh.bind(this)); } // Return an array of tags selected in the tag dropdown. get selected_tags() { var tag_list = []; var bookmark_tags = this.container; for(var entry of bookmark_tags.querySelectorAll(".popup-bookmark-tag-entry")) { if(!entry.classList.contains("active")) continue; tag_list.push(entry.dataset.tag); } return tag_list; } // Override setting illust_id to save tags when we're closed. Otherwise, illust_id will already // be cleared when we close and we won't be able to save. set_illust_id(illust_id, page=null) { // If we're hiding and were previously visible, save changes. if(illust_id == null) this.save_current_tags(); super.set_illust_id(illust_id, page); } get visible() { return !this.container.hidden; } set visible(value) { this._set_tag_dropdown_visible(value); } // Hide the dropdown without committing anything. This happens if a bookmark // button is pressed to remove a bookmark: if we just close the dropdown normally, // we'd readd the bookmark. hide_without_sync() { this._set_tag_dropdown_visible(false, true); } async _set_tag_dropdown_visible(value, skip_save) { if(this.container.hidden == !value) return; this.container.hidden = !value; if(value) { // We only load existing bookmark tags when the tag list is open, so refresh. await this.refresh(); } else { if(!skip_save) { // Save any selected tags when the dropdown is closed. this.save_current_tags(); } // Clear the tag list when the menu closes, so it's clean on the next refresh. var bookmark_tags = this.container.querySelector(".tag-list"); helpers.remove_elements(bookmark_tags); this.displaying_illust_id = null; } } async refresh_internal({ illust_id }) { // If we're refreshing the same illust that's already refreshed, store which tags were selected // before we clear the list. let old_selected_tags = this.displaying_illust_id == illust_id? this.selected_tags:[]; this.displaying_illust_id = null; let bookmark_tags = this.container.querySelector(".tag-list"); helpers.remove_elements(bookmark_tags); // Make sure the dropdown is hidden if we have no image. if(illust_id == null) this.visible = false; if(illust_id == null || !this.visible) return; // Figure out how much space we have, and set that as the max-height. This will // fit the tag scroll box within however much space we have available. let dropdown = this.container.querySelector(".tag-list"); let pos = helpers.get_relative_pos(dropdown, document)[1]; let tag_box_height = window.innerHeight - pos; tag_box_height -= 10; // a bit of padding so it's not flush against the edge tag_box_height = Math.min(400, tag_box_height); dropdown.style.maxHeight = \`\${tag_box_height}px\`; // Create a temporary entry to show loading while we load bookmark details. let entry = document.createElement("span"); bookmark_tags.appendChild(entry); entry.innerText = "Loading..."; // If the tag list is open, populate bookmark details to get bookmark tags. // If the image isn't bookmarked this won't do anything. let active_tags = await image_data.singleton().load_bookmark_details(illust_id); // Remember which illustration's bookmark tags are actually loaded. this.displaying_illust_id = illust_id; // Remove elements again, in case another refresh happened while we were async // and to remove the loading entry. helpers.remove_elements(bookmark_tags); // If we're refreshing the list while it's open, make sure that any tags the user // selected are still in the list, even if they were removed by the refresh. Put // them in active_tags, so they'll be marked as active. for(let tag of old_selected_tags) { if(active_tags.indexOf(tag) == -1) active_tags.push(tag); } let shown_tags = []; let recent_bookmark_tags = Array.from(helpers.get_recent_bookmark_tags()); // copy for(let tag of recent_bookmark_tags) if(shown_tags.indexOf(tag) == -1) shown_tags.push(tag); shown_tags.sort((lhs, rhs) => { lhs = lhs.toLowerCase(); rhs = rhs.toLowerCase(); return lhs.localeCompare(rhs); }); for(let i = 0; i < shown_tags.length; ++i) { let tag = shown_tags[i]; let entry = helpers.create_from_template(".template-popup-bookmark-tag-entry"); entry.dataset.tag = tag; bookmark_tags.appendChild(entry); entry.querySelector(".tag-name").innerText = tag; let active = active_tags.indexOf(tag) != -1; helpers.set_class(entry, "active", active); } } // Save the selected bookmark tags to the current illust. async save_current_tags() { // Store the ID and tag list we're saving, since they can change when we await. let illust_id = this._illust_id; let new_tags = this.selected_tags; if(illust_id == null) return; // Only save tags if we're refreshed to the current illust ID, to make sure we don't save // incorrectly if we're currently waiting for the async refresh. if(illust_id != this.displaying_illust_id) return; // Get the tags currently on the bookmark to compare. let old_tags = await image_data.singleton().load_bookmark_details(illust_id); var equal = new_tags.length == old_tags.length; for(let tag of new_tags) { if(old_tags.indexOf(tag) == -1) equal = false; } // If the selected tags haven't changed, we're done. if(equal) return; // Save the tags. If the image wasn't bookmarked, this will create a public bookmark. console.log("Tag list closing and tags have changed"); console.log("Old tags:", old_tags); console.log("New tags:", new_tags); await actions.bookmark_add(this._illust_id, { tags: new_tags, }); } // Toggle tags on click. We don't save changes until we're closed. async clicked_bookmark_tag(e) { let a = e.target.closest(".popup-bookmark-tag-entry"); if(a == null) return; e.preventDefault(); e.stopPropagation(); // Toggle this tag. Don't actually save it immediately, so if we make multiple // changes we don't spam requests. let tag = a.dataset.tag; helpers.set_class(a, "active", !a.classList.contains("active")); } } // The button that shows and hides the tag list. ppixiv.toggle_bookmark_tag_list_widget = class extends ppixiv.illust_widget { // We only need an illust ID and no info. get needed_data() { return "illust_id"; } constructor(container, bookmark_tag_widget) { super(container); this.bookmark_tag_widget = bookmark_tag_widget; this.container.addEventListener("click", (e) => { e.preventDefault(); // Ignore clicks if this button isn't enabled. if(!this.container.classList.contains("enabled")) return; this.bookmark_tag_widget.visible = !this.bookmark_tag_widget.visible; }); } refresh_internal({ illust_id }) { helpers.set_class(this.container, "enabled", illust_id != null); } } ppixiv.bookmark_button_widget = class extends ppixiv.illust_widget { get needed_data() { return "early_info"; } constructor(container, private_bookmark, bookmark_tag_widget) { super(container); this.private_bookmark = private_bookmark; this.bookmark_tag_widget = bookmark_tag_widget; this.container.addEventListener("click", this.clicked_bookmark.bind(this)); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } refresh_internal({ early_info }) { let bookmarked = early_info?.bookmarkData != null; let our_bookmark_type = early_info?.bookmarkData?.private == this.private_bookmark; // Set up the bookmark buttons. helpers.set_class(this.container, "enabled", early_info != null); helpers.set_class(this.container, "bookmarked", our_bookmark_type); helpers.set_class(this.container, "will-delete", our_bookmark_type); // Set the tooltip. let type_string = this.private_bookmark? "private":"public"; this.container.dataset.popup = early_info == null? "": !bookmarked? (this.private_bookmark? "Bookmark privately":"Bookmark image"): our_bookmark_type? "Remove bookmark": "Change bookmark to " + type_string; } // Clicked one of the top-level bookmark buttons or the tag list. async clicked_bookmark(e) { // See if this is a click on a bookmark button. let a = e.target.closest(".button-bookmark"); if(a == null) return; e.preventDefault(); e.stopPropagation(); // If the tag list dropdown is open, make a list of tags selected in the tag list dropdown. // If it's closed, leave tag_list null so we don't modify the tag list. let tag_list = null; if(this.bookmark_tag_widget && this.bookmark_tag_widget.visible) tag_list = this.bookmark_tag_widget.selected_tags; // If we have a tag list dropdown, close it before saving the bookmark. // // When the tag list bookmark closes, it'll save the bookmark with its current tags // if they're different, creating the bookmark if needed. If we leave it open when // we save, it's possible to click the private bookmark button in the context menu, // then release the right mouse button to close the context menu before the bookmark // finishes saving. The tag list won't know that the bookmark is already being saved // and will save. This can cause private bookmarks to become public bookmarks. Just // tell the tag list to close without saving, since we're committing the tag list now. if(this.bookmark_tag_widget) this.bookmark_tag_widget.hide_without_sync(); // If the image is bookmarked and the same privacy button was clicked, remove the bookmark. let illust_data = await image_data.singleton().get_early_illust_data(this._illust_id); if(illust_data.bookmarkData && illust_data.bookmarkData.private == this.private_bookmark) { await actions.bookmark_remove(this._illust_id); // If the current image changed while we were async, stop. if(this._illust_id != illust_data.illustId) return; // Hide the tag dropdown after unbookmarking, without saving any tags in the // dropdown (that would readd the bookmark). if(this.bookmark_tag_widget) this.bookmark_tag_widget.hide_without_sync(); return; } // Add or edit the bookmark. await actions.bookmark_add(this._illust_id, { private: this.private_bookmark, tags: tag_list, }); } } ppixiv.bookmark_count_widget = class extends ppixiv.illust_widget { constructor(container) { super(container); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } refresh_internal({ illust_data }) { let count = this.container.querySelector(".count"); if(count) count.textContent = illust_data? illust_data.bookmarkCount:"---"; } } ppixiv.like_button_widget = class extends ppixiv.illust_widget { get needed_data() { return "illust_id"; } constructor(container) { super(container); this.container.addEventListener("click", this.clicked_like); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } async refresh_internal({ illust_id }) { let liked_recently = this._illust_id != null? image_data.singleton().get_liked_recently(this._illust_id):false; helpers.set_class(this.container, "liked", liked_recently); helpers.set_class(this.container, "enabled", !liked_recently); this.container.dataset.popup = this._illust_id == null? "": liked_recently? "Already liked image":"Like image"; } clicked_like = (e) => { e.preventDefault(); e.stopPropagation(); if(this._illust_id != null) actions.like_image(this._illust_id); } } ppixiv.like_count_widget = class extends ppixiv.illust_widget { constructor(container) { super(container); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } async refresh_internal({ illust_data }) { this.container.textContent = illust_data? illust_data.likeCount:"---"; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/widgets.js `; ppixiv.resources["src/menu_option.js"] = `"use strict"; // Simple menu settings widgets. ppixiv.menu_option = class { static add_settings(container) { if(container.closest(".screen-manga-container")) { new thumbnail_size_slider_widget(container, { label: "Thumbnail size", setting: "manga-thumbnail-size", min: 0, max: 7, }); } if(container.closest(".screen-search-container")) { new thumbnail_size_slider_widget(container, { label: "Thumbnail size", setting: "thumbnail-size", min: 0, max: 7, }); } new menu_option_toggle(container, { label: "Disabled by default", setting: "disabled-by-default", }); new menu_option_toggle(container, { label: "Hide cursor", setting: "no-hide-cursor", invert_display: true, }); // Firefox's contextmenu behavior is broken, so hide this option. if(navigator.userAgent.indexOf("Firefox/") == -1) { new menu_option_toggle(container, { label: "Hold shift to open context menu", setting: "invert-popup-hotkey", }); } new menu_option_toggle(container, { label: "Hover to show UI", setting: "ui-on-hover", onchange: this.update_from_settings, }); new menu_option_toggle(container, { label: "Invert scrolling while zoomed", setting: "invert-scrolling", }); new menu_option_toggle_light_theme(container, { label: "Light mode", setting: "theme", }); new menu_option_toggle(container, { label: "Show translations", setting: "disable-translations", invert_display: true, }); new menu_option_toggle(container, { label: "Thumbnail panning", setting: "disable_thumbnail_panning", invert_display: true, }); new menu_option_toggle(container, { label: "Thumbnail zooming", setting: "disable_thumbnail_zooming", invert_display: true, }); /* new menu_option_toggle(container, { label: "Quick view (experimental)", setting: "quick_view", }); new menu_option_toggle(container, { label: "Link to quick view (experimental)", setting: "no_receive_quick_view", invert_display: true, }); */ new menu_option_toggle(container, { label: "Remember recent history", setting: "no_recent_history", invert_display: true, }); /* new menu_option_toggle(container, { label: "Touchpad mode", setting: "touchpad-mode", }); */ } constructor(container, options) { this.refresh = this.refresh.bind(this); this.container = container; this.options = options; settings.register_change_callback(this.options.setting, this.refresh); } get value() { return settings.get(this.options.setting); } set value(value) { settings.set(this.options.setting, value); } refresh() { if(this.options.onchange) this.options.onchange(); } } class menu_option_toggle extends ppixiv.menu_option { constructor(container, options) { super(container, options); this.onclick = this.onclick.bind(this); this.item = helpers.create_from_template(".template-menu-toggle"); this.container.appendChild(this.item); this.item.addEventListener("click", this.onclick); this.item.querySelector(".label").innerText = options.label; this.refresh(); } refresh() { super.refresh(); var value = this.value; if(this.options.invert_display) value = !value; this.item.querySelector(".on").hidden = !value; this.item.querySelector(".off").hidden = value; } onclick(e) { e.preventDefault(); e.stopPropagation(); this.value = !this.value; } } // A special case for the theme, which is just a light/dark toggle but stored // as a string. class menu_option_toggle_light_theme extends menu_option_toggle { get value() { var value = super.value; return value == "light"; } set value(value) { super.value = value? "light":"dark"; } } class menu_option_slider extends ppixiv.menu_option { constructor(container, options) { super(container, options); this.oninput = this.oninput.bind(this); this.item = helpers.create_from_template(".template-menu-slider"); this.item.addEventListener("input", this.oninput); this.item.querySelector(".label").innerText = options.label; this.slider = this.item.querySelector("input"); this.slider.min = this.options.min; this.slider.max = this.options.max; this.container.appendChild(this.item); } refresh() { this._slider_value = this.value; super.refresh(); } oninput(e) { this.value = this._slider_value; } get value() { return parseInt(super.value); } set value(value) { super.value = value; } set _slider_value(value) { if(this.slider.value == value) return; this.slider.value = value; } get _slider_value() { return parseInt(this.slider.value); } } // A widget to control the thumbnail size slider. ppixiv.thumbnail_size_slider_widget = class extends menu_option_slider { constructor(container, options) { super(container, options); this.onwheel = this.onwheel.bind(this); this.onkeydown = this.onkeydown.bind(this); var view = this.container.closest(".screen"); view.addEventListener("wheel", this.onwheel, { passive: false }); view.addEventListener("keydown", this.onkeydown); this.refresh(); } get min_value() { return this.options.min; } get max_value() { return this.options.max; } onkeydown(e) { var zoom = helpers.is_zoom_hotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); this.move(zoom < 0); } } onwheel(e) { if(!e.ctrlKey) return; e.preventDefault(); e.stopImmediatePropagation(); this.move(e.deltaY > 0); } // Increase or decrease zoom. move(down) { var value = this._slider_value; value += down?-1:+1; value = helpers.clamp(value, 0, 5); this._slider_value = value; this.value = this._slider_value; } get value() { var value = super.value; if(typeof(value) != "number" || isNaN(value)) value = 4; return value; } set value(value) { super.value = value; } static thumbnail_size_for_value(value) { return 100 * Math.pow(1.3, value); } get thumbnail_size() { return thumbnail_size_slider_widget.thumbnail_size_for_value(this.slider.value); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/menu_option.js `; ppixiv.resources["src/main_context_menu.js"] = `"use strict"; // A global right-click popup menu. // // This is only active when right clicking over items with the context-menu-target // class. // // Not all items are available all the time. This is a singleton class, so it's easy // for different parts of the UI to tell us when they're active. // // This also handles alt-mousewheel zooming. ppixiv.context_menu_image_info_widget = class extends ppixiv.illust_widget { get needed_data() { // We need illust info if we're viewing a manga page beyond page 1, since // early info doesn't have that. Most of the time, we only need early info. if(this._page == null || this._page == 0) return "early_info"; else return "illust_info"; } refresh_internal({ early_info, illust_data }) { if(!illust_data) illust_data = early_info; this.container.hidden = illust_data == null; if(this.container.hidden) return; var set_info = (query, text) => { var node = this.container.querySelector(query); node.innerText = text; node.hidden = text == ""; }; // Add the page count for manga. var page_text = ""; if(illust_data.pageCount > 1) { if(this._page == null) page_text = illust_data.pageCount + " pages"; else page_text = "Page " + (this._page+1) + "/" + illust_data.pageCount; } set_info(".page-count", page_text); // Show info for the current page. If _page is -1 then we're on the search view and don't have // a specific page, so show info for the first page. let page = this._page; if(page == null) page = 0; // If we're on the first page then we only requested early info, and we can use the dimensions // on it. Otherwise, we have full info and we'll get dimensions from mangaPages. var info = ""; if(page == 0) info += illust_data.width + "x" + illust_data.height; else { let page_info = illust_data.mangaPages[page]; info += page_info.width + "x" + page_info.height; } set_info(".image-info", info); let seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000; let age = helpers.age_to_string(seconds_old) + " ago"; this.container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate); set_info(".post-age", age); } } // A helper for a simple right-click context menu. // // The menu opens on right click and closes when the button is released. ppixiv.popup_context_menu = class { // Names for buttons, for storing in this.buttons_down. buttons = ["lmb", "rmb", "mmb"]; constructor(container) { this.window_onblur = this.window_onblur.bind(this); this.onmouseover = this.onmouseover.bind(this); this.onmouseout = this.onmouseout.bind(this); this.onkeyevent = this.onkeyevent.bind(this); this.hide = this.hide.bind(this); this.cancel_event = this.cancel_event.bind(this); this.onmousemove = this.onmousemove.bind(this); this.container = container; this.visible = false; // We can't tell where the mouse is until it moves due to half-baked web APIs, so pretend // the mouse is in the center of the window. this.latest_mouse_pos = [window.innerWidth/2, window.innerHeight/2]; new ppixiv.pointer_listener({ element: window, button_mask: 0b11, // signal: this.quick_view_active.signal, callback: this.pointerevent, }); window.addEventListener("keydown", this.onkeyevent); window.addEventListener("keyup", this.onkeyevent); window.addEventListener("mousemove", this.onmousemove, { passive: true }); // Create the menu. The caller will attach event listeners for clicks. this.menu = helpers.create_from_template(".template-context-menu"); this.container.appendChild(this.menu); // Work around glitchiness in Chrome's click behavior (if we're in Chrome). new fix_chrome_clicks(this.menu); this.menu.addEventListener("mouseover", this.onmouseover, true); this.menu.addEventListener("mouseout", this.onmouseout, true); // Whether the left and right mouse buttons are pressed: this.buttons_down = {}; } context_menu_enabled_for_element(element) { while(element != null && element instanceof Element) { if(element.dataset.contextMenuTarget == "off") return false; if("contextMenuTarget" in element.dataset) return true; element = element.parentNode; } return false; } pointerevent = (e) => { if(e.pressed) { if(!this.visible && !this.context_menu_enabled_for_element(e.target)) return; if(!this.visible && e.mouseButton != 1) return; let button_name = this.buttons[e.mouseButton]; if(button_name != null) this.buttons_down[button_name] = true; if(e.mouseButton != 1) return; // If invert-popup-hotkey is true, hold shift to open the popup menu. Otherwise, // hold shift to suppress the popup menu so the browser context menu will open. // // Firefox doesn't cancel the context menu if shift is pressed. This seems like a // well-intentioned but deeply confused attempt to let people override pages that // block the context menu, making it impossible for us to let you choose context // menu behavior and probably making it impossible for games to have sane keyboard // behavior at all. this.shift_was_pressed = e.shiftKey; if(navigator.userAgent.indexOf("Firefox/") == -1 && settings.get("invert-popup-hotkey")) this.shift_was_pressed = !this.shift_was_pressed; if(this.shift_was_pressed) return; e.preventDefault(); e.stopPropagation(); if(this.toggle_mode && this.visible) this.hide(); else this.show(e.pageX, e.pageY, e.target); } else { // Releasing the left or right mouse button hides the menu if both the left // and right buttons are released. Pressing right, then left, then releasing // right won't close the menu until left is also released. This prevents lost // inputs when quickly right-left clicking. if(!this.visible) return; let button_name = this.buttons[e.mouseButton]; if(button_name != null) this.buttons_down[button_name] = false; this.hide_if_all_buttons_released(); } } // If true, RMB toggles the menu instead of displaying while held, and we'll also hide the // menu if the mouse moves too far away. get toggle_mode() { return settings.get("touchpad-mode", false); } onmousemove(e) { // Store the mouse position, so we can tell where to open the context menu if it's opened // with the keyboard. this.latest_mouse_pos = [e.pageX, e.pageY]; } // The subclass can override this to handle key events. This is called whether the menu // is open or not. handle_key_event(e) { return false; } onkeyevent(e) { if(e.repeat) return; // Don't eat inputs if we're inside an input. if(e.target.closest("input, textarea")) return; // Let the subclass handle events. if(this.handle_key_event(e)) { e.preventDefault(); e.stopPropagation(); return; } // Keyboard access to the context menu, to try to make things easier for touchpad // users. let down = e.type == "keydown"; let key = e.key.toUpperCase(); if(e.key == "Control") { e.preventDefault(); e.stopPropagation(); this.buttons_down[e.key] = down; if(down) { let x = this.latest_mouse_pos[0]; let y = this.latest_mouse_pos[1]; let node = document.elementFromPoint(x, y); this.show(x, y, node); } else { this.hide_if_all_buttons_released(); } } } // This is called on mouseup, and when keyboard shortcuts are released. Hide the menu if all buttons // that can open the menu have been released. hide_if_all_buttons_released() { if(this.toggle_mode) return; if(!this.buttons_down["lmb"] && !this.buttons_down["rmb"] && !this.buttons_down["Control"]) this.hide(); } window_onblur(e) { this.hide(); } // Return the element that should be under the cursor when the menu is opened. get element_to_center() { return null; } show(x, y, target) { if(this.visible) return; this.displayed_menu = this.menu; this.visible = true; this.refresh_visibility(); // Disable popup UI while a context menu is open. document.body.classList.add("hide-ui"); window.addEventListener("blur", this.window_onblur); // Disable all dragging while the context menu is open, since drags cause browsers to // forget to send mouseup events, which throws things out of whack. We don't use // drag and drop and there's no real reason to use it while the context menu is open. window.addEventListener("dragstart", this.cancel_event, true); // In toggle mode, close the popup if anything outside is clicked. if(this.toggle_mode && this.click_outside_listener == null) { this.click_outside_listener = new click_outside_listener([this.menu], () => { this.hide(); }); } var centered_element = this.element_to_center; if(centered_element == null) centered_element = this.displayed_menu; // The center of the centered element, relative to the menu. Shift the center // down a bit in the button. var pos = helpers.get_relative_pos(centered_element, this.displayed_menu); pos[0] += centered_element.offsetWidth / 2; pos[1] += centered_element.offsetHeight * 3 / 4; x -= pos[0]; y -= pos[1]; this.displayed_menu.style.left = x + "px"; this.displayed_menu.style.top = y + "px"; // Adjust the fade-in so it's centered around the centered element. this.displayed_menu.style.transformOrigin = (pos[0]) + "px " + (pos[1]) + "px"; hide_mouse_cursor_on_idle.disable_all(); } // If element is within a button that has a tooltip set, show it. show_tooltip_for_element(element) { if(element != null) element = element.closest("[data-popup]"); if(this.tooltip_element == element) return; this.tooltip_element = element; this.refresh_tooltip(); if(this.tooltip_observer) { this.tooltip_observer.disconnect(); this.tooltip_observer = null; } if(this.tooltip_element == null) return; // Refresh the tooltip if the popup attribute changes while it's visible. this.tooltip_observer = new MutationObserver((mutations) => { for(var mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-popup") this.refresh_tooltip(); } } }); this.tooltip_observer.observe(this.tooltip_element, { attributes: true }); } refresh_tooltip() { var element = this.tooltip_element; if(element != null) element = element.closest("[data-popup]"); this.menu.querySelector(".tooltip-display").hidden = element == null; if(element != null) this.menu.querySelector(".tooltip-display-text").textContent = element.dataset.popup; } onmouseover(e) { this.show_tooltip_for_element(e.target); } onmouseout(e) { this.show_tooltip_for_element(e.relatedTarget); } // Return true if we're visible, ignoring hidden_temporarily. get visible() { return !this.hidden; } set visible(value) { this.hidden = !value; } get hide_temporarily() { return this.hidden_temporarily; } set hide_temporarily(value) { this.hidden_temporarily = value; this.refresh_visibility(); } refresh_visibility() { let visible = !this.hidden_temporarily && !this.hidden; helpers.set_class(this.menu, "visible", visible); } hide() { if(!this.visible) return; this.visible = false; this.hidden_temporarily = false; this.refresh_visibility(); // Let menus inside the context menu know we're closing. view_hidden_listener.send_viewhidden(this.menu); this.displayed_menu = null; hide_mouse_cursor_on_idle.enable_all(); this.buttons_down = {}; document.body.classList.remove("hide-ui"); window.removeEventListener("blur", this.window_onblur); window.removeEventListener("dragstart", this.cancel_event, true); if(this.click_outside_listener) { this.click_outside_listener.shutdown(); this.click_outside_listener = null; } } cancel_event(e) { e.preventDefault(); e.stopPropagation(); } } ppixiv.main_context_menu = class extends ppixiv.popup_context_menu { // Return the singleton. static get get() { return main_context_menu._singleton; } constructor(container) { super(container); if(main_context_menu._singleton != null) throw "Singleton already exists"; main_context_menu._singleton = this; this.onwheel = this.onwheel.bind(this); this.handle_link_click = this.handle_link_click.bind(this); this._on_click_viewer = null; this._page = null; // Refresh the menu when the view changes. this.mode_observer = new MutationObserver(function(mutationsList, observer) { for(var mutation of mutationsList) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-current-view") this.refresh(); } } }.bind(this)); this.mode_observer.observe(document.body, { attributes: true, childList: false, subtree: false }); this.menu.querySelector(".button-return-to-search").addEventListener("click", this.clicked_return_to_search.bind(this)); this.menu.querySelector(".button-fullscreen").addEventListener("click", this.clicked_fullscreen.bind(this)); this.menu.querySelector(".button-zoom").addEventListener("click", this.clicked_zoom_toggle.bind(this)); this.menu.querySelector(".button-browser-back").addEventListener("click", (e) => { history.back(); }); this.menu.addEventListener("click", this.handle_link_click); for(var button of this.menu.querySelectorAll(".button-zoom-level")) button.addEventListener("click", this.clicked_zoom_level.bind(this)); this.send_button = this.menu.querySelector(".button-send-image"); this.send_button.addEventListener("click", (e) => { let illust_id = this.effective_illust_id; if(!illust_id) return; this.send_image_widget.visible = !this.send_image_widget.visible; }); let bookmark_tag_widget = new bookmark_tag_list_widget(this.menu.querySelector(".popup-bookmark-tag-dropdown-container")); this.send_image_widget = new send_image_widget(this.menu.querySelector(".popup-send-to-tab-container")); this.illust_widgets = [ bookmark_tag_widget, this.send_image_widget, new toggle_bookmark_tag_list_widget(this.menu.querySelector(".button-bookmark-tags"), bookmark_tag_widget), new like_button_widget(this.menu.querySelector(".button-like")), new like_count_widget(this.menu.querySelector(".button-like .count")), new context_menu_image_info_widget(this.menu.querySelector(".context-menu-image-info")), new bookmark_count_widget(this.menu.querySelector(".button-bookmark.public")), ]; this.avatar_widget = new avatar_widget({ parent: this.menu.querySelector(".avatar-widget-container"), mode: "overlay", }); // The bookmark buttons, and clicks in the tag dropdown: for(var a of this.menu.querySelectorAll(".button-bookmark")) { let private_bookmark = a.classList.contains("private"); this.illust_widgets.push(new bookmark_button_widget(a, private_bookmark, bookmark_tag_widget)); } this.element_bookmark_tag_list = this.menu.querySelector(".bookmark-tag-list"); this.refresh(); } // Override ctrl-clicks inside the context menu. // // This is a bit annoying. Ctrl-clicking a link opens it in a tab, but we allow opening the // context menu by holding ctrl, which means all clicks are ctrl-clicks if you use the popup // that way. We work around this by preventing ctrl-click from opening links in a tab and just // navigate normally. This is annoying since some people might like opening tabs that way, but // there's no other obvious solution other than changing the popup menu hotkey. That's not a // great solution since it needs to be on Ctrl or Alt, and Alt causes other problems, like showing // the popup menu every time you press alt-left. // // This only affects links inside the context menu, which is currently only the author link, and // most people probably use middle-click anyway, so this will have to do. handle_link_click(e) { let a = e.target.closest("A"); if(a == null) return; // If a previous event handler called preventDefault on this click, ignore it. if(e.defaultPrevented) return; // Only change ctrl-clicks. if(e.altKey || e.shiftKey || !e.ctrlKey) return; e.preventDefault(); e.stopPropagation(); let url = new URL(a.href, ppixiv.location); helpers.set_page_url(url, true, "Clicked link in context menu"); } set visible(value) { if(this.visible == value) return; super.visible = value; if(this.avatar_widget != null) this.avatar_widget.visible = value; if(value) window.addEventListener("wheel", this.onwheel, { capture: true, // Work around Chrome intentionally breaking event listeners. Remember when browsers // actually made an effort to not break things? passive: false, }); else window.removeEventListener("wheel", this.onwheel, true); } get visible() { return super.visible; } // Return the illust ID active in the context menu, or null if none. // // If we're opened by right clicking on an illust, we'll show that image's // info. Otherwise, we'll show the info for the illust we're on, if any. get effective_illust_id() { if(this._clicked_illust_id != null) return this._clicked_illust_id; else return this._illust_id; } get effective_user_id() { if(this._clicked_user_id != null) return this._clicked_user_id; else if(this._user_id) return this._user_id; else return null; } get effective_page() { // If we have a temporary illust_id, use the temporary page too. If it's // null, that means we're over an illust and not a page. if(this._clicked_illust_id != null) return this._clicked_page; else return this._page; } // When the effective illust ID changes, let our widgets know. _effective_illust_id_changed() { // If we're not visible, don't refresh an illust until we are, so we don't trigger // data loads. Do refresh even if we're hidden if we have no illust to clear // the previous illust's display even if we're not visible, so it's not visible the // next time we're displayed. let illust_id = this.effective_illust_id; if(!this.visible && illust_id != null) return; for(let widget of this.illust_widgets) widget.set_illust_id(illust_id, this.effective_page); helpers.set_class(this.send_button, "enabled", illust_id != null); } set illust_id(value) { if(this._illust_id == value) return; this._illust_id = value; this._effective_illust_id_changed(); } set page(value) { if(this._page == value) return; this._page = value; this._effective_illust_id_changed(); } // Set the current viewer, or null if none. If set, we'll activate zoom controls. get on_click_viewer() { return this._on_click_viewer; } set on_click_viewer(viewer) { this._on_click_viewer = viewer; this.refresh(); } // Set the related user currently being viewed, or null if none. get user_id() { return this._user_id; } set user_id(user_id) { if(this._user_id == user_id) return; this._user_id = user_id; this.refresh(); } // Put the zoom toggle button under the cursor, so right-left click is a quick way // to toggle zoom lock. get element_to_center() { return this.displayed_menu.querySelector(".button-zoom"); } get _is_zoom_ui_enabled() { var view = document.body.dataset.currentView; return view == "illust" && this._on_click_viewer != null; } set_data_source(data_source) { if(this.data_source == data_source) return; this.data_source = data_source; this.refresh(); } // Handle key events. This is called whether the context menu is open or closed, and handles // global hotkeys. This is handled here because it has a lot of overlapping functionality with // the context menu. // // The actual actions may happen async, but this always returns synchronously since the keydown/keyup // event needs to be defaultPrevented synchronously. // // We always return true for handled hotkeys even if we aren't able to perform them currently, so // keys don't randomly revert to default actions. _handle_key_event_for_image(e) { // These hotkeys require an image, which we have if we're viewing an image or if the user // was hovering over an image in search results. We might not have the illust info yet, // but we at least need an illust ID. let illust_id = this.effective_illust_id; // All of these hotkeys require Ctrl. if(!e.ctrlKey) return; if(e.key.toUpperCase() == "V") { (async() => { if(illust_id == null) return; actions.like_image(illust_id); })(); return true; } if(e.key.toUpperCase() == "B") { (async() => { if(illust_id == null) return; let illust_data = await image_data.singleton().get_early_illust_data(illust_id); // Ctrl-Shift-Alt-B: add a bookmark tag if(e.altKey && e.shiftKey) { actions.add_new_tag(illust_id); return; } // Ctrl-Shift-B: unbookmark if(e.shiftKey) { if(illust_data.bookmarkData == null) { message_widget.singleton.show("Image isn't bookmarked"); return; } actions.bookmark_remove(illust_id); return; } // Ctrl-B: bookmark // Ctrl-Alt-B: bookmark privately let bookmark_privately = e.altKey; if(illust_data.bookmarkData != null) { message_widget.singleton.show("Already bookmarked (^B to remove bookmark)"); return; } actions.bookmark_add(illust_id, { private: bookmark_privately }); })(); return true; } return false; } _handle_key_event_for_user(e) { // These hotkeys require a user, which we have if we're viewing an image, if the user // was hovering over an image in search results, or if we're viewing a user's posts. // We might not have the user info yet, but we at least need a user ID. let user_id = this.effective_user_id; // All of these hotkeys require Ctrl. if(!e.ctrlKey) return; if(e.key.toUpperCase() == "F") { (async() => { if(user_id == null) return; var user_info = await image_data.singleton().get_user_info_full(user_id); if(user_info == null) return; // Ctrl-Shift-F: unfollow if(e.shiftKey) { if(!user_info.isFollowed) { message_widget.singleton.show("Not following this user"); return; } await actions.unfollow(user_id); return; } // Ctrl-F: follow // Ctrl-Alt-F: follow privately // // It would be better to check if we're following publically or privately to match the hotkey, but // Pixiv doesn't include that information. let follow_privately = e.altKey; if(user_info.isFollowed) { message_widget.singleton.show("Already following this user"); return; } await actions.follow(user_id, follow_privately, []); })(); return true; } return false; } handle_key_event(e) { if(e.type != "keydown") return false; if(this._is_zoom_ui_enabled) { var zoom = helpers.is_zoom_hotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); this.handle_zoom_event(e, zoom < 0); return true; } } // Check image and user hotkeys. if(this._handle_key_event_for_image(e)) return true; if(this._handle_key_event_for_user(e)) return true; return false; } onwheel(e) { // RMB-wheel zooming is confusing in toggle mode. if(this.toggle_mode) return; // Stop if zooming isn't enabled. if(!this._is_zoom_ui_enabled) return; // Only mousewheel zoom if the popup menu is visible. if(!this.visible) return; // We want to override almost all mousewheel events while the popup menu is open, but // don't override scrolling the popup menu's tag list. if(e.target.closest(".popup-bookmark-tag-dropdown")) return; e.preventDefault(); e.stopImmediatePropagation(); var down = e.deltaY > 0; this.handle_zoom_event(e, down); } // Handle both mousewheel and control-+/- zooming. handle_zoom_event(e, down) { e.preventDefault(); e.stopImmediatePropagation(); if(!this.hide_temporarily) { // Hide the popup menu. It remains open, so hide() will still be called when // the right mouse button is released and the overall flow remains unchanged, but // the popup itself will be hidden. this.hide_temporarily = true; } // If e is a keyboard event, use null to use the center of the screen. var keyboard = e instanceof KeyboardEvent; var pageX = keyboard? null:e.pageX; var pageY = keyboard? null:e.pageY; let center = this._on_click_viewer.get_image_position([pageX, pageY]); // If mousewheel zooming is used while not zoomed, turn on zooming and set // a 1x zoom factor, so we zoom relative to the previously unzoomed image. if(!this._on_click_viewer.zoom_active) { this._on_click_viewer.zoom_level = 0; this._on_click_viewer.locked_zoom = true; this.refresh(); } this._on_click_viewer.change_zoom(down); // As a special case, // If that put us in 0x zoom, we're now showing the image identically to not being zoomed // at all. That's confusing, since toggling zoom does nothing since it toggles between // unzoomed and an identical zoom. When this happens, switch zoom off and change the zoom // level to "cover". The display will be identical, but clicking will zoom. // // This works with the test above: if you zoom again after this happens, we'll turn locked_zoom // back on. if(this._on_click_viewer.zoom_level == 0) { // but this should leave locked_zoom false, which we don't want this._on_click_viewer.zoom_level = "cover"; this._on_click_viewer.locked_zoom = false; } this._on_click_viewer.set_image_position([pageX, pageY], center); this.refresh(); } show(x, y, target) { // When we hide, we clear which ID we want to display, but we don't refresh the // display so it doesn't flicker while it fades out. Refresh now instead, so // we don't flash the previous ID if we need to wait for a load. this._effective_illust_id_changed(); // If RMB is pressed while dragging LMB, stop dragging the window when we // show the popup. if(this.on_click_viewer != null) this.on_click_viewer.stop_dragging(); // See if an element representing a user and/or an illust was under the cursor. if(target != null) { let { user_id, illust_id, page } = main_controller.singleton.get_illust_at_element(target); if(user_id != null) this._set_temporary_user(user_id); if(illust_id != null) this._set_temporary_illust(illust_id, page); } super.show(x, y, target); // Make sure we're up to date if we deferred an update while hidden. this._effective_illust_id_changed(); } // Set an alternative illust ID to show. This is effective until the context menu is hidden. // This is used to remember what the cursor was over when the context menu was opened when in // the search view. async _set_temporary_illust(illust_id, page) { // Store the illust_id immediately, so it's available without waiting for image // info to load. this._clicked_illust_id = illust_id; this._clicked_page = page; if(page != null) page = parseInt(page); this._effective_illust_id_changed(); } // Set an alternative user ID to show. This is effective until the context menu is hidden. async _set_temporary_user(user_id) { // Clear the avatar widget while we load user info, so we don't show the previous // user's avatar while the new avatar loads. this.avatar_widget.set_user_id(null); this._clicked_user_id = user_id; this.refresh(); } hide() { // For debugging, this can be set to temporarily force the context menu to stay open. if(unsafeWindow.keep_context_menu_open) return; this._clicked_user_id = null; this._clicked_illust_id = null; this._clicked_page = null; // Don't refresh yet, so we try to not change the display while it fades out. // We'll do the refresh the next time we're displayed. // this._effective_illust_id_changed(); super.hide(); } // Update selection highlight for the context menu. refresh() { var view = document.body.dataset.currentView; // Update the tooltip for the thumbnail toggle button. var navigate_out_label = main_controller.singleton.navigate_out_label; var title = navigate_out_label != null? ("Return to " + navigate_out_label):""; this.menu.querySelector(".button-return-to-search").dataset.popup = title; helpers.set_class(this.menu.querySelector(".button-return-to-search"), "enabled", navigate_out_label != null); this.refresh_tooltip(); // Enable the zoom buttons if we're in the image view and we have an on_click_viewer. for(var element of this.menu.querySelectorAll(".button.requires-zoom")) helpers.set_class(element, "enabled", this._is_zoom_ui_enabled); // Set the avatar button. this.avatar_widget.set_user_id(this.effective_user_id); if(this._is_zoom_ui_enabled) { helpers.set_class(this.menu.querySelector(".button-zoom"), "selected", this._on_click_viewer.locked_zoom); var zoom_level = this._on_click_viewer.zoom_level; for(var button of this.menu.querySelectorAll(".button-zoom-level")) helpers.set_class(button, "selected", this._on_click_viewer.locked_zoom && button.dataset.level == zoom_level); } } clicked_return_to_search(e) { main_controller.singleton.navigate_out(); } clicked_fullscreen(e) { if(!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); } // "Zoom lock", zoom as if we're holding the button constantly clicked_zoom_toggle(e) { if(!this._is_zoom_ui_enabled) return; let center = this._on_click_viewer.get_image_position([e.pageX, e.pageY]); this._on_click_viewer.locked_zoom = !this._on_click_viewer.locked_zoom; this._on_click_viewer.set_image_position([e.pageX, e.pageY], center); this.refresh(); } clicked_zoom_level(e) { if(!this._is_zoom_ui_enabled) return; let level = e.currentTarget.dataset.level; // If the zoom level that's already selected is clicked and we're already zoomed, // just toggle zoom as if the toggle zoom button was pressed. if(this._on_click_viewer.zoom_level == level && this._on_click_viewer.locked_zoom) { this.on_click_viewer.locked_zoom = false; this.refresh(); return; } let center = this._on_click_viewer.get_image_position([e.pageX, e.pageY]); // Each zoom button enables zoom lock, since otherwise changing the zoom level would // only have an effect when click-dragging, so it looks like the buttons don't do anything. this._on_click_viewer.zoom_level = level; this._on_click_viewer.locked_zoom = true; this._on_click_viewer.set_image_position([e.pageX, e.pageY], center); this.refresh(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/main_context_menu.js `; ppixiv.resources["src/create_zip.js"] = `"use strict"; // Create an uncompressed ZIP from a list of files and filenames. ppixiv.create_zip = function(filenames, files) { if(filenames.length != files.length) throw "Mismatched array lengths"; // Encode the filenames. var filename_blobs = []; for(var i = 0; i < filenames.length; ++i) { var filename = new Blob([filenames[i]]); filename_blobs.push(filename); } // Make CRC32s, and create blobs for each file. var blobs = []; var crc32s = []; for(var i = 0; i < filenames.length; ++i) { var data = files[i]; var crc = crc32(new Int8Array(data)); crc32s.push(crc); blobs.push(new Blob([data])); } var parts = []; var file_pos = 0; var file_offsets = []; for(var i = 0; i < filenames.length; ++i) { var filename = filename_blobs[i]; var data = blobs[i]; var crc = crc32s[i]; // Remember the position of the local file header for this file. file_offsets.push(file_pos); var local_file_header = this.create_local_file_header(filename, data, crc); parts.push(local_file_header); file_pos += local_file_header.size; // Add the data. parts.push(data); file_pos += data.size; } // Create the central directory. var central_directory_pos = file_pos; var central_directory_size = 0; for(var i = 0; i < filenames.length; ++i) { var filename = filename_blobs[i]; var data = blobs[i]; var crc = crc32s[i]; var file_offset = file_offsets[i]; var central_record = this.create_central_directory_entry(filename, data, file_offset, crc); central_directory_size += central_record.size; parts.push(central_record); } var end_central_record = this.create_end_central(filenames.length, central_directory_pos, central_directory_size); parts.push(end_central_record); return new Blob(parts, { "type": "application/zip", }); }; ppixiv.create_zip.prototype.create_local_file_header = function(filename, file, crc) { var data = struct(" 0) console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", ")); illust_ids = illust_ids.slice(); for(let new_id of ids_to_remove) { let idx = illust_ids.indexOf(new_id); illust_ids.splice(idx, 1); } // If there's nothing on this page, don't add it, so this doesn't increase // get_highest_loaded_page(). // FIXME: If we removed everything, the data source will appear to have reached the last // page and we won't load any more pages, since thumbnail_view assumes that a page not // returning any data means we're at the end. if(illust_ids.length == 0) return; this.illust_ids_by_page.set(page, illust_ids); }; // Return the page number illust_id is on, or null if we don't know. get_page_for_illust(illust_id) { for(let [page, ids] of this.illust_ids_by_page) { if(ids.indexOf(illust_id) != -1) return page; } return null; }; // Return the next or previous illustration. If we don't have that page, return null. // // This only returns illustrations, skipping over any special entries like user:12345. get_neighboring_illust_id(illust_id, next) { for(let i = 0; i < 100; ++i) // sanity limit { illust_id = this._get_neighboring_illust_id_internal(illust_id, next); if(illust_id == null) return null; // If it's not an illustration, keep looking. if(helpers.parse_id(illust_id).type == "illust") return illust_id; } return null; } // The actual logic for get_neighboring_illust_id, except for skipping entries. _get_neighboring_illust_id_internal(illust_id, next) { let page = this.get_page_for_illust(illust_id); if(page == null) return null; let ids = this.illust_ids_by_page.get(page); let idx = ids.indexOf(illust_id); let new_idx = idx + (next? +1:-1); if(new_idx < 0) { // Return the last illustration on the previous page, or null if that page isn't loaded. let prev_page_no = page - 1; let prev_page_illust_ids = this.illust_ids_by_page.get(prev_page_no); if(prev_page_illust_ids == null) return null; return prev_page_illust_ids[prev_page_illust_ids.length-1]; } else if(new_idx >= ids.length) { // Return the first illustration on the next page, or null if that page isn't loaded. let next_page_no = page + 1; let next_page_illust_ids = this.illust_ids_by_page.get(next_page_no); if(next_page_illust_ids == null) return null; return next_page_illust_ids[0]; } else { return ids[new_idx]; } }; // Return the page we need to load to get the next or previous illustration. This only // makes sense if get_neighboring_illust returns null. get_page_for_neighboring_illust(illust_id, next) { let page = this.get_page_for_illust(illust_id); if(page == null) return null; let ids = this.illust_ids_by_page.get(page); let idx = ids.indexOf(illust_id); let new_idx = idx + (next? +1:-1); if(new_idx >= 0 && new_idx < ids.length) return page; page += next? +1:-1; return page; }; // Return the first ID, or null if we don't have any. get_first_id() { if(this.illust_ids_by_page.size == 0) return null; let keys = this.illust_ids_by_page.keys(); let page = keys.next().value; return this.illust_ids_by_page.get(page)[0]; } // Return true if the given page is loaded. is_page_loaded(page) { return this.illust_ids_by_page.has(page); } }; // A data source asynchronously loads illust_ids to show. The callback will be called // with: // { // 'illust': { // illust_id1: illust_data1, // illust_id2: illust_data2, // ... // }, // illust_ids: [illust_id1, illust_id2, ...] // next: function, // } // // Some sources can retrieve user data, some can retrieve only illustration data, and // some can't retrieve anything but IDs. // // The callback will always be called asynchronously, and data_source.callback can be set // after creation. // // If "next" is included, it's a function that can be called to create a new data source // to load the next page of data. If there are no more pages, next will be null. // A data source handles a particular source of images, depending on what page we're // on: // // - Retrieves batches of image IDs to display, eg. a single page of bookmark results // - Load another page of results with load_more() // - Updates the page URL to reflect the current image // // Not all data sources have multiple pages. For example, when we're viewing a regular // illustration page, we get all of the author's other illust IDs at once, so we just // load all of them as a single page. class data_source { constructor(url) { this.url = new URL(url); this.id_list = new illust_id_list(); this.update_callbacks = []; this.loading_pages = {}; this.first_empty_page = -1; this.update_callbacks = []; // If this data source supports a start page, store the page we started on. // This isn't increased as we load more pages, but if we load earlier results // because the user clicks "load previous results", we'll reduce it. if(this.supports_start_page) { let args = new helpers.args(url); this.initial_page = this.get_start_page(args); console.log("Starting at page", this.initial_page); } else this.initial_page = 1; }; // If a data source returns a name, we'll display any .data-source-specific elements in // the thumbnail view with that name. get name() { return null; } // Most data sources are for illustrations. This is set to "users" for the followed view. get search_mode() { return "illusts"; } // Return a canonical URL for this data source. If the canonical URL is the same, // the same instance of the data source should be used. // // A single data source is used eg. for a particular search and search flags. If // flags are changed, such as changing filters, a new data source instance is created. // However, some parts of the URL don't cause a new data source to be used. Return // a URL with all unrelated parts removed, and with query and hash parameters sorted // alphabetically. static get_canonical_url(url) { // Make a copy of the URL. var url = new URL(url); url = this.remove_ignored_url_parts(url); // Sort query parameters. We don't use multiple parameters with the same key. url.search = helpers.sort_query_parameters(url.searchParams).toString(); let args = new helpers.args(url); // Sort hash parameters. args.hash = helpers.sort_query_parameters(args.hash); return args.url.toString(); } // This is overridden by subclasses to remove parts of the URL that don't affect // which data source instance is used. static remove_ignored_url_parts(url) { // If p=1 is in the query, it's the page number, which doesn't affect the data source. url.searchParams.delete("p"); let args = new helpers.args(url); // The manga page doesn't affect the data source. args.hash.delete("page"); // #view=thumbs controls which view is active. args.hash.delete("view"); // illust_id in the hash is always just telling us which image within the current // data source to view. data_source_current_illust is different and is handled in // the subclass. args.hash.delete("illust_id"); // These are for quick view and don't affect the data source. args.hash.delete("virtual"); args.hash.delete("quick-view"); // This is for overriding muting. args.hash.delete("view-muted"); return args.url; } // startup() is called when the data source becomes active, and shutdown is called when // it's done. This can be used to add and remove event handlers on the UI. startup() { this.active = true; } shutdown() { this.active = false; } // Load the given page, or the page of the current history state if page is null. // Call callback when the load finishes. // // If we synchronously know that the page doesn't exist, return false and don't // call callback. Otherwise, return true. load_page(page, { cause }={}) { var result = this.loading_pages[page]; if(result == null) { var result = this._load_page_async(page, cause); this.loading_pages[page] = result; result.finally(() => { // console.log("finished loading page", page); delete this.loading_pages[page]; }); } return result; } // Return true if the given page is either loaded, or currently being loaded by a call to load_page. is_page_loaded_or_loading(page) { if(this.id_list.is_page_loaded(page)) return true; if(this.loading_pages[page]) return true; return false; } // Return true if any page is currently loading. get any_page_loading() { for(let page in this.loading_pages) if(this.loading_pages[page]) return true; return false; } async _load_page_async(page, cause) { // Check if we're trying to load backwards too far. if(page < 1) { console.info("No pages before page 1"); return false; } // If we know there's no data on this page (eg. we loaded an earlier page before and it // was empty), don't try to load this one. This prevents us from spamming empty page // requests. if(this.first_empty_page != -1 && page >= this.first_empty_page) return false; // If the page is already loaded, stop. if(this.id_list.is_page_loaded(page)) return true; // Check if this is past the end. if(!this.load_page_available(page)) return false; console.log("Load page", page, "for:", cause); // Before starting, await at least once so we get pushed to the event loop. This // guarantees that load_page has a chance to store us in this.loading_pages before // we do anything that might have side-effects of starting another load. await null; // Start the actual load. var result = await this.load_page_internal(page); // Reduce the start page, which will update the "load more results" button if any. It's important // to do this after the await above. If we do it before, it'll update the button before we load // and cause the button to update before the thumbs. screen_search.refresh_images won't be able // to optimize that and it'll cause uglier refreshes. if(this.supports_start_page && page < this.initial_page) this.initial_page = page; // If there were no results, then we've loaded the last page. Don't try to load // any pages beyond this. if(!this.id_list.illust_ids_by_page.has(page)) { console.log("No data on page", page); if(this.first_empty_page == -1 || page < this.first_empty_page) this.first_empty_page = page; }; return true; } // Return the illust_id to display by default. // // This should only be called after the initial data is loaded. get_current_illust_id() { // If we have an explicit illust_id in the hash, use it. Note that some pages (in // particular illustration pages) put this in the query, which is handled in the particular // data source. let args = helpers.args.location; if(args.hash.has("illust_id")) return args.hash.get("illust_id"); return this.id_list.get_first_id(); }; // Return the page title to use. get page_title() { return "Pixiv"; } // This is implemented by the subclass. async load_page_internal(page) { throw "Not implemented"; } // Return true if page is an available page (not past the end). // // We'll always stop if we read a page and it's empty. This allows the extra // last request to be avoided if we know the last page earlier. load_page_available(page) { return true; } // This is called when the currently displayed illust_id changes. The illust_id should // always have been loaded by this data source, so it should be in id_list. The data // source should update the history state to reflect the current state. set_current_illust_id(illust_id, args) { if(this.supports_start_page) { // Store the page the illustration is on in the hash, so if the page is reloaded while // we're showing an illustration, we'll start on that page. If we don't do this and // the user clicks something that came from page 6 while the top of the search results // were on page 5, we'll start the search at page 5 if the page is reloaded and not find // the image, which is confusing. var original_page = this.id_list.get_page_for_illust(illust_id); if(original_page != null) this.set_start_page(args, original_page); } // By default, put the illust_id in the hash. args.hash.set("illust_id", illust_id); } // Return the estimated number of items per page. get estimated_items_per_page() { // Most newer Pixiv pages show a grid of 6x8 images. Try to match it, so page numbers // line up. return 48; }; // Return the screen that should be displayed by default, if no "view" field is in the URL. get default_screen() { return "search"; } // If we're viewing a page specific to a user (an illustration or artist page), return // the user ID we're viewing. This can change when refreshing the UI. get viewing_user_id() { return null; }; // If a data source is transient, it'll be discarded when the user navigates away instead of // reused. get transient() { return false; } // Some data sources can restart the search at a page. get supports_start_page() { return false; } // Store the current page in the URL. // // This is only used if supports_start_page is true. set_start_page(args, page) { args.query.set("p", page); } get_start_page(args) { let page = args.query.get("p") || "1"; return parseInt(page) || 1; } // Add or remove an update listener. These are called when the data source has new data, // or wants a UI refresh to happen. add_update_listener(callback) { this.update_callbacks.push(callback); } remove_update_listener(callback) { var idx = this.update_callbacks.indexOf(callback); if(idx != -1) this.update_callbacks.splice(idx); } // Register a page of data. add_page(page, illust_ids) { this.id_list.add_page(page, illust_ids); // Call update listeners asynchronously to let them know we have more data. helpers.yield(() => { this.call_update_listeners(); }); } call_update_listeners() { var callbacks = this.update_callbacks.slice(); for(var callback of callbacks) callback(); } // Refresh parts of the UI that are specific to this data source. This is only called // when first activating a data source, to update things like input fields that shouldn't // be overwritten on each refresh. initial_refresh_thumbnail_ui(container, view) { } // Each data source can have a different UI in the thumbnail view. container is // the thumbnail-ui-box container to refresh. refresh_thumbnail_ui(container, view) { } // A helper for setting up UI links. Find the link with the given data-type, // set all {key: value} entries as query parameters, and remove any query parameters // where value is null. Set .selected if the resulting URL matches the current one. // // If default_values is present, it tells us the default key that will be used if // a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting // s_mode. We prefer to omit it rather than clutter the URL with defaults, but we // need to know this to figure out whether an item is selected or not. // // If a key begins with #, it's placed in the hash rather than the query. set_item(container, type, fields, default_values) { var link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } // This button is selected if all of the keys it sets are present in the URL. var button_is_selected = true; // Adjust the URL for this button. let url = new URL(this.url); // Don't include the page number in search buttons, so clicking a filter goes // back to page 1. url.searchParams.delete("p"); let args = new helpers.args(url); for(var key of Object.keys(fields)) { var original_key = key; var value = fields[key]; // If key begins with "#", it means it goes in the hash. var hash = key.startsWith("#"); if(hash) key = key.substr(1); let params = hash? args.hash:args.query; // The value we're setting in the URL: var this_value = value; if(this_value == null && default_values != null) this_value = default_values[original_key]; // The value currently in the URL: var selected_value = params.get(key); if(selected_value == null && default_values != null) selected_value = default_values[original_key]; // If the URL didn't have the key we're setting, then it isn't selected. if(this_value != selected_value) button_is_selected = false; // If the value we're setting is the default, delete it instead. if(default_values != null && this_value == default_values[original_key]) value = null; if(value != null) params.set(key, value); else params.delete(key); } url = args.url; helpers.set_class(link, "selected", button_is_selected); link.href = url.toString(); }; // Like set_item for query and hash parameters, this sets parameters in the URL. // // Pixiv used to have clean, consistent URLs with page parameters in the query where // they belong, but recently they've started encoding them in an ad hoc way into the // path. For example, what used to look like "/users/12345?type=illust" is now // "/users/12345/illustrations", so they can't be accessed in a generic way. // // index is the index into the path to replace. In "/users/12345/abcd", "users" is // 0 and "abcd" is 2. If the index doesn't exist, the path will be extended, so // replacing index 2 in "/users/12345" will become "/users/12345/abcd". This only // makes sense when adding a single entry. // // Pixiv URLs can optionally have the language prefixed (which doesn't make sense). // This is handled automatically by get_path_part and set_path_part, and index should // always be for URLs without the language. set_path_item(container, type, index, value) { let link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } // Adjust the URL for this button. let url = new URL(this.url); // Don't include the page number in search buttons, so clicking a filter goes // back to page 1. url.searchParams.delete("p"); // This button is selected if the given value was already set. let button_is_selected = helpers.get_path_part(url, index) == value; // Replace the path part. url = helpers.set_path_part(url, index, value); helpers.set_class(link, "selected", button_is_selected); link.href = url.toString(); }; // Highlight search menu popups if any entry other than the default in them is // selected. // // selector_list is a list of selectors for each menu item. If any of them are // selected and don't have the data-default attribute, set .active on the popup. // Search filters // Set the active class on all top-level dropdowns which have something other than // the default selected. set_active_popup_highlight(container, selector_list) { for(var popup of selector_list) { var box = container.querySelector(popup); var selected_item = box.querySelector(".selected"); if(selected_item == null) { // There's no selected item. If there's no default item then this is normal, but if // there's a default item, it should have been selected by default, so this is probably // a bug. var default_entry_exists = box.querySelector("[data-default]") != null; if(default_entry_exists) console.warn("Popup", popup, "has no selection"); continue; } var selected_default = selected_item.dataset["default"]; helpers.set_class(box, "active", !selected_default); // Find the dropdown menu button. let menu_button = box.querySelector(".menu-button"); if(menu_button == null) { console.warn("Couldn't find menu button for " + box); continue; } // Store the original text, so we can restore it when the default is selected. if(menu_button.dataset.originalText == null) menu_button.dataset.originalText = menu_button.innerText; // If an option is selected, replace the menu button text with the selection's label. if(selected_default) menu_button.innerText = menu_button.dataset.originalText; else { // The short label is used to try to keep these labels from causing the menu buttons to // overflow the container, and for labels like "2 years ago" where the menu text doesn't // make sense. let label = selected_item.dataset.shortLabel; menu_button.innerText = label? label:selected_item.innerText; } } } // Return true of the thumbnail view should show bookmark icons for this source. get show_bookmark_icons() { return true; } // URLs added to links will be included in the links at the top of the page when viewing an artist. add_extra_links(links) { } }; // Load a list of illust IDs, and allow retriving them by page. function paginate_illust_ids(illust_ids, items_per_page) { // Paginate the big list of results. var pages = []; var page = null; for(var illust_id of illust_ids) { if(page == null) { page = []; pages.push(page); } page.push(illust_id); if(page.length == items_per_page) page = null; } return pages; } // This extends data_source with local pagination. // // A few API calls just return all results as a big list of IDs. We can handle loading // them all at once, but it results in a very long scroll box, which makes scrolling // awkward. This artificially paginates the results. class data_source_fake_pagination extends data_source { async load_page_internal(page) { if(this.pages == null) { var illust_ids = await this.load_all_results(); this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page); } // Register this page. var illust_ids = this.pages[page-1] || []; this.add_page(page, illust_ids); } // Implemented by the subclass. Load all results, and return the resulting IDs. async load_all_results() { throw "Not implemented"; } } // /discovery - Recommended Works ppixiv.data_sources.discovery = class extends data_source { get name() { return "discovery"; } get estimated_items_per_page() { return 60; } async load_page_internal(page) { // Get "mode" from the URL. If it's not present, use "all". let mode = this.url.searchParams.get("mode") || "all"; let result = await helpers.get_request("/ajax/discovery/artworks", { limit: this.estimated_items_per_page, mode: mode, lang: "en", }); // result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds // has info about why it recommended it. let thumbs = result.body.thumbnails.illust; thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal"); let illust_ids = []; for(let thumb of thumbs) illust_ids.push(thumb.id); tag_translations.get().add_translations_dict(result.body.tagTranslation); this.add_page(page, illust_ids); }; get page_title() { return "Discovery"; } get_displaying_text() { return "Recommended Works"; } refresh_thumbnail_ui(container) { // Set .selected on the current mode. let current_mode = this.url.searchParams.get("mode") || "all"; helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all"); helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe"); helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18"); } } // bookmark_detail.php#recommendations=1 - Similar Illustrations // // We use this as an anchor page for viewing recommended illusts for an image, since // there's no dedicated page for this. ppixiv.data_sources.related_illusts = class extends data_source { get name() { return "related-illusts"; } get estimated_items_per_page() { return 60; } async _load_page_async(page, cause) { // The first time we load a page, get info about the source illustration too, so // we can show it in the UI. if(!this.fetched_illust_info) { this.fetched_illust_info = true; // Don't wait for this to finish before continuing. let illust_id = this.url.searchParams.get("illust_id"); image_data.singleton().get_image_info(illust_id).then((illust_info) => { this.illust_info = illust_info; this.call_update_listeners(); }).catch((e) => { console.error(e); }); } return await super._load_page_async(page, cause); } async load_page_internal(page) { // Get "mode" from the URL. If it's not present, use "all". let mode = this.url.searchParams.get("mode") || "all"; let result = await helpers.get_request("/ajax/discovery/artworks", { sampleIllustId: this.url.searchParams.get("illust_id"), mode: mode, limit: this.estimated_items_per_page, lang: "en", }); // result.body.recommendedIllusts[].recommendMethods, recommendSeedIllustIds // has info about why it recommended it. let thumbs = result.body.thumbnails.illust; thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal"); let illust_ids = []; for(let thumb of thumbs) illust_ids.push(thumb.id); tag_translations.get().add_translations_dict(result.body.tagTranslation); this.add_page(page, illust_ids); }; get page_title() { return "Similar Illusts"; } get_displaying_text() { return "Similar Illustrations"; } refresh_thumbnail_ui(container) { // Set the source image. var source_link = container.querySelector(".image-for-suggestions"); source_link.hidden = this.illust_info == null; if(this.illust_info) { source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv"; var img = source_link.querySelector(".image-for-suggestions > img"); img.src = this.illust_info.urls.thumb; } } } // Artist suggestions take a random sample of followed users, and query suggestions from them. // The followed user list normally comes from /discovery/users. // // This can also be used to view recommendations based on a specific user. Note that if we're // doing this, we don't show things like the artist's avatar in the corner, so it doesn't look // like the images we're showing are by that user. ppixiv.data_sources.discovery_users = class extends data_source { get name() { return "discovery_users"; } constructor(url) { super(url); let args = new helpers.args(this.url); let user_id = args.hash.get("user_id"); if(user_id != null) this.showing_user_id = user_id; this.original_url = url; this.seen_user_ids = {}; } get users_per_page() { return 20; } get estimated_items_per_page() { let illusts_per_user = this.showing_user_id != null? 3:5; return this.users_per_page + (users_per_page * illusts_per_user); } async load_page_internal(page) { if(this.showing_user_id != null) { // Make sure the user info is loaded. this.user_info = await image_data.singleton().get_user_info_full(this.showing_user_id); // Update to refresh our page title, which uses user_info. this.call_update_listeners(); } // Get suggestions. Each entry is a user, and contains info about a small selection of // images. let result; if(this.showing_user_id != null) { result = await helpers.get_request(\`/ajax/user/\${this.showing_user_id}/recommends\`, { userNum: this.users_per_page, workNum: 3, isR18: true, lang: "en" }); } else { result = await helpers.get_request("/ajax/discovery/users", { limit: this.users_per_page, lang: "en", }); // This one includes tag translations. tag_translations.get().add_translations_dict(result.body.tagTranslation); } if(result.error) throw "Error reading suggestions: " + result.message; thumbnail_data.singleton().loaded_thumbnail_info(result.body.thumbnails.illust, "normal"); for(let user of result.body.users) { image_data.singleton().add_user_data(user); // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data(user, "recommendations"); } // Pixiv's motto: "never do the same thing the same way twice" // ajax/user/#/recommends is body.recommendUsers and user.illustIds. // discovery/users is body.recommendedUsers and user.recentIllustIds. let recommended_users = result.body.recommendUsers || result.body.recommendedUsers; let illust_ids = []; for(let user of recommended_users) { // Each time we load a "page", we're actually just getting a new randomized set of recommendations // for our seed, so we'll often get duplicate results. Ignore users that we've seen already. id_list // will remove dupes, but we might get different sample illustrations for a duplicated artist, and // those wouldn't be removed. if(this.seen_user_ids[user.userId]) continue; this.seen_user_ids[user.userId] = true; illust_ids.push("user:" + user.userId); let illustIds = user.illustIds || user.recentIllustIds; for(let illust_id of illustIds) illust_ids.push(illust_id); } // Register the new page of data. this.add_page(page, illust_ids); } load_page_available(page) { // If we're showing similar users, only show one page, since the API returns the // same thing every time. if(this.showing_user_id) return page == 1; return true; } get estimated_items_per_page() { return 30; } get page_title() { if(this.showing_user_id == null) return "Recommended Users"; if(this.user_info) return this.user_info.name; else return "Loading..."; } get_displaying_text() { if(this.showing_user_id == null) return "Recommended Users"; if(this.user_info) return "Similar artists to " + this.user_info.name; else return "Illustrations"; }; refresh_thumbnail_ui(container) { } }; // /ranking.php // // This one has an API, and also formats the first page of results into the page. // They have completely different formats, and the page is updated dynamically (unlike // the pages we scrape), so we ignore the page for this one and just use the API. // // An exception is that we load the previous and next days from the page. This is better // than using our current date, since it makes sure we have the same view of time as // the search results. ppixiv.data_sources.rankings = class extends data_source { constructor(url) { super(url); this.max_page = 999999; } get name() { return "rankings"; } load_page_available(page) { return page <= this.max_page; } async load_page_internal(page) { /* "mode": "daily", "content": "all", "page": 1, "prev": false, "next": 2, "date": "20180923", "prev_date": "20180922", "next_date": false, "rank_total": 500 */ // Get "mode" from the URL. If it's not present, use "all". var query_args = this.url.searchParams; var data = { format: "json", p: page, }; var date = query_args.get("date"); if(date) data.date = date; var content = query_args.get("content"); if(content) data.content = content; var mode = query_args.get("mode"); if(mode) data.mode = mode; var result = await helpers.get_request("/ranking.php", data); // If "next" is false, this is the last page. if(!result.next) this.max_page = Math.min(page, this.max_page); // Fill in the next/prev dates for the navigation buttons, and the currently // displayed date. if(this.today_text == null) { this.today_text = result.date; // This is "YYYYMMDD". Reformat it. if(this.today_text.length == 8) { var year = this.today_text.slice(0,4); var month = this.today_text.slice(4,6); var day = this.today_text.slice(6,8); this.today_text = year + "/" + month + "/" + day; } } if(this.prev_date == null && result.prev_date) this.prev_date = result.prev_date; if(this.next_date == null && result.next_date) this.next_date = result.next_date; // This returns a struct of data that's like the thumbnails data response, // but it's not quite the same. var illust_ids = []; for(var item of result.contents) { // Most APIs return IDs as strings, but this one returns them as ints. // Convert them to strings. var illust_id = "" + item.illust_id; var user_id = "" + item.user_id; illust_ids.push(illust_id); } // Register this as thumbnail data. thumbnail_data.singleton().loaded_thumbnail_info(result.contents, "rankings"); // Register the new page of data. this.add_page(page, illust_ids); }; get estimated_items_per_page() { return 50; } get page_title() { return "Rankings"; } get_displaying_text() { return "Rankings"; } refresh_thumbnail_ui(container) { var query_args = this.url.searchParams; this.set_item(container, "content-all", {content: null}); this.set_item(container, "content-illust", {content: "illust"}); this.set_item(container, "content-ugoira", {content: "ugoira"}); this.set_item(container, "content-manga", {content: "manga"}); this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"}); this.set_item(container, "mode-daily-r18", {mode: "daily_r18"}); this.set_item(container, "mode-r18g", {mode: "r18g"}); this.set_item(container, "mode-weekly", {mode: "weekly"}); this.set_item(container, "mode-monthly", {mode: "monthly"}); this.set_item(container, "mode-rookie", {mode: "rookie"}); this.set_item(container, "mode-original", {mode: "original"}); this.set_item(container, "mode-male", {mode: "male"}); this.set_item(container, "mode-female", {mode: "female"}); if(this.today_text) container.querySelector(".nav-today").innerText = this.today_text; // This UI is greyed rather than hidden before we have the dates, so the UI doesn't // shift around as we load. var yesterday = container.querySelector(".nav-yesterday"); helpers.set_class(yesterday.querySelector(".box-link"), "disabled", this.prev_date == null); if(this.prev_date) { let url = new URL(this.url); url.searchParams.set("date", this.prev_date); yesterday.querySelector("a").href = url; } var tomorrow = container.querySelector(".nav-tomorrow"); helpers.set_class(tomorrow.querySelector(".box-link"), "disabled", this.next_date == null); if(this.next_date) { let url = new URL(this.url); url.searchParams.set("date", this.next_date); tomorrow.querySelector("a").href = url; } // Not all combinations of content and mode exist. For example, there's no ugoira // monthly, and we'll get an error page if we load it. Hide navigations that aren't // available. This isn't perfect: if you want to choose ugoira when you're on monthly // you need to select a different time range first. We could have the content links // switch to daily if not available... var available_combinations = [ "all/daily", "all/daily_r18", "all/r18g", "all/weekly", "all/monthly", "all/rookie", "all/original", "all/male", "all/female", "illust/daily", "illust/daily_r18", "illust/r18g", "illust/weekly", "illust/monthly", "illust/rookie", "ugoira/daily", "ugoira/weekly", "ugoira/daily_r18", "manga/daily", "manga/daily_r18", "manga/r18g", "manga/weekly", "manga/monthly", "manga/rookie", ]; // Check each link in both checked-links sections. for(var a of container.querySelectorAll(".checked-links a")) { let url = new URL(a.href, this.url); var link_content = url.searchParams.get("content") || "all"; var link_mode = url.searchParams.get("mode") || "daily"; var name = link_content + "/" + link_mode; var available = available_combinations.indexOf(name) != -1; var is_content_link = a.dataset.type.startsWith("content"); if(is_content_link) { // If this is a content link (eg. illustrations) and the combination of the // current time range and this content type isn't available, make this link // go to daily rather than hiding it, so all content types are always available // and you don't have to switch time ranges just to select a different type. if(!available) { url.searchParams.delete("mode"); a.href = url; } } else { // If this is a mode link (eg. weekly) and it's not available, just hide // the link. a.hidden = !available; } } } } // This is a base class for data sources that work by loading a regular Pixiv page // and scraping it. // // All of these work the same way. We keep the current URL (ignoring the hash) synced up // as a valid page URL that we can load. If we change pages or other search options, we // modify the URL appropriately. class data_source_from_page extends data_source { constructor(url) { super(url); this.items_per_page = 1; this.original_url = url; } get estimated_items_per_page() { return this.items_per_page; } async load_page_internal(page) { // Our page URL looks like eg. // // https://www.pixiv.net/bookmark.php?p=2 // // possibly with other search options. Request the current URL page data. var url = new unsafeWindow.URL(this.url); // Update the URL with the current page. url.searchParams.set("p", page); console.log("Loading:", url.toString()); let doc = await helpers.load_data_in_iframe(url); let illust_ids = this.parse_document(doc); if(illust_ids == null) { // The most common case of there being no data in the document is loading // a deleted illustration. See if we can find an error message. console.error("No data on page"); return; } // Assume that if the first request returns 10 items, all future pages will too. This // is usually correct unless we happen to load the last page last. Allow this to increase // in case that happens. (This is only used by the thumbnail view.) if(this.items_per_page == 1) this.items_per_page = Math.max(illust_ids.length, this.items_per_page); // Register the new page of data. this.add_page(page, illust_ids); } // Parse the loaded document and return the illust_ids. parse_document(doc) { throw "Not implemented"; } }; // - User illustrations // // /users/# // /users/#/artworks // /users/#/illustrations // /users/#/manga // // We prefer to link to the /artworks page, but we handle /users/# as well. ppixiv.data_sources.artist = class extends data_source { get name() { return "artist"; } constructor(url) { super(url); this.fanbox_url = null; } get supports_start_page() { return true; } get viewing_user_id() { // /users/13245 return helpers.get_path_part(this.url, 1); }; startup() { super.startup(); // While we're active, watch for the tags box to open. We only populate the tags // dropdown if it's opened, so we don't load user tags for every user page. var popup = document.body.querySelector(".member-tags-box > .popup-menu-box"); this.src_observer = new MutationObserver((mutation_list) => { if(popup.classList.contains("popup-visible")) this.tag_list_opened(); }); this.src_observer.observe(popup, { attributes: true }); } shutdown() { super.shutdown(); // Remove our MutationObserver. this.src_observer.disconnect(); this.src_observer = null; } // Return "artworks" (all), "illustrations" or "manga". get viewing_type() { // The URL is one of: // // /users/12345 // /users/12345/artworks // /users/12345/illustrations // /users/12345/manga // // The top /users/12345 page is the user's profile page, which has the first page of images, but // instead of having a link to page 2, it only has "See all", which goes to /artworks and shows you // page 1 again. That's pointless, so we treat the top page as /artworks the same. /illustrations // and /manga filter those types. let url = helpers.get_url_without_language(this.url); let parts = url.pathname.split("/"); return parts[3] || "artworks"; } async load_page_internal(page) { let viewing_type = this.type; // Make sure the user info is loaded. This should normally be preloaded by globalInitData // in main.js, and this won't make a request. this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); // Update to refresh our page title, which uses user_info. this.call_update_listeners(); let args = new helpers.args(this.url); var tag = args.query.get("tag") || ""; if(tag == "") { // If we're not filtering by tag, use the profile/all request. This returns all of // the user's illust IDs but no thumb data. // // We can use the "illustmanga" code path for this by leaving the tag empty, but // we do it this way since that's what the site does. if(this.pages == null) { let all_illust_ids = await this.load_all_results(); this.pages = paginate_illust_ids(all_illust_ids, this.estimated_items_per_page); } let illust_ids = this.pages[page-1] || []; if(illust_ids.length) { // That only gives us a list of illust IDs, so we have to load them. Annoyingly, this // is the one single place it gives us illust info in bulk instead of thumbnail data. // It would be really useful to be able to batch load like this in general, but this only // works for a single user's posts. let url = \`/ajax/user/\${this.viewing_user_id}/profile/illusts\`; let result = await helpers.get_request(url, { "ids[]": illust_ids, work_category: "illustManga", is_first_page: "0", }); let illusts = Object.values(result.body.works); thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal"); } // Don't do this. image_data assumes that if we have illust data, we want all data, // like manga pages, and it'll make a request for each one to add the missing info. // Just register it as thumbnail info. // for(let illust_data in illusts) // await image_data.singleton().add_illust_data(illust_data); // Register this page. this.add_page(page, illust_ids); } else { // We're filtering by tag. var type = args.query.get("type"); // For some reason, this API uses a random field in the URL for the type instead of a normal // query parameter. var type_for_url = type == null? "illustmanga": type == "illust"?"illusts": "manga"; var request_url = "/ajax/user/" + this.viewing_user_id + "/" + type_for_url + "/tag"; var result = await helpers.get_request(request_url, { tag: tag, offset: (page-1)*48, limit: 48, }); // This data doesn't have profileImageUrl or userName. That's presumably because it's // used on user pages which get that from user data, but this seems like more of an // inconsistency than an optimization. Fill it in for thumbnail_data. for(var item of result.body.works) { item.userName = this.user_info.name; item.profileImageUrl = this.user_info.imageBig; } var illust_ids = []; for(var illust_data of result.body.works) illust_ids.push(illust_data.id); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } } add_extra_links(links) { // Add the Fanbox link to the list if we have one. if(this.fanbox_url) links.push(this.fanbox_url); } async load_all_results() { this.call_update_listeners(); var query_args = this.url.searchParams; let type = this.viewing_type; var result = await helpers.get_request("/ajax/user/" + this.viewing_user_id + "/profile/all", {}); // See if there's a Fanbox link. // // For some reason Pixiv supports links to Twitter and Pawoo natively in the profile, but Fanbox // can only be linked in this weird way outside the regular user profile info. for(let pickup of result.body.pickup) { if(pickup.type != "fanbox") continue; // Remove the Google analytics junk from the URL. let url = new URL(pickup.contentUrl); url.search = ""; this.fanbox_url = url.toString(); this.call_update_listeners(); } var illust_ids = []; if(type == "artworks" || type == "illustrations") for(var illust_id in result.body.illusts) illust_ids.push(illust_id); if(type == "artworks" || type == "manga") for(var illust_id in result.body.manga) illust_ids.push(illust_id); // Sort the two sets of IDs back together, putting higher (newer) IDs first. illust_ids.sort(function(lhs, rhs) { return parseInt(rhs) - parseInt(lhs); }); return illust_ids; }; refresh_thumbnail_ui(container, thumbnail_view) { thumbnail_view.avatar_widget.visible = true; if(this.user_info) thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id); let viewing_type = this.viewing_type; let url = new URL(this.url); this.set_path_item(container, "artist-works", 2, ""); this.set_path_item(container, "artist-illust", 2, "illustrations"); this.set_path_item(container, "artist-manga", 2, "manga"); // Refresh the post tag list. var query_args = this.url.searchParams; var current_query = query_args.toString(); var tag_list = container.querySelector(".post-tag-list"); helpers.remove_elements(tag_list); var add_tag_link = (tag_info) => { // Skip tags with very few posts. This list includes every tag the author // has ever used, and ends up being pages long with tons of tags that were // only used once. if(tag_info.tag != "All" && tag_info.cnt < 5) return; let tag = tag_info.tag; let translated_tag = tag; if(this.translated_tags[tag]) translated_tag = this.translated_tags[tag]; var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = translated_tag; // Show the post count in the popup. a.classList.add("popup"); a.dataset.popup = tag_info.cnt; let url = new URL(this.url); url.hash = "#ppixiv"; if(tag != "All") url.searchParams.set("tag", tag); else { url.searchParams.delete("tag"); a.dataset["default"] = 1; } a.href = url.toString(); if(url.searchParams.toString() == current_query) a.classList.add("selected"); tag_list.appendChild(a); }; if(this.post_tags != null) { add_tag_link({ tag: "All" }); for(let tag_info of this.post_tags || []) add_tag_link(tag_info); } else { // Tags aren't loaded yet. We'll be refreshed after tag_list_opened loads tags. var span = document.createElement("span"); span.innerText = "Loading..."; tag_list.appendChild(span); } // Set whether the tags menu item is highlighted. We don't use set_active_popup_highlight // here so we don't need to load the tag list. var box = container.querySelector(".member-tags-box"); helpers.set_class(box, "active", query_args.has("tag")); } // This is called when the tag list dropdown is opened. async tag_list_opened() { // Get user info. We probably have this on this.user_info, but that async load // might not be finished yet. var user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); console.log("Loading tags for user", user_info.userId); // Load the user's common tags. this.post_tags = await this.get_user_tags(user_info); let tags = []; for(let tag_info of this.post_tags) tags.push(tag_info.tag); this.translated_tags = await tag_translations.get().get_translations(tags, "en"); // If we became inactive before the above request finished, stop. if(!this.active) return; // Trigger refresh_thumbnail_ui to fill in tags. this.call_update_listeners(); } async get_user_tags(user_info) { if(user_info.frequentTags) return user_info.frequentTags; var result = await helpers.get_request("https://www.pixiv.net/ajax/user/" + user_info.userId + "/illustmanga/tags", {}); if(result.error) { console.error("Error fetching tags for user " + user_info.userId + ": " + result.error); user_info.frequentTags = []; return user_info.frequentTags; } // Sort most frequent tags first. result.body.sort(function(lhs, rhs) { return rhs.cnt - lhs.cnt; }) // Store translations. let translations = []; for(let tag_info of result.body) { if(tag_info.tag_translation == "") continue; translations.push({ tag: tag_info.tag, translation: { en: tag_info.tag_translation, }, }); } tag_translations.get().add_translations(translations); // Cache the results on the user info. user_info.frequentTags = result.body; return result.body; } get page_title() { if(this.user_info) return this.user_info.name; else return "Loading..."; } get_displaying_text() { if(this.user_info) return this.user_info.name + "'s Illustrations"; else return "Illustrations"; }; } // /artworks/# - Viewing a single illustration // // This is a stub for when we're viewing an image with no search. it // doesn't return any search results. ppixiv.data_sources.current_illust = class extends data_source { get name() { return "illust"; } constructor(url) { super(url); // /artworks/# url = new URL(url); url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); this.illust_id = parts[2]; } // Show the illustration by default. get default_screen() { return "illust"; } // This data source just views a single image and doesn't return any posts. async load_page_internal(page) { } // We're always viewing our illust_id. get_current_illust_id() { return this.illust_id; } // We don't return any posts to navigate to, but this can still be called by // quick view. set_current_illust_id(illust_id, args) { // Pixiv's inconsistent URLs are annoying. Figure out where the ID field is. // If the first field is a language, it's the third field (/en/artworks/#), otherwise // it's the second (/artworks/#). let parts = args.path.split("/"); let id_part = parts[1].length == 2? 3:2; parts[id_part] = illust_id; args.path = parts.join("/"); } get viewing_user_id() { if(this.user_info == null) return null; return this.user_info.userId; }; }; // bookmark.php // /users/12345/bookmarks // // If id is in the query, we're viewing another user's bookmarks. Otherwise, we're // viewing our own. // // Pixiv currently serves two unrelated pages for this URL, using an API-driven one // for viewing someone else's bookmarks and a static page for viewing your own. We // always use the API in either case. // // For some reason, Pixiv only allows viewing either public or private bookmarks, // and has no way to just view all bookmarks. class data_source_bookmarks_base extends data_source { get name() { return "bookmarks"; } constructor(url) { super(url); this.bookmark_tag_counts = []; // The subclass sets this once it knows the number of bookmarks in this search. this.total_bookmarks = -1; } async load_page_internal(page) { this.fetch_bookmark_tag_counts(); // Load the user's info. We don't need to wait for this to finish. let user_info_promise = image_data.singleton().get_user_info_full(this.viewing_user_id); user_info_promise.then((user_info) => { // Stop if we were deactivated before this finished. if(!this.active) return; this.user_info = user_info; this.call_update_listeners(); }); await this.continue_loading_page_internal(page); }; get supports_start_page() { // Disable start pages when we're shuffling pages anyway. return !this.shuffle; } get displaying_tag() { let url = helpers.get_url_without_language(this.url); let parts = url.pathname.split("/"); if(parts.length < 6) return null; // Replace 未分類 with "" for uncategorized. let tag = decodeURIComponent(parts[5]); if(tag == "未分類") return ""; return tag; } // If we haven't done so yet, load bookmark tags for this bookmark page. This // happens in parallel with with page loading. async fetch_bookmark_tag_counts() { if(this.fetched_bookmark_tag_counts) return; this.fetched_bookmark_tag_counts = true; // If we have cached bookmark counts for ourself, load them. if(this.viewing_own_bookmarks() && data_source_bookmarks_base.cached_bookmark_tag_counts != null) this.load_bookmark_tag_counts(data_source_bookmarks_base.cached_bookmark_tag_counts); // Fetch bookmark tags. We can do this in parallel with everything else. var url = "https://www.pixiv.net/ajax/user/" + this.viewing_user_id + "/illusts/bookmark/tags"; var result = await helpers.get_request(url, {}); // Cache this if we're viewing our own bookmarks, so we can display them while // navigating bookmarks. We'll still refresh it as each page loads. if(this.viewing_own_bookmarks()) data_source_bookmarks_base.cached_bookmark_tag_counts = result.body; this.load_bookmark_tag_counts(result.body); } load_bookmark_tag_counts(result) { let public_bookmarks = this.viewing_public; let private_bookmarks = this.viewing_private; // Reformat the tag list into a format that's easier to work with. let tags = { }; for(let privacy of ["public", "private"]) { let public_tags = privacy == "public"; if((public_tags && !public_bookmarks) || (!public_tags && !private_bookmarks)) continue; let tag_counts = result[privacy]; for(let tag_info of tag_counts) { let tag = tag_info.tag; // Rename "未分類" (uncategorized) to "". if(tag == "未分類") tag = ""; if(tags[tag] == null) tags[tag] = 0; // Add to the tag count. tags[tag] += tag_info.cnt; } } // Fill in total_bookmarks from the tag count. We'll get this from the search API, // but we can have it here earlier if we're viewing our own bookmarks and // cached_bookmark_tag_counts is filled in. We can't do this when viewing all bookmarks // (summing the counts will give the wrong answer whenever multiple tags are used on // one bookmark). let displaying_tag = this.displaying_tag; if(displaying_tag != null && this.total_bookmarks == -1) { let count = tags[displaying_tag]; if(count != null) this.total_bookmarks = count; } // Sort tags by count, so we can trim just the most used tags. Use the count for the // display mode we're in. var all_tags = Object.keys(tags); all_tags.sort(function(lhs, rhs) { return tags[lhs].count - tags[lhs].count; }); if(!this.viewing_own_bookmarks()) { // Trim the list when viewing other users. Some users will return thousands of tags. all_tags.splice(20); } all_tags.sort(); this.bookmark_tag_counts = {}; for(let tag of all_tags) this.bookmark_tag_counts[tag] = tags[tag]; // Update the UI with the tag list. this.call_update_listeners(); } // Get API arguments to query bookmarks. // // If force_rest isn't null, it's either "show" (public) or "hide" (private), which // overrides the search parameters. get_bookmark_query_params(page, force_rest) { var query_args = this.url.searchParams; var rest = query_args.get("rest") || "show"; if(force_rest != null) rest = force_rest; let tag = this.displaying_tag; if(tag == "") tag = "未分類"; // Uncategorized else if(tag == null) tag = ""; // Load 20 results per page, so our page numbers should match the underlying page if // the UI is disabled. return { tag: tag, offset: (page-1)*this.estimated_items_per_page, limit: this.estimated_items_per_page, rest: rest, // public or private (no way to get both) }; } async request_bookmarks(page, rest) { let data = this.get_bookmark_query_params(page, rest); let url = \`/ajax/user/\${this.viewing_user_id}/illusts/bookmarks\`; let result = await helpers.get_request(url, data); // This request includes each bookmark's tags. Register those with image_data, // so the bookmark tag dropdown can display tags more quickly. for(let illust of result.body.works) { let bookmark_id = illust.bookmarkData.id; let tags = result.body.bookmarkTags[bookmark_id] || []; image_data.singleton().update_cached_bookmark_image_tags(illust.id, tags); } result.body.works = data_source_bookmarks_base.filter_deleted_images(result.body.works); return result.body; } // This is implemented by the subclass to do the main loading. async continue_loading_page_internal(page) { throw "Not implemented"; } get page_title() { if(!this.viewing_own_bookmarks()) { if(this.user_info) return this.user_info.name + "'s Bookmarks"; else return "Loading..."; } return "Bookmarks"; } get_displaying_text() { if(!this.viewing_own_bookmarks()) { if(this.user_info) return this.user_info.name + "'s Bookmarks"; return "User's Bookmarks"; } let args = new helpers.args(this.url); let public_bookmarks = this.viewing_public; let private_bookmarks = this.viewing_private; let viewing_all = public_bookmarks && private_bookmarks; var displaying = ""; if(this.total_bookmarks != -1) displaying += this.total_bookmarks + " "; displaying += viewing_all? "Bookmark": private_bookmarks? "Private Bookmark":"Public Bookmark"; // English-centric pluralization: if(this.total_bookmarks != 1) displaying += "s"; var tag = this.displaying_tag; if(tag == "") displaying += \` with no tags\`; else if(tag != null) displaying += \` with tag "\${tag}"\`; return displaying; }; // Return true if we're viewing publig and private bookmarks. These are overridden // in bookmarks_merged. get viewing_public() { let args = new helpers.args(this.url); return args.query.get("rest") != "hide"; } get viewing_private() { let args = new helpers.args(this.url); return args.query.get("rest") == "hide"; } refresh_thumbnail_ui(container, thumbnail_view) { // The public/private button only makes sense when viewing your own bookmarks. var public_private_button_container = container.querySelector(".bookmarks-public-private"); public_private_button_container.hidden = !this.viewing_own_bookmarks(); // Set up the public and private buttons. The "all" button also removes shuffle, since it's not // supported there. this.set_item(public_private_button_container, "all", {"#show-all": 1, "#shuffle": null}, {"#show-all": 1}); this.set_item(container, "public", {rest: null, "#show-all": 0}, {"#show-all": 1}); this.set_item(container, "private", {rest: "hide", "#show-all": 0}, {"#show-all": 1}); // Shuffle isn't supported for merged bookmarks. If we're on #show-all, make the shuffle button // also switch to public bookmarks. This is easier than graying it out and trying to explain it // in the popup, and better than hiding it which makes it hard to find. let args = new helpers.args(this.url); let show_all = args.hash.get("show-all") != "0"; let set_public = show_all? { rest: null, "#show-all": 0 }:{}; this.set_item(container, "order-date", {"#shuffle": null}, {"#shuffle": null}); this.set_item(container, "order-shuffle", {"#shuffle": 1, ...set_public}, {"#shuffle": null, "#show-all": 1}); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_url = new URL(this.url); current_url.searchParams.delete("p"); var tag_list = container.querySelector(".bookmark-tag-list"); let current_tag = this.displaying_tag; helpers.remove_elements(tag_list); var add_tag_link = (tag) => { let tag_count = this.bookmark_tag_counts[tag]; var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); let tag_name = tag; if(tag_name == null) tag_name = "All"; else if(tag_name == "") tag_name = "Uncategorized"; a.innerText = tag_name; // Show the bookmark count in the popup. if(tag_count != null) { a.classList.add("popup"); a.dataset.popup = tag_count + (tag_count == 1? " bookmark":" bookmarks"); } let url = new URL(this.url); url.searchParams.delete("p"); if(tag == current_tag) a.classList.add("selected"); // Pixiv used to put the tag in a nice, clean query parameter, but recently got // a bit worse and put it in the query. That's a much worse way to do things: // it's harder to parse, and means you bake one particular feature into your // URLs. let old_pathname = helpers.get_url_without_language(url).pathname; let parts = old_pathname.split("/"); if(tag == "") tag = "未分類"; // Uncategorized if(tag == null) // All { if(parts.length == 6) parts = parts.splice(0,5); } else { if(parts.length < 6) parts.push(""); parts[5] = encodeURIComponent(tag); } url.pathname = parts.join("/"); a.href = url.toString(); tag_list.appendChild(a); }; add_tag_link(null); // All add_tag_link(""); // Uncategorized for(var tag of Object.keys(this.bookmark_tag_counts)) { // Skip uncategorized, which is always placed at the beginning. if(tag == "") continue; if(this.bookmark_tag_counts[tag] == 0) continue; add_tag_link(tag); } thumbnail_view.avatar_widget.visible = true; thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id); } get viewing_user_id() { // /users/13245/bookmarks // // This is currently only used for viewing other people's bookmarks. Your own bookmarks are still // viewed with /bookmark.php with no ID. return helpers.get_path_part(this.url, 1); }; // Return true if we're viewing our own bookmarks. viewing_own_bookmarks() { return this.viewing_user_id == window.global_data.user_id; } // Don't show bookmark icons for the user's own bookmarks. Every image on that page // is bookmarked, so it's just a lot of noise. get show_bookmark_icons() { return !this.viewing_own_bookmarks(); } // Bookmark results include deleted images. These are weird and a bit broken: // the post ID is an integer instead of a string (which makes more sense but is // inconsistent with other results) and the data is mostly empty or garbage. // Check isBookmarkable to filter these out. static filter_deleted_images(images) { let result = []; for(let image of images) { if(!image.isBookmarkable) { console.log("Discarded deleted bookmark " + image.id); continue; } result.push(image); } return result; } } // Normal bookmark querying. This can only retrieve public or private bookmarks, // and not both. ppixiv.data_sources.bookmarks = class extends data_source_bookmarks_base { get shuffle() { let args = new helpers.args(this.url); return args.hash.has("shuffle"); } async continue_loading_page_internal(page) { let page_to_load = page; if(this.shuffle) { // We need to know the number of pages in order to shuffle, so load the first page. // This is why we don't support this for merged bookmark loading: we'd need to load // both first pages, then both first shuffled pages, so we'd be making four bookmark // requests all at once. if(this.total_shuffled_bookmarks == null) { let result = await this.request_bookmarks(1, null); this.total_shuffled_bookmarks = result.total; this.total_pages = Math.ceil(this.total_shuffled_bookmarks / this.estimated_items_per_page); // Create a shuffled page list. this.shuffled_pages = []; for(let p = 1; p <= this.total_pages; ++p) this.shuffled_pages.push(p); helpers.shuffle_array(this.shuffled_pages); } if(page < this.shuffled_pages.length) page_to_load = this.shuffled_pages[page]; } let result = await this.request_bookmarks(page_to_load, null); var illust_ids = []; for(let illust_data of result.works) illust_ids.push(illust_data.id); // If we're shuffling, shuffle the individual illustrations too. if(this.shuffle) helpers.shuffle_array(illust_ids); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.works, "normal"); // Register the new page of data. If we're shuffling, use the original page number, not the // shuffled page. this.add_page(page, illust_ids); // Remember the total count, for display. this.total_bookmarks = result.total; } }; // Merged bookmark querying. This makes queries for both public and private bookmarks, // and merges them together. ppixiv.data_sources.bookmarks_merged = class extends data_source_bookmarks_base { get viewing_public() { return true; } get viewing_private() { return true; } constructor(url) { super(url); this.max_page_per_type = [-1, -1]; // public, private this.bookmark_illust_ids = [[], []]; // public, private this.bookmark_totals = [0, 0]; // public, private } async continue_loading_page_internal(page) { // Request both the public and private bookmarks on the given page. If we've // already reached the end of either of them, don't send that request. let request1 = this.request_bookmark_type(page, "show"); let request2 = this.request_bookmark_type(page, "hide"); // Wait for both requests to finish. await Promise.all([request1, request2]); // Both requests finished. Combine the two lists of illust IDs into a single page // and register it. var illust_ids = []; for(var i = 0; i < 2; ++i) if(this.bookmark_illust_ids[i] != null && this.bookmark_illust_ids[i][page] != null) illust_ids = illust_ids.concat(this.bookmark_illust_ids[i][page]); this.add_page(page, illust_ids); // Combine the two totals. this.total_bookmarks = this.bookmark_totals[0] + this.bookmark_totals[1]; } async request_bookmark_type(page, rest) { var is_private = rest == "hide"? 1:0; var max_page = this.max_page_per_type[is_private]; if(max_page != -1 && page > max_page) { // We're past the end. console.log("page", page, "beyond", max_page, rest); return; } let result = await this.request_bookmarks(page, rest); // Put higher (newer) bookmarks first. result.works.sort(function(lhs, rhs) { return parseInt(rhs.bookmarkData.id) - parseInt(lhs.bookmarkData.id); }); var illust_ids = []; for(let illust_data of result.works) illust_ids.push(illust_data.id); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.works, "normal"); // If there are no results, remember that this is the last page, so we don't // make more requests for this type. if(illust_ids.length == 0) { if(this.max_page_per_type[is_private] == -1) this.max_page_per_type[is_private] = page; else this.max_page_per_type[is_private] = Math.min(page, this.max_page_per_type[is_private]); // console.log("max page for", is_private? "private":"public", this.max_page_per_type[is_private]); } // Store the IDs. We don't register them here. this.bookmark_illust_ids[is_private][page] = illust_ids; // Remember the total count, for display. this.bookmark_totals[is_private] = result.total; } } // new_illust.php ppixiv.data_sources.new_illust = class extends data_source { get name() { return "new_illust"; } get page_title() { return "New Works"; } get_displaying_text() { return "New Works"; }; async load_page_internal(page) { let args = new helpers.args(this.url); // new_illust.php or new_illust_r18.php: let r18 = this.url.pathname == "/new_illust_r18.php"; var type = args.query.get("type") || "illust"; // Everything Pixiv does has always been based on page numbers, but this one uses starting IDs. // That's a better way (avoids duplicates when moving forward in the list), but it's inconsistent // with everything else. We usually load from page 1 upwards. If we're loading the next page and // we have a previous last_id, assume it starts at that ID. // // This makes some assumptions about how we're called: that we won't be called for the same page // multiple times and we're always loaded in ascending order. In practice this is almost always // true. If Pixiv starts using this method for more important pages it might be worth checking // this more carefully. if(this.last_id == null) { this.last_id = 0; this.last_id_page = 1; } if(this.last_id_page != page) { console.error("Pages weren't loaded in order"); return; } console.log("Assuming page", page, "starts at", this.last_id); var url = "/ajax/illust/new"; var result = await helpers.get_request(url, { limit: 20, type: type, r18: r18, lastId: this.last_id, }); var illust_ids = []; for(var illust_data of result.body.illusts) illust_ids.push(illust_data.id); if(illust_ids.length > 0) { this.last_id = illust_ids[illust_ids.length-1]; this.last_id_page++; } // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.illusts, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } refresh_thumbnail_ui(container) { this.set_item(container, "new-illust-type-illust", {type: null}); this.set_item(container, "new-illust-type-manga", {type: "manga"}); // These links are different from anything else on the site: they switch between // two top-level pages, even though they're just flags and everything else is the // same. We don't actually need to do this since we're just making API calls, but // we try to keep the base URLs compatible, so we go to the equivalent page on Pixiv // if we're turned off. var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']"); var r18_link = container.querySelector("[data-type='new-illust-ages-r18']"); let url = new URL(this.url); url.pathname = "/new_illust.php"; all_ages_link.href = url; url = new URL(this.url); url.pathname = "/new_illust_r18.php"; r18_link.href = url; url = new URL(this.url); var currently_all_ages = url.pathname == "/new_illust.php"; helpers.set_class(all_ages_link, "selected", currently_all_ages); helpers.set_class(r18_link, "selected", !currently_all_ages); } } // bookmark_new_illust.php, bookmark_new_illust_r18.php ppixiv.data_sources.bookmarks_new_illust = class extends data_source { get name() { return "bookmarks_new_illust"; } constructor(url) { super(url); this.bookmark_tags = []; } async load_page_internal(page) { let current_tag = this.url.searchParams.get("tag") || ""; let r18 = this.url.pathname == "/bookmark_new_illust_r18.php"; let result = await helpers.get_request("/ajax/follow_latest/illust", { p: page, tag: current_tag, mode: r18? "r18":"all", }); let data = result.body; // Add translations. tag_translations.get().add_translations_dict(data.tagTranslation); // Store bookmark tags. this.bookmark_tags = data.page.tags; // Populate thumbnail data with this data. thumbnail_data.singleton().loaded_thumbnail_info(data.thumbnails.illust, "normal"); let illust_ids = []; for(let illust of data.thumbnails.illust) illust_ids.push(illust.id); // Register the new page of data. this.add_page(page, illust_ids); } get page_title() { return "Following"; } get_displaying_text() { return "Following"; }; refresh_thumbnail_ui(container) { // Refresh the bookmark tag list. let current_tag = this.url.searchParams.get("tag") || "All"; var tag_list = container.querySelector(".follow-new-post-tag-list"); helpers.remove_elements(tag_list); let add_tag_link = (tag) => { var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = tag; var url = new URL(this.url); if(tag != "All") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); a.href = url.toString(); if(tag == current_tag) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All"); for(var tag of this.bookmark_tags) add_tag_link(tag); var all_ages_link = container.querySelector("[data-type='bookmarks-new-illust-all']"); var r18_link = container.querySelector("[data-type='bookmarks-new-illust-ages-r18']"); var url = new URL(this.url); url.pathname = "/bookmark_new_illust.php"; all_ages_link.href = url; var url = new URL(this.url); url.pathname = "/bookmark_new_illust_r18.php"; r18_link.href = url; var url = new URL(this.url); var currently_all_ages = url.pathname == "/bookmark_new_illust.php"; helpers.set_class(all_ages_link, "selected", currently_all_ages); helpers.set_class(r18_link, "selected", !currently_all_ages); } }; // /tags // // The new tag search UI is a bewildering mess: // // - Searching for a tag goes to "/tags/TAG/artworks". This searches all posts with the // tag. The API query is "/ajax/search/artworks/TAG". The "top" tab is highlighted, but // it's not actually on that tab and no tab button goes back here. "Illustrations, Manga, // Ugoira" in search options also goes here. // // - The "Illustrations" tab goes to "/tags/TAG/illustrations". The API is // "/ajax/search/illustrations/TAG?type=illust_and_ugoira". This is almost identical to // "artworks", but excludes posts marked as manga. "Illustrations, Ugoira" in search // options also goes here. // // - Clicking "manga" goes to "/tags/TAG/manga". The API is "/ajax/search/manga" and also // sets type=manga. This is "Manga" in the search options. This page is also useless. // // The "manga only" and "exclude manga" pages are useless, since Pixiv doesn't make any // useful distinction between "manga" and "illustrations with more than one page". We // only include them for completeness. // // - You can search for just animations, but there's no button for it in the UI. You // have to pick it from the dropdown in search options. This one is "illustrations?type=ugoira". // Why did they keep using type just for one search mode? Saying "type=manga" or any // other type fails, so it really is just used for this. // // - Clicking "Top" goes to "/tags/TAG" with no type. This is a completely different // page and API, "/ajax/search/top/TAG". It doesn't actually seem to be a rankings // page and just shows the same thing as the others with a different layout, so we // ignore this and treat it like "artworks". ppixiv.data_sources.search = class extends data_source { get name() { return "search"; } constructor(url) { super(url); this.cache_search_title = this.cache_search_title.bind(this); // Add the search tags to tag history. We only do this at the start when the // data source is created, not every time we navigate back to the search. let tag = this._search_tags; if(tag) helpers.add_recent_search_tag(tag); this.cache_search_title(); } get _search_tags() { return helpers._get_search_tags_from_url(this.url); } // Return the search type from the URL. This is one of "artworks", "illustrations" // or "novels" (not supported). It can also be omitted, which is the "top" page, // but that gives the same results as "artworks" with a different page layout, so // we treat it as "artworks". get _search_type() { // ["", "tags", tag list, type] let url = helpers.get_url_without_language(this.url); let parts = url.pathname.split("/"); if(parts.length >= 4) return parts[3]; else return "artworks"; } startup() { super.startup(); // Refresh our title when translations are toggled. settings.register_change_callback("disable-translations", this.cache_search_title); } shutdown() { super.shutdown(); settings.unregister_change_callback("disable-translations", this.cache_search_title); } async cache_search_title() { this.title = "Search: "; let tags = this._search_tags; if(tags) { tags = await tag_translations.get().translate_tag_list(tags, "en"); var tag_list = document.createElement("span"); for(let tag of tags) { // Force "or" lowercase. if(tag.toLowerCase() == "or") tag = "or"; var span = document.createElement("span"); span.innerText = tag; span.classList.add("word"); if(tag == "or") span.classList.add("or"); else span.classList.add("tag"); tag_list.appendChild(span); } this.title += tags.join(" "); this.displaying_tags = tag_list; } // Update our page title. this.call_update_listeners(); } async load_page_internal(page) { var query_args = this.url.searchParams; let args = { p: page, }; // "artworks" and "illustrations" are different on the search page: "artworks" uses "/tag/TAG/artworks", // and "illustrations" is "/tag/TAG/illustrations?type=illust_and_ugoira". let search_type = this._search_type; let api_search_type = "artworks"; if(search_type == "artworks") { // "artworks" doesn't use the type field. api_search_type = "artworks"; } else if(search_type == "illustrations") { api_search_type = "illustrations"; args.type = "illust_and_ugoira"; } else if(search_type == "manga") { api_search_type = "manga"; args.type = "manga"; } query_args.forEach((value, key) => { args[key] = value; }); let tag = this._search_tags; // If we have no tags, we're probably on the "/tags" page, which is just a list of tags. Don't // run a search with no tags. if(!tag) { console.log("No search tags"); return; } var url = "/ajax/search/" + api_search_type + "/" + encodeURIComponent(tag); var result = await helpers.get_request(url, args); let body = result.body; // Store related tags. Only do this the first time and don't change it when we read // future pages, so the tags don't keep changing as you scroll around. if(this.related_tags == null) { this.related_tags = []; for(let tag of body.relatedTags) this.related_tags.push({tag: tag}); this.call_update_listeners(); } // Add translations. let translations = []; for(let tag of Object.keys(body.tagTranslation)) { translations.push({ tag: tag, translation: body.tagTranslation[tag], }); } tag_translations.get().add_translations(translations); // /tag/TAG/illustrations returns results in body.illust. // /tag/TAG/artworks returns results in body.illustManga. // /tag/TAG/manga returns results in body.manga. let illusts = body.illust || body.illustManga || body.manga; illusts = illusts.data; // Populate thumbnail data with this data. thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal"); var illust_ids = []; for(let illust of illusts) illust_ids.push(illust.id); // Register the new page of data. this.add_page(page, illust_ids); } get page_title() { return this.title; } get_displaying_text() { return this.displaying_tags; }; initial_refresh_thumbnail_ui(container, view) { // Fill the search box with the current tag. var query_args = this.url.searchParams; let tag = this._search_tags; container.querySelector(".search-page-tag-entry .search-tags").value = tag; } // Return the search mode, which is selected by the "Type" search option. This generally // corresponds to the underlying page's search modes. get_url_search_mode() { // "/tags/tag/illustrations" has a "type" parameter with the search type. This is used for // "illust" (everything except animations) and "ugoira". let search_type = this._search_type; if(search_type == "illustrations") { let query_search_type = this.url.searchParams.get("type"); if(query_search_type == "ugoira") return "ugoira"; if(query_search_type == "illust") return "illust"; // If there's no parameter, show everything. return "all"; } if(search_type == "artworks") return "all"; if(search_type == "manga") return "manga"; // Use "all" for unrecognized types. return "all"; } // Return URL with the search mode set to mode. set_url_search_mode(url, mode) { url = new URL(url); url = helpers.get_url_without_language(url); // Only "ugoira" searches use type in the query. It causes an error in other modes, so remove it. if(mode == "illust") url.searchParams.set("type", "illust"); else if(mode == "ugoira") url.searchParams.set("type", "ugoira"); else url.searchParams.delete("type"); let search_type = "artworks"; if(mode == "manga") search_type = "manga"; else if(mode == "ugoira" || mode == "illust") search_type = "illustrations"; // Set the type in the URL. let parts = url.pathname.split("/"); parts[3] = search_type; url.pathname = parts.join("/"); return url; } refresh_thumbnail_ui(container, thumbnail_view) { if(this.related_tags) { thumbnail_view.tag_widget.set({ tags: this.related_tags }); } this.set_item(container, "ages-all", {mode: null}); this.set_item(container, "ages-safe", {mode: "safe"}); this.set_item(container, "ages-r18", {mode: "r18"}); this.set_item(container, "order-newest", {order: null}, {order: "date_d"}); this.set_item(container, "order-oldest", {order: "date"}); this.set_item(container, "order-all", {order: "popular_d"}); this.set_item(container, "order-male", {order: "popular_male_d"}); this.set_item(container, "order-female", {order: "popular_female_d"}); let set_search_mode = (container, type, mode) => { var link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } let current_mode = this.get_url_search_mode(); let button_is_selected = current_mode == mode; helpers.set_class(link, "selected", button_is_selected); // Adjust the URL for this button. let url = this.set_url_search_mode(this.url, mode); link.href = url.toString(); }; set_search_mode(container, "search-type-all", "all"); set_search_mode(container, "search-type-illust", "illust"); set_search_mode(container, "search-type-manga", "manga"); set_search_mode(container, "search-type-ugoira", "ugoira"); this.set_item(container, "search-all", {s_mode: null}, {s_mode: "s_tag"}); this.set_item(container, "search-exact", {s_mode: "s_tag_full"}); this.set_item(container, "search-text", {s_mode: "s_tc"}); this.set_item(container, "res-all", {wlt: null, hlt: null, wgt: null, hgt: null}); this.set_item(container, "res-high", {wlt: 3000, hlt: 3000, wgt: null, hgt: null}); this.set_item(container, "res-medium", {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999}); this.set_item(container, "res-low", {wlt: null, hlt: null, wgt: 999, hgt: 999}); this.set_item(container, "aspect-ratio-all", {ratio: null}); this.set_item(container, "aspect-ratio-landscape", {ratio: "0.5"}); this.set_item(container, "aspect-ratio-portrait", {ratio: "-0.5"}); this.set_item(container, "aspect-ratio-square", {ratio: "0"}); this.set_item(container, "bookmarks-all", {blt: null, bgt: null}); this.set_item(container, "bookmarks-5000", {blt: 5000, bgt: null}); this.set_item(container, "bookmarks-2500", {blt: 2500, bgt: null}); this.set_item(container, "bookmarks-1000", {blt: 1000, bgt: null}); this.set_item(container, "bookmarks-500", {blt: 500, bgt: null}); this.set_item(container, "bookmarks-250", {blt: 250, bgt: null}); this.set_item(container, "bookmarks-100", {blt: 100, bgt: null}); // The time filter is a range, but I'm not sure what time zone it filters in // (presumably either JST or UTC). There's also only a date and not a time, // which means you can't actually filter "today", since there's no way to specify // which "today" you mean. So, we offer filtering starting at "this week", // and you can just use the default date sort if you want to see new posts. // For "this week", we set the end date a day in the future to make sure we // don't filter out posts today. this.set_item(container, "time-all", {scd: null, ecd: null}); var format_date = (date) => { var f = (date.getYear() + 1900).toFixed(); return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" + (date.getMonth() + 1).toFixed().padStart(2, "0") + "-" + date.getDate().toFixed().padStart(2, "0"); }; var set_date_filter = (name, start, end) => { var start_date = format_date(start); var end_date = format_date(end); this.set_item(container, name, {scd: start_date, ecd: end_date}); }; var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); var last_week = new Date(); last_week.setDate(last_week.getDate() - 7); var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1); var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1); set_date_filter("time-week", last_week, tomorrow); set_date_filter("time-month", last_month, tomorrow); set_date_filter("time-year", last_year, tomorrow); for(var years_ago = 1; years_ago <= 7; ++years_ago) { var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1); var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago); set_date_filter("time-years-ago-" + years_ago, start_year, end_year); } this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]); // The "reset search" button removes everything in the query except search terms, and resets // the search type. var box = container.querySelector(".reset-search"); let url = new URL(this.url); let tag = helpers._get_search_tags_from_url(url); url.search = ""; if(tag == null) url.pathname = "/tags"; else url.pathname = "/tags/" + encodeURIComponent(tag) + "/artworks"; box.href = url; } }; ppixiv.data_sources.follows = class extends data_source { get name() { return "following"; } get search_mode() { return "users"; } constructor(url) { super(url); this.follow_tags = null; } get supports_start_page() { return true; } get viewing_user_id() { if(helpers.get_path_part(this.url, 0) == "users") { // New URLs (/users/13245/follows) return helpers.get_path_part(this.url, 1); } var query_args = this.url.searchParams; let user_id = query_args.get("id"); if(user_id == null) return window.global_data.user_id; return user_id; }; async load_page_internal(page) { // Make sure the user info is loaded. This should normally be preloaded by globalInitData // in main.js, and this won't make a request. this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); // Update to refresh our page title, which uses user_info. this.call_update_listeners(); var query_args = this.url.searchParams; var rest = query_args.get("rest") || "show"; var url = "/ajax/user/" + this.viewing_user_id + "/following"; let args = { offset: this.estimated_items_per_page*(page-1), limit: this.estimated_items_per_page, rest: rest, }; if(query_args.get("tag")) args.tag = query_args.get("tag"); let result = await helpers.get_request(url, args); // Store following tags. this.follow_tags = result.body.followUserTags; // Make a list of the first illustration for each user. var illusts = []; for(let followed_user of result.body.users) { if(followed_user == null) continue; // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data(followed_user, "following"); // XXX: user:user_id if(!followed_user.illusts.length) { console.log("Can't show followed user that has no posts:", followed_user.userId); continue; } let illust = followed_user.illusts[0]; illusts.push(illust); // We'll register this with thumbnail_data below. These results don't have profileImageUrl // and only put it in the enclosing user, so copy it over. illust.profileImageUrl = followed_user.profileImageUrl; } var illust_ids = []; for(let illust of illusts) illust_ids.push("user:" + illust.userId); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } refresh_thumbnail_ui(container, thumbnail_view) { thumbnail_view.avatar_widget.visible = true; thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id); // The public/private button only makes sense when viewing your own follows. var public_private_button_container = container.querySelector(".follows-public-private"); public_private_button_container.hidden = !this.viewing_self; this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"}); this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"}); var tag_list = container.querySelector(".follow-tag-list"); helpers.remove_elements(tag_list); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_url = new URL(this.url); current_url.searchParams.delete("p"); let current_query = current_url.searchParams.toString(); var add_tag_link = (tag) => { var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = tag; let url = new URL(this.url); url.searchParams.delete("p"); if(tag == "Uncategorized") url.searchParams.set("untagged", 1); else url.searchParams.delete("untagged", 1); if(tag != "All") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); a.href = url.toString(); if(url.searchParams.toString() == current_query) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All"); for(var tag of this.follow_tags || []) add_tag_link(tag); } get viewing_self() { return this.viewing_user_id == window.global_data.user_id; } get page_title() { if(!this.viewing_self) { if(this.user_info) return this.user_info.name + "'s Follows"; return "User's follows"; } var query_args = this.url.searchParams; var private_follows = query_args.get("rest") == "hide"; return private_follows? "Private follows":"Followed users"; }; get_displaying_text() { if(!this.viewing_self) { if(this.user_info) return this.user_info.name + "'s followed users"; return "User's followed users"; } var query_args = this.url.searchParams; var private_follows = query_args.get("rest") == "hide"; return private_follows? "Private follows":"Followed users"; }; } // bookmark_detail.php // // This lists the users who publically bookmarked an illustration, linking to each users' bookmarks. ppixiv.data_sources.related_favorites = class extends data_source_from_page { get name() { return "illust-bookmarks"; } get search_mode() { return "users"; } constructor(url) { super(url); this.illust_info = null; } async load_page_internal(page) { // Get info for the illustration we're displaying bookmarks for. var query_args = this.url.searchParams; var illust_id = query_args.get("illust_id"); this.illust_info = await image_data.singleton().get_image_info(illust_id); return super.load_page_internal(page); } // Parse the loaded document and return the illust_ids. parse_document(doc) { var ids = []; for(var element of doc.querySelectorAll("li.bookmark-item a[data-user_id]")) { // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data({ user_id: element.dataset.user_id, user_name: element.dataset.user_name, // This page gives links to very low-res avatars. Replace them with the high-res ones // that newer pages give. // // These links might be annoying animated GIFs, but we don't bother killing them here // like we do for the followed page since this isn't used very much. profile_img: element.dataset.profile_img.replace("_50.", "_170."), }, "users_bookmarking_illust"); // The bookmarks: URL type will generate links to this user's bookmarks. ids.push("bookmarks:" + element.dataset.user_id); } return ids; } refresh_thumbnail_ui(container, thumbnail_view) { // Set the source image. var source_link = container.querySelector(".image-for-suggestions"); source_link.hidden = this.illust_info == null; if(this.illust_info) { source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv"; var img = source_link.querySelector(".image-for-suggestions > img"); img.src = this.illust_info.urls.thumb; } } get page_title() { return "Similar Bookmarks"; }; get_displaying_text() { if(this.illust_info) return "Users who bookmarked " + this.illust_info.illustTitle; else return "Users who bookmarked image"; }; } ppixiv.data_sources.search_users = class extends data_source_from_page { get name() { return "search-users"; } get search_mode() { return "users"; } parse_document(doc) { var illust_ids = []; for(let item of doc.querySelectorAll(".user-recommendation-items .user-recommendation-item")) { let username = item.querySelector(".title").innerText; let user_id = item.querySelector(".follow").dataset.id; let profile_image = item.querySelector("._user-icon").dataset.src; thumbnail_data.singleton().add_quick_user_data({ user_id: user_id, user_name: username, profile_img: profile_image, }, "user_search"); illust_ids.push("user:" + user_id); } return illust_ids; } initial_refresh_thumbnail_ui(container, view) { let search = this.url.searchParams.get("nick"); container.querySelector(".search-users").value = search; } get page_title() { let search = this.url.searchParams.get("nick"); if(search) return "Search users: " + search; else return "Search users"; }; get_displaying_text() { return this.page_title; }; } // /history.php - Recent history // // This uses our own history and not Pixiv's history. ppixiv.data_sources.recent = class extends data_source { get name() { return "recent"; } // Implement data_source_fake_pagination: async load_page_internal(page) { // Read illust_ids once and paginate them so we don't return them all at once. if(this.illust_ids == null) { let illust_ids = await ppixiv.recently_seen_illusts.get().get_recent_illust_ids(); this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page); } // Register this page. let illust_ids = this.pages[page-1] || []; let found_illust_ids = []; // Get thumbnail data for this page. Some thumbnail data might be missing if it // expired before this page was viewed. Don't add illust IDs that we don't have // thumbnail data for. let thumbs = await ppixiv.recently_seen_illusts.get().get_thumbnail_info(illust_ids); thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "normal"); for(let thumb of thumbs) found_illust_ids.push(thumb.id); this.add_page(page, found_illust_ids); }; get page_title() { return "Recent"; } get_displaying_text() { return "Recent History"; } // This data source is transient, so it's recreated each time the user navigates to it. get transient() { return true; } refresh_thumbnail_ui(container) { // Set .selected on the current mode. let current_mode = this.url.searchParams.get("mode") || "all"; helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all"); helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe"); helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18"); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/data_sources.js `; ppixiv.resources["src/encode_mkv.js"] = `"use strict"; // This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs. ppixiv.encode_mkv = (function() { var encode_length = function(value) { // Encode a 40-bit EBML int. This lets us encode 32-bit ints with no extra logic. return struct(">BI").pack(0x08, value); }; var header_int = function(container, identifier, value) { container.push(new Uint8Array(identifier)); var data = struct(">II").pack(0, value); var size = data.byteLength; container.push(encode_length(size)); container.push(data); }; var header_float = function(container, identifier, value) { container.push(new Uint8Array(identifier)); var data = struct(">f").pack(value); var size = data.byteLength; container.push(encode_length(size)); container.push(data); }; var header_data = function(container, identifier, data) { container.push(new Uint8Array(identifier)); container.push(encode_length(data.byteLength)); container.push(data); }; // Return the total size of an array of ArrayBuffers. var total_size = function(array) { var size = 0; for(var idx = 0; idx < array.length; ++idx) { var item = array[idx]; size += item.byteLength; } return size; }; var append_array = function(a1, a2) { var result = new Uint8Array(a1.byteLength + a2.byteLength); result.set(new Uint8Array(a1)); result.set(new Uint8Array(a2), a1.byteLength); return result; }; // Create an EBML block from an identifier and a list of Uint8Array parts. Return a // single Uint8Array. var create_data_block = function(identifier, parts) { var identifier = new Uint8Array(identifier); var data_size = total_size(parts); var encoded_data_size = encode_length(data_size); var result = new Uint8Array(identifier.byteLength + encoded_data_size.byteLength + data_size); var pos = 0; result.set(new Uint8Array(identifier), pos); pos += identifier.byteLength; result.set(new Uint8Array(encoded_data_size), pos); pos += encoded_data_size.byteLength; for(var i = 0; i < parts.length; ++i) { var part = parts[i]; result.set(new Uint8Array(part), pos); pos += part.byteLength; } return result; }; // EBML data types var ebml_header = function() { var parts = []; header_int(parts, [0x42, 0x86], 1); // EBMLVersion header_int(parts, [0x42, 0xF7], 1); // EBMLReadVersion header_int(parts, [0x42, 0xF2], 4); // EBMLMaxIDLength header_int(parts, [0x42, 0xF3], 8); // EBMLMaxSizeLength header_data(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); // DocType ("matroska") header_int(parts, [0x42, 0x87], 4); // DocTypeVersion header_int(parts, [0x42, 0x85], 2); // DocTypeReadVersion return create_data_block([0x1A, 0x45, 0xDF, 0xA3], parts); // EBML }; var ebml_info = function(duration) { var parts = []; header_int(parts, [0x2A, 0xD7, 0xB1], 1000000); // TimecodeScale header_data(parts, [0x4D, 0x80], new Uint8Array([120])); // MuxingApp ("x") (this shouldn't be mandatory) header_data(parts, [0x57, 0x41], new Uint8Array([120])); // WritingApp ("x") (this shouldn't be mandatory) header_float(parts, [0x44, 0x89], duration * 1000); // Duration (why is this a float?) return create_data_block([0x15, 0x49, 0xA9, 0x66], parts); // Info }; var ebml_track_entry_video = function(width, height) { var parts = []; header_int(parts, [0xB0], width); // PixelWidth header_int(parts, [0xBA], height); // PixelHeight return create_data_block([0xE0], parts); // Video }; var ebml_track_entry = function(width, height) { var parts = []; header_int(parts, [0xD7], 1); // TrackNumber header_int(parts, [0x73, 0xC5], 1); // TrackUID header_int(parts, [0x83], 1); // TrackType (video) header_int(parts, [0x9C], 0); // FlagLacing header_int(parts, [0x23, 0xE3, 0x83], 33333333); // DefaultDuration (overridden per frame) header_data(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); // CodecID ("V_MJPEG") parts.push(ebml_track_entry_video(width, height)); return create_data_block([0xAE], parts); // TrackEntry }; var ebml_tracks = function(width, height) { var parts = []; parts.push(ebml_track_entry(width, height)); return create_data_block([0x16, 0x54, 0xAE, 0x6B], parts); // Tracks }; var ebml_simpleblock = function(frame_data) { // We should be able to use encode_length(1), but for some reason, while everything else // handles our non-optimal-length ints just fine, this field doesn't. Manually encode it // instead. var result = new Uint8Array([ 0x81, // track number 1 (EBML encoded) 0, 0, // timecode relative to cluster 0x80, // flags (keyframe) ]); result = append_array(result, frame_data); return result; }; var ebml_cluster = function(frame_data, frame_time) { var parts = []; header_int(parts, [0xE7], Math.round(frame_time * 1000)); // Timecode header_data(parts, [0xA3], ebml_simpleblock(frame_data)); // SimpleBlock return create_data_block([0x1F, 0x43, 0xB6, 0x75], parts); // Cluster }; var ebml_cue_track_positions = function(file_position) { var parts = []; header_int(parts, [0xF7], 1); // CueTrack header_int(parts, [0xF1], file_position); // CueClusterPosition return create_data_block([0xB7], parts); // CueTrackPositions }; var ebml_cue_point = function(frame_time, file_position) { var parts = []; header_int(parts, [0xB3], Math.round(frame_time * 1000)); // CueTime parts.push(ebml_cue_track_positions(file_position)); return create_data_block([0xBB], parts); // CuePoint }; var ebml_cues = function(frame_times, frame_file_positions) { var parts = []; for(var frame = 0; frame < frame_file_positions.length; ++frame) { var frame_time = frame_times[frame]; var file_position = frame_file_positions[frame]; parts.push(ebml_cue_point(frame_time, file_position)); } return create_data_block([0x1C, 0x53, 0xBB, 0x6B], parts); // Cues }; var ebml_segment = function(parts) { return create_data_block([0x18, 0x53, 0x80, 0x67], parts); // Segment }; // API: // We don't decode the JPEG frames while we do this, so the resolution is supplied here. class encode_mkv { constructor(width, height) { this.width = width; this.height = height; this.frames = []; } add(jpeg_data, frame_duration_ms) { this.frames.push({ data: jpeg_data, duration: frame_duration_ms, }); }; build() { // Sum the duration of the video. var duration = 0; for(var frame = 0; frame < this.frames.length; ++frame) { var data = this.frames[frame].data; var ms = this.frames[frame].duration; duration += ms / 1000.0; } var header_parts = ebml_header(); var parts = []; parts.push(ebml_info(duration)); parts.push(ebml_tracks(this.width, this.height)); // current_pos is the relative position from the start of the segment (after the ID and // size bytes) to the beginning of the cluster. var current_pos = 0; for(var part of parts) current_pos += part.byteLength; // Create each frame as its own cluster, and keep track of the file position of each. var frame_file_positions = []; var frame_file_times = []; var frame_time = 0; for(var frame = 0; frame < this.frames.length; ++frame) { var data = this.frames[frame].data; var ms = this.frames[frame].duration; var cluster = ebml_cluster(data, frame_time); parts.push(cluster); frame_file_positions.push(current_pos); frame_file_times.push(frame_time); frame_time += ms / 1000.0; current_pos += cluster.byteLength; }; // Add the frame index. parts.push(ebml_cues(frame_file_times, frame_file_positions)); // Create an EBMLSegment containing all of the parts (excluding the header). var segment = ebml_segment(parts); // Return a blob containing the final data. var file = []; file = file.concat(header_parts); file = file.concat(segment); return new Blob(file); }; }; return encode_mkv; })(); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/encode_mkv.js `; ppixiv.resources["src/hide_mouse_cursor_on_idle.js"] = `"use strict"; // A singleton that keeps track of whether the mouse has moved recently. // // Dispatch "mouseactive" on window when the mouse has moved recently and // "mouseinactive" when it hasn't. ppixiv.track_mouse_movement = class { constructor() { track_mouse_movement._singleton = this; this.idle = this.idle.bind(this); this.onmousemove = this.onmousemove.bind(this); this.force_hidden_until = null; this.set_mouse_anchor_timeout = -1; this.last_mouse_pos = null; window.addEventListener("mousemove", this.onmousemove, { capture: true }); } static _singleton = null; static get singleton() { return track_mouse_movement._singleton; } // True if the mouse is active. This corresponds to the mouseactive and mouseinactive // events. get active() { return _this; } // Briefly pretend that the mouse is inactive. // // This is done when releasing a zoom to prevent spuriously showing the mouse cursor. simulate_inactivity() { this.force_hidden_until = Date.now() + 150; this.idle(); } onmousemove(e) { let mouse_pos = [e.screenX, e.screenY]; this.last_mouse_pos = mouse_pos; if(!this.anchor_pos) this.anchor_pos = this.last_mouse_pos; // Cleare the anchor_pos timeout when the mouse moves. this.clear_mouse_anchor_timeout(); // If we're forcing the cursor inactive for a while, stop. if(this.force_hidden_until && this.force_hidden_until > Date.now()) return; // Show the cursor if the mouse has moved far enough from the current anchor_pos. let distance_moved = helpers.distance(this.anchor_pos, mouse_pos); if(distance_moved > 10) { this.mark_mouse_active(); return; } // If we see mouse movement that isn't enough to cause us to display the cursor // and we don't see more movement for a while, reset anchor_pos so we discard // the movement we saw. this.set_mouse_anchor_timeout = setTimeout(() => { this.set_mouse_anchor_timeout = -1; this.anchor_pos = this.last_mouse_pos; }, 500); } // Remove the set_mouse_anchor_timeout timeout, if any. clear_mouse_anchor_timeout() { if(this.set_mouse_anchor_timeout == -1) return; clearTimeout(this.set_mouse_anchor_timeout); this.set_mouse_anchor_timeout = -1; } remove_timer() { if(!this.timer) return; clearTimeout(this.timer); this.timer = null; } // The mouse has been active recently. Send mouseactive if the state is changing, // and schedule the next time it'll become inactive. mark_mouse_active() { // When showing the cursor, snap the mouse movement anchor to the last seen position // and remove any anchor_pos timeout. this.anchor_pos = this.last_mouse_pos; this.clear_mouse_anchor_timeout(); this.remove_timer(); this.timer = setTimeout(this.idle, 500); if(!this._active) { this._active = true; window.dispatchEvent(new Event("mouseactive")); } } // The timer has expired (or was forced to expire). idle() { this.remove_timer(); if(this._active) { window.dispatchEvent(new Event("mouseinactive")); this._active = false; } } } // Hide the mouse cursor when it hasn't moved briefly, to get it out of the way. // This only hides the cursor over element. ppixiv.hide_mouse_cursor_on_idle = class { constructor(element) { hide_mouse_cursor_on_idle.add_style(); this.track = new track_mouse_movement(); this.show_cursor = this.show_cursor.bind(this); this.hide_cursor = this.hide_cursor.bind(this); this.element = element; this.cursor_hidden = false; window.addEventListener("mouseactive", this.show_cursor); window.addEventListener("mouseinactive", this.hide_cursor); settings.register_change_callback("no-hide-cursor", hide_mouse_cursor_on_idle.update_from_settings); hide_mouse_cursor_on_idle.update_from_settings(); } static add_style() { if(hide_mouse_cursor_on_idle.global_style) return; // Create the style to hide the mouse cursor. This hides the mouse cursor on .hide-cursor, // and forces everything underneath it to inherit it. This prevents things further down // that set their own cursors from unhiding it. // // This also works around a Chrome bug: if the cursor is hidden, and we show the cursor while // simultaneously animating an element to be visible over it, it doesn't recognize // hovers over the element until the animation completes or the mouse moves. It // seems to be incorrectly optimizing out hover checks when the mouse is hidden. // Work around this by hiding the cursor with an empty image instead of cursor: none, // so it doesn't know that the cursor isn't visible. // // This is set as a separate style, so we can disable it selectively. This allows us to // globally disable mouse hiding. This used to be done by setting a class on body, but // that's slower and can cause animation hitches. let style = helpers.add_style("hide-cursor", \` .hide-cursor { cursor: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="), none !important; } .hide-cursor * { cursor: inherit !important; } \`); hide_mouse_cursor_on_idle.global_style = style; } static update_from_settings() { // If no-hide-cursor is true, disable the style that hides the cursor. We track cursor // hiding and set the local hide-cursor style even if cursor hiding is disabled, so // other UI can use it, like video seek bars. hide_mouse_cursor_on_idle.global_style.disabled = settings.get("no-hide-cursor"); } // Temporarily disable hiding all mouse cursors. static enable_all() { // Just let update_from_settings readding the enable-cursor-hiding class if needed. this.update_from_settings(); } static disable_all() { // Just disable the style, so we stop hiding the mouse. We don't just unset the hide-cursor // class, so this only stops hiding the mouse cursor and doesn't cause other UI like seek // bars to be displayed. hide_mouse_cursor_on_idle.global_style.disabled = true; } show_cursor(e) { this.cursor_hidden = false; this.refresh_hide_cursor(); } hide_cursor(e) { this.cursor_hidden = true; this.refresh_hide_cursor(); } refresh_hide_cursor() { let hidden = this.cursor_hidden; helpers.set_class(this.element, "hide-cursor", hidden); helpers.set_class(this.element, "show-cursor", !hidden); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/hide_mouse_cursor_on_idle.js `; ppixiv.resources["src/image_data.js"] = `"use strict"; // This handles fetching and caching image data and associated user data. // // We always load the user data for an illustration if it's not already loaded. We also // load ugoira_metadata. This way, we can access all the info we need for an image in // one place, without doing multi-phase loads elsewhere. ppixiv.image_data = class { constructor() { this.loaded_user_info = this.loaded_user_info.bind(this); this.illust_modified_callbacks = new callback_list(); this.user_modified_callbacks = new callback_list(); // Cached data: this.image_data = { }; this.user_data = { }; this.bookmarked_image_tags = { }; this.recent_likes = { } // Negative cache to remember illusts that don't exist, so we don't try to // load them repeatedly: this.nonexistant_illist_ids = { }; this.nonexistant_user_ids = { }; // XXX this.illust_loads = {}; this.user_info_loads = {}; }; // Return the singleton, creating it if needed. static singleton() { if(image_data._singleton == null) image_data._singleton = new image_data(); return image_data._singleton; }; // Call all illust_modified callbacks. call_user_modified_callbacks(user_id) { console.log("User modified:", user_id); this.user_modified_callbacks.call(user_id); } call_illust_modified_callbacks(illust_id) { this.illust_modified_callbacks.call(illust_id); } // Get image data asynchronously. // // await get_image_info(12345); // // If illust_id is a video, we'll also download the metadata before returning it, and store // it as image_data.ugoiraMetadata. get_image_info(illust_id) { if(illust_id == null) return null; // Stop if we know this illust doesn't exist. if(illust_id in this.nonexistant_illist_ids) return null; // If we already have the image data, just return it. if(this.image_data[illust_id] != null) { return new Promise(resolve => { resolve(this.image_data[illust_id]); }); } // If there's already a load in progress, just return it. if(this.illust_loads[illust_id] != null) return this.illust_loads[illust_id]; var load_promise = this.load_image_info(illust_id); this._started_loading_image_info(illust_id, load_promise); return load_promise; } _started_loading_image_info(illust_id, load_promise) { this.illust_loads[illust_id] = load_promise; this.illust_loads[illust_id].then(() => { delete this.illust_loads[illust_id]; }); } // Like get_image_info, but return the result immediately. // // If the image info isn't loaded, don't start a request and just return null. get_image_info_sync(illust_id) { return this.image_data[illust_id]; } // Load illust_id and all data that it depends on. // // If we already have the image data (not necessarily the rest, like ugoira_metadata), // it can be supplied with illust_data. // // If load_user_info is true, we'll attempt to load user info in parallel. It still // needs to be requested with get_user_info(), but loading it here can allow requesting // it sooner. async load_image_info(illust_id, illust_data, { load_user_info=false }={}) { // See if we already have data for this image. If we do, stop. We always load // everything we need if we load anything at all. if(this.image_data[illust_id] != null) return; // We need the illust data, user data, and ugoira metadata (for illustType 2). (We could // load manga data too, but we currently let the manga view do that.) We need to know the // user ID and illust type to start those loads. console.log("Fetching", illust_id); var user_info_promise = null; var manga_promise = null; var ugoira_promise = null; // Given a user ID and/or an illust_type (or null if either isn't known yet), start any // fetches we can. var start_loading = (user_id, illust_type, page_count) => { // If we know the user ID and haven't started loading user info yet, start it. if(load_user_info && user_info_promise == null && user_id != null) user_info_promise = this.get_user_info(user_id); // If we know the illust type and haven't started loading other data yet, start them. if(page_count != null && page_count > 1 && manga_promise == null && (illust_data == null || illust_data.mangaPages == null)) manga_promise = helpers.get_request("/ajax/illust/" + illust_id + "/pages", {}); if(illust_type == 2 && ugoira_promise == null && (illust_data == null || illust_data.ugoiraMetadata == null)) ugoira_promise = helpers.get_request("/ajax/illust/" + illust_id + "/ugoira_meta"); }; // If we have thumbnail info, it tells us the user ID. This lets us start loading // user info without waiting for the illustration data to finish loading first. // Don't fetch thumbnail info if it's not already loaded. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) start_loading(thumbnail_info.userId, thumbnail_info.illustType, thumbnail_info.pageCount); // If we don't have illust data, block while it loads. if(illust_data == null) { var illust_result_promise = helpers.get_request("/ajax/illust/" + illust_id, {}); var illust_result = await illust_result_promise; if(illust_result == null || illust_result.error) { let message = illust_result?.message || "Error loading illustration"; console.log(\`Error loading illust \${illust_id}; \${message}\`); this.nonexistant_illist_ids[illust_id] = message; return null; } illust_data = illust_result.body; } tag_translations.get().add_translations(illust_data.tags.tags); // Now that we have illust data, load anything we weren't able to load before. start_loading(illust_data.userId, illust_data.illustType, illust_data.pageCount); // If we're loading image info, we're almost definitely going to load the avatar, so // start preloading it now. let user_info = await user_info_promise; if(user_info) helpers.preload_images([user_info.imageBig]); if(manga_promise != null) { var manga_info = await manga_promise; illust_data.mangaPages = manga_info.body; } if(ugoira_promise != null) { var ugoira_result = await ugoira_promise; illust_data.ugoiraMetadata = ugoira_result.body; } // If this is a single-page image, create a dummy single-entry mangaPages array. This lets // us treat all images the same. if(illust_data.pageCount == 1) { illust_data.mangaPages = [{ width: illust_data.width, height: illust_data.height, // Rather than just referencing illust_Data.urls, copy just the image keys that // exist in the regular mangaPages list (no thumbnails). urls: { original: illust_data.urls.original, regular: illust_data.urls.regular, small: illust_data.urls.small, } }]; } // Store the image data. this.image_data[illust_id] = illust_data; return illust_data; } // If get_image_info or get_user_info returned null, return the error message. get_illust_load_error(illust_id) { return this.nonexistant_illist_ids[illust_id]; } get_user_load_error(user_id) { return this.nonexistant_user_ids[illust_id]; } // The user request can either return a small subset of data (just the username, // profile image URL, etc.), or a larger set with a webpage URL, Twitter, etc. // User preloads often only have the smaller set, and we want to use the preload // data whenever possible. // // get_user_info requests the smaller set of data, and get_user_info_full requests // the full data. // // Note that get_user_info will return the full data if we have it already. async get_user_info_full(user_id) { return await this._get_user_info(user_id, true); } async get_user_info(user_id) { return await this._get_user_info(user_id, false); } get_user_info_sync(user_id) { return this.user_data[user_id]; } // Load user_id if needed. // // If load_full_data is false, it means the caller only needs partial data, and we // won't send a request if we already have that, but if we do end up loading the // user we'll always load full data. // // Some sources only give us partial data, which only has a subset of keys. See // _check_user_data for the keys available with partial and full data. _get_user_info(user_id, load_full_data) { if(user_id == null) return null; // Stop if we know this user doesn't exist. if(user_id in this.nonexistant_user_ids) return null; // If we already have the user info for this illustration (and it's full data, if // requested), we're done. if(this.user_data[user_id] != null) { // user_info.partial is 1 if it's the full data (this is backwards). If we need // full data and we only have partial data, we still need to request data. if(!load_full_data || this.user_data[user_id].partial) { return new Promise(resolve => { resolve(this.user_data[user_id]); }); } } // If there's already a load in progress, just return it. if(this.user_info_loads[user_id] != null) return this.user_info_loads[user_id]; this.user_info_loads[user_id] = this.load_user_info(user_id); this.user_info_loads[user_id].then(() => { delete this.user_info_loads[user_id]; }); return this.user_info_loads[user_id]; }; async load_user_info(user_id) { // console.log("Fetch user", user_id); let result = await helpers.get_request("/ajax/user/" + user_id, {full:1}); if(result == null || result.error) { let message = result?.message || "Error loading user"; console.log(\`Error loading user \${user_id}; \${message}\`); this.nonexistant_user_ids[user_id] = message; return null; } return this.loaded_user_info(result); } _check_user_data(user_data) { // Make sure that the data contains all of the keys we expect, so we catch any unexpected // missing data early. Discard keys that we don't use, to make sure we update this if we // make use of new keys. This makes sure that the user data keys are always consistent. let full_keys = [ 'userId', // 'background', // 'image', 'imageBig', // 'isBlocking', 'isFollowed', 'isMypixiv', 'name', 'partial', 'social', 'commentHtml', // 'premium', // 'sketchLiveId', // 'sketchLives', ]; let partial_keys = [ 'userId', 'isFollowed', 'name', 'imageBig', 'partial', ]; // partial is 0 if this is partial user data and 1 if it's full data (this is backwards). let expected_keys = user_data.partial? full_keys:partial_keys; var thumbnail_info_map = this.thumbnail_info_map_illust_list; var remapped_user_data = { }; for(let key of expected_keys) { if(!(key in user_data)) { console.warn("User info is missing key:", key); continue; } remapped_user_data[key] = user_data[key]; } return remapped_user_data; } loaded_user_info(user_result) { if(user_result.error) return; var user_data = user_result.body; user_data = this._check_user_data(user_data); var user_id = user_data.userId; // console.log("Got user", user_id); // Store the user data. if(this.user_data[user_id] == null) this.user_data[user_id] = user_data; else { // If we already have an object for this user, we're probably replacing partial user data // with full user data. Don't replace the user_data object itself, since widgets will have // a reference to the old one which will become stale. Just replace the data inside the // object. var old_user_data = this.user_data[user_id]; for(var key of Object.keys(old_user_data)) delete old_user_data[key]; for(var key of Object.keys(user_data)) old_user_data[key] = user_data[key]; } return user_data; } // Add image and user data to the cache that we received from other sources. Note that if // we have any fetches in the air already, we'll leave them running. add_illust_data(illust_data) { var load_promise = this.load_image_info(illust_data.illustId, illust_data); this._started_loading_image_info(illust_data.illustId, load_promise); } add_user_data(user_data) { this.loaded_user_info({ body: user_data, }); } // Load bookmark tags. // // There's no visible API to do this, so we have to scrape the bookmark_add page. I wish // they'd just include this in bookmarkData. Since this takes an extra request, we should // only load this if the user is viewing/editing bookmark tags. get_bookmark_details(illust_info) { var illust_id = illust_info.illustId; if(this.bookmark_details[illust_id] == null) this.bookmark_details[illust_id] = this.load_bookmark_details(illust_info); return this.bookmark_details[illust_id]; } async load_bookmark_details(illust_id) { // Stop if this is already loaded. if(this.bookmarked_image_tags[illust_id]) return this.bookmarked_image_tags[illust_id]; let bookmark_page = await helpers.load_data_in_iframe("/bookmark_add.php?type=illust&illust_id=" + illust_id); let tags = bookmark_page.querySelector(".bookmark-detail-unit form input[name='tag']").value; tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); this.bookmarked_image_tags[illust_id] = tags; return this.bookmarked_image_tags[illust_id]; } // Replace our cache of bookmark tags for an image. This is used after updating // a bookmark. update_cached_bookmark_image_tags(illust_id, tags) { if(tags == null) delete this.bookmarked_image_tags[illust_id]; else this.bookmarked_image_tags[illust_id] = tags; this.call_illust_modified_callbacks(illust_id); } thumbnail_info_early_illust_data_keys = { "id": "id", "illustType": "illustType", "title": "illustTitle", "pageCount": "pageCount", "userId": "userId", "userName": "userName", "width": "width", "height": "height", "mangaPages": "mangaPages", "bookmarkData": "bookmarkData", "width": "width", "height": "height", "createDate": "createDate", // These are handled separately. // "tags": "tags", // "url": "previewUrl", }; illust_info_early_illust_data_keys = { "id": "id", "illustType": "illustType", "illustTitle": "illustTitle", "pageCount": "pageCount", "userId": "userId", "userName": "userName", "width": "width", "height": "height", "mangaPages": "mangaPages", "bookmarkData": "bookmarkData", "width": "width", "height": "height", "createDate": "createDate", // "tags": "tags", // urls.small: "previewUrl", }; // Get illustration info that can be retrieved from both // Get the info we need to set up an image display. We can do this from thumbnail info // if we're coming from a search, or illust info otherwise. This only blocks if we // need to load the data. async get_early_illust_data(illust_id) { let keys = null; let data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); let result = { }; if(data) { keys = this.thumbnail_info_early_illust_data_keys; result.previewUrl = data.url; result.tags = data.tags; } else { data = await image_data.singleton().get_image_info(illust_id); if(data == null) return null; keys = this.illust_info_early_illust_data_keys; result.previewUrl = data.urls.small; result.tags = []; for(let tag of data.tags.tags) result.tags.push(tag.tag); } // Remap whichever data type we got. for(let from_key in keys) { let to_key = keys[from_key]; if(!(from_key in data)) { console.warn(\`Missing key \${from_key} for early data\`, data); continue; } result[to_key] = data[from_key]; } return result; } // Update early illust data. // // THe data might have come from illust info or thumbnail info, so update whichever // we have. This can't update fields with special handling, like tags. update_early_illust_data(illust_id, data) { let update_data = (update, keys) => { for(let from_key in keys) { let to_key = keys[from_key]; if(!(from_key in data)) continue; console.assert(from_key != "tags"); update[to_key] = data[from_key]; } }; let thumb_data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); let tags = null; if(thumb_data) update_data(thumb_data, this.thumbnail_info_early_illust_data_keys); let illust_info = image_data.singleton().get_image_info_sync(illust_id); if(illust_info != null) update_data(illust_info, this.illust_info_early_illust_data_keys); this.call_illust_modified_callbacks(illust_id); } // Remember when we've liked an image recently, so we don't spam API requests. get_liked_recently(illust_id) { return this.recent_likes[illust_id]; } add_liked_recently(illust_id) { this.recent_likes[illust_id] = true; } // Convert a verbose tag list from illust info: // // illust_info.tags = { tags: [{tag: "tag1"}, {tag: "tag2"}] }; // // to a simple array of tag names, which is what we get in thumbnail data and // the format we use in early illust info. static from_tag_list(tags) { let result = []; for(let tag of tags.tags) { result.push(tag.tag); } return result; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/image_data.js `; ppixiv.resources["src/on_click_viewer.js"] = `"use strict"; // View img fullscreen. Clicking the image will zoom it to its original size and scroll // it around. // // The image is always zoomed a fixed amount from its fullscreen size. This is generally // more usable than doing things like zooming based on the native resolution. ppixiv.on_click_viewer = class { constructor() { this.onresize = this.onresize.bind(this); this.pointermove = this.pointermove.bind(this); this.block_event = this.block_event.bind(this); this.window_blur = this.window_blur.bind(this); this.original_width = 1; this.original_height = 1; this.center_pos = [0, 0]; // Restore the most recent zoom mode. We assume that there's only one of these on screen. this.locked_zoom = settings.get("zoom-mode") == "locked"; this._zoom_level = settings.get("zoom-level", "cover"); } set_new_image(url, preview_url, image_container, width, height) { this.disable(false /* !stop_drag */); this.image_container = image_container; this.original_width = width; this.original_height = height; this.img = document.createElement("img"); this.img.src = url? url:helpers.blank_image; this.img.className = "filtering"; image_container.appendChild(this.img); // Create the low-res preview. This loads the thumbnail underneath the main image. Don't set the // "filtering" class, since using point sampling for the thumbnail doesn't make sense. If preview_url // is null, just use a blank image. this.preview_img = document.createElement("img"); this.preview_img.src = preview_url? preview_url:helpers.blank_image; this.preview_img.classList.add("low-res-preview"); image_container.appendChild(this.preview_img); // The secondary image holds the low-res preview image that's shown underneath the loading image. // It just follows the main image around and shouldn't receive input events. this.preview_img.style.pointerEvents = "none"; // When the image finishes loading, remove the preview image, to prevent artifacts with // transparent images. Keep a reference to preview_img, so we don't need to worry about // it changing. on_click_viewer will still have a reference to it, but it won't do anything. // // Don't do this if url is null. Leave the preview up and don't switch over to the blank // image. let preview_image = this.preview_img; if(url != null) { this.img.addEventListener("load", (e) => { preview_image.remove(); }); } this._add_events(); this.reset_position(); this.reposition(); } disable(stop_drag=true) { if(stop_drag) this.stop_dragging(); this._remove_events(); this.cancel_save_to_history(); if(this.img) { this.img.remove(); this.img = null; } if(this.preview_img) { this.preview_img.remove(); this.preview_img = null; } } // Set the pan position to the default for this image. reset_position() { // Figure out whether the image is relatively portrait or landscape compared to the screen. let screen_width = Math.max(this.container_width, 1); // might be 0 if we're hidden let screen_height = Math.max(this.container_height, 1); let aspect = (screen_width/this.original_width) > (screen_height/this.original_height)? "portrait":"landscape"; // If this.set_initial_image_position is true, then we're changing pages in the same illustration // and already have a position. If the images are similar, it's useful to keep the same position, // so you can alternate between variants of an image and have them stay in place. However, if // they're very different, this just leaves the display in a weird place. // // Try to guess. If we have a position already, only replace it if the aspect ratio mode is // the same. If we're going from portrait to portrait leave the position alone, but if we're // going from portrait to landscape, reset it. // // Note that we'll come here twice when loading most images: once using the preview image and // then again with the real image. It's important that we not mess with the zoom position on // the second call. if(this.set_initial_image_position && aspect != this.initial_image_position_aspect) this.set_initial_image_position = false; if(this.set_initial_image_position) return; this.set_initial_image_position = true; this.initial_image_position_aspect = aspect; // Similar to how we display thumbnails for portrait images starting at the top, default to the top // if we'll be panning vertically when in cover mode. let zoom_center = [0.5, aspect == "portrait"? 0:0.5]; this.center_pos = zoom_center; } block_event(e) { e.preventDefault(); } _add_events() { this._remove_events(); this.event_abort = new AbortController(); window.addEventListener("blur", this.window_blur, { signal: this.event_abort.signal }); window.addEventListener("resize", this.onresize, { signal: this.event_abort.signal, capture: true }); this.image_container.addEventListener("dragstart", this.block_event, { signal: this.event_abort.signal }); this.image_container.addEventListener("selectstart", this.block_event, { signal: this.event_abort.signal }); new ppixiv.pointer_listener({ element: this.image_container, button_mask: 1, signal: this.event_abort.signal, callback: this.pointerevent, }); // This is like pointermove, but received during quick view from the source tab. window.addEventListener("quickviewpointermove", this.quick_view_pointermove, { signal: this.event_abort.signal }); this.image_container.style.userSelect = "none"; this.image_container.style.MozUserSelect = "none"; } _remove_events() { if(this.event_abort) { this.event_abort.abort(); this.event_abort = null; } if(this.image_container) { this.image_container.style.userSelect = "none"; this.image_container.style.MozUserSelect = ""; } } onresize(e) { this.reposition(); } window_blur(e) { this.stop_dragging(); } // Enable or disable zoom lock. get locked_zoom() { return this._locked_zoom; } // Select between click-pan zooming and sticky, filled-screen zooming. set locked_zoom(enable) { this._locked_zoom = enable; settings.set("zoom-mode", enable? "locked":"normal"); this.reposition(); } // Relative zoom is applied on top of the main zoom. At 0, no adjustment is applied. // Positive values zoom in and negative values zoom out. get zoom_level() { return this._zoom_level; } set zoom_level(value) { this._zoom_level = value; settings.set("zoom-level", this._zoom_level); this.reposition(); } // A zoom level is the exponential ratio the user sees, and the zoom // factor is just the multiplier. zoom_level_to_zoom_factor(level) { return Math.pow(1.5, level); } zoom_factor_to_zoom_level(factor) { return Math.log2(factor) / Math.log2(1.5); } // Get the effective zoom level, translating "cover" and "actual" to actual values. get _zoom_level_current() { if(!this.zoom_active) return 0; let level = this._zoom_level; if(level == "cover") return this._zoom_level_cover; else if(level == "actual") return this._zoom_level_actual; else return level; } // Return the active zoom ratio. A zoom of 1x corresponds to "contain" zooming. get _zoom_factor_current() { if(!this.zoom_active) return 1; return this.zoom_level_to_zoom_factor(this._zoom_level_current); } // The zoom factor for cover mode: get _zoom_factor_cover() { return Math.max(this.container_width/this.width, this.container_height/this.height); } get _zoom_level_cover() { return this.zoom_factor_to_zoom_level(this._zoom_factor_cover); } // The zoom level for "actual" mode: get _zoom_factor_actual() { return 1 / this._image_to_screen_ratio; } get _zoom_level_actual() { return this.zoom_factor_to_zoom_level(this._zoom_factor_actual); } // Zoom in or out. If zoom_in is true, zoom in by one level, otherwise zoom out by one level. change_zoom(zoom_out) { // zoom_level can be a number. At 0 (default), we zoom to fit the image in the screen. // Higher numbers zoom in, lower numbers zoom out. Zoom levels are logarithmic. // // zoom_level can be "cover", which zooms to fill the screen completely, so we only zoom on // one axis. // // zoom_level can also be "actual", which zooms the image to its natural size. // // These zoom levels have a natural ordering, which we use for incremental zooming. Figure // out the zoom levels that correspond to "cover" and "actual". This changes depending on the // image and screen size. let cover_zoom_level = this._zoom_level_cover; let actual_zoom_level = this._zoom_level_actual; // Increase or decrease relative_zoom_level by snapping to the next or previous increment. // We're usually on a multiple of increment, moving from eg. 0.5 to 0.75, but if we're on // a non-increment value from a special zoom level, this puts us back on the zoom increment. let old_level = this._zoom_level_current; let new_level = old_level; let increment = 0.25; if(zoom_out) new_level = Math.floor((new_level - 0.001) / increment) * increment; else new_level = Math.ceil((new_level + 0.001) / increment) * increment; // If the amount crosses over one of the special zoom levels above, we select that instead. let crossed = function(old_value, new_value, threshold) { return (old_value < threshold && new_value > threshold) || (new_value < threshold && old_value > threshold); }; if(crossed(old_level, new_level, cover_zoom_level)) { // console.log("Selected cover zoom"); new_level = "cover"; } else if(crossed(old_level, new_level, actual_zoom_level)) { // console.log("Selected actual zoom"); new_level = "actual"; } else { // Clamp relative zooming. Do this here to make sure we can always select cover and actual // which aren't clamped, even if the image is very large or small. new_level = helpers.clamp(new_level, -8, +8); } this.zoom_level = new_level; } // Return the image coordinate at a given screen coordinate. // return zoom_pos, so this just converts screen coords to unit get_image_position(screen_pos) { let pos = this.current_zoom_pos; return [ pos[0] + (screen_pos[0] - this.container_width/2) / this.onscreen_width, pos[1] + (screen_pos[1] - this.container_height/2) / this.onscreen_height, ]; } // Given a screen position and a point on the image, align the point to the screen // position. This has no effect when we're not zoomed. set_image_position(screen_pos, zoom_center, draw=true) { this.center_pos = [ -((screen_pos[0] - this.container_width/2) / this.onscreen_width - zoom_center[0]), -((screen_pos[1] - this.container_height/2) / this.onscreen_height - zoom_center[1]), ]; if(draw) this.reposition(); } pointerevent = (e) => { if(e.mouseButton != 0) return; if(e.pressed) { // We only want clicks on the image, or on the container backing the image, not other // elements inside the container. if(e.target != this.img && e.target != this.image_container) return; this.image_container.style.cursor = "none"; // Don't show the UI if the mouse hovers over it while dragging. document.body.classList.add("hide-ui"); if(!this._locked_zoom) var zoom_center_pos = this.get_image_position([e.pageX, e.pageY]); this._mouse_pressed = true; this.dragged_while_zoomed = false; this.captured_pointer_id = e.pointerId; this.img.setPointerCapture(this.captured_pointer_id); // If this is a click-zoom, align the zoom to the point on the image that // was clicked. if(!this._locked_zoom) this.set_image_position([e.pageX, e.pageY], zoom_center_pos); this.reposition(); // Only listen to pointermove while we're dragging. this.image_container.addEventListener("pointermove", this.pointermove); } else { if(this.captured_pointer_id == null || e.pointerId != this.captured_pointer_id) return; if(!this._mouse_pressed) return; // Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the // cursor may have just been moved. This prevents the cursor from appearing briefly and // disappearing every time a zoom is released. track_mouse_movement.singleton.simulate_inactivity(); this.stop_dragging(); } } stop_dragging() { if(this.image_container != null) { this.image_container.removeEventListener("pointermove", this.pointermove); this.image_container.style.cursor = ""; } if(this.captured_pointer_id != null) { this.img.releasePointerCapture(this.captured_pointer_id); this.captured_pointer_id = null; } document.body.classList.remove("hide-ui"); this._mouse_pressed = false; this.reposition(); } pointermove(e) { if(!this._mouse_pressed) return; this.dragged_while_zoomed = true; this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY}); } quick_view_pointermove = (e) => { this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY}); } apply_pointer_movement({movementX, movementY}) { // Apply mouse dragging. let x_offset = movementX; let y_offset = movementY; if(settings.get("invert-scrolling")) { x_offset *= -1; y_offset *= -1; } // This will make mouse dragging match the image exactly: x_offset /= this.onscreen_width; y_offset /= this.onscreen_height; // Scale movement by the zoom factor, so we move faster if we're zoomed // further in. let zoom_factor = this._zoom_factor_current; x_offset *= zoom_factor; y_offset *= zoom_factor; this.center_pos[0] += x_offset; this.center_pos[1] += y_offset; this.reposition(); } // Return true if zooming is active. get zoom_active() { return this._mouse_pressed || this._locked_zoom; } get _image_to_screen_ratio() { let screen_width = this.container_width; let screen_height = this.container_height; // In case we're hidden and have no width, make sure we don't return an invalid value. if(screen_width == 0 || screen_height == 0) return 1; return Math.min(screen_width/this.original_width, screen_height/this.original_height); } // Return the width and height of the image when at 1x zoom. get width() { return this.original_width * this._image_to_screen_ratio; } get height() { return this.original_height * this._image_to_screen_ratio; } // The actual size of the image with its current zoom. get onscreen_width() { return this.width * this._zoom_factor_current; } get onscreen_height() { return this.height * this._zoom_factor_current; } // The dimensions of the image viewport. This can be 0 if the view is hidden. get container_width() { return this.image_container.offsetWidth || 0; } get container_height() { return this.image_container.offsetHeight || 0; } get current_zoom_pos() { if(this.zoom_active) return this.center_pos; else return [0.5, 0.5]; } reposition() { if(this.img == null) return; // Stop if we're being called after being disabled. if(this.image_container == null) return; this.schedule_save_to_history(); let screen_width = this.container_width; let screen_height = this.container_height; var width = this.width; var height = this.height; // If the dimensions are empty then we aren't loaded. Stop now, so the math // below doesn't break. if(width == 0 || height == 0 || screen_width == 0 || screen_height == 0) return; // When we're zooming to fill the screen, clamp panning to the screen, so we always fill the // screen and don't pan past the edge. if(this.zoom_active && !settings.get("pan-past-edge")) { let top_left = this.get_image_position([0,0]); top_left[0] = Math.max(top_left[0], 0); top_left[1] = Math.max(top_left[1], 0); this.set_image_position([0,0], top_left, false); let bottom_right = this.get_image_position([screen_width,screen_height]); bottom_right[0] = Math.min(bottom_right[0], 1); bottom_right[1] = Math.min(bottom_right[1], 1); this.set_image_position([screen_width,screen_height], bottom_right, false); } let zoom_factor = this._zoom_factor_current; let zoomed_width = width * zoom_factor; let zoomed_height = height * zoom_factor; // If we're narrower than the screen, lock to the middle. if(screen_width >= zoomed_width) this.center_pos[0] = 0.5; // center horizontally if(screen_height >= zoomed_height) this.center_pos[1] = 0.5; // center vertically // Normally (when unzoomed), the image is centered. let [x, y] = this.current_zoom_pos; this.img.style.width = this.width + "px"; this.img.style.height = this.height + "px"; this.img.style.position = "absolute"; // We can either use CSS positioning or transforms. Transforms used to be a lot // faster, but today it doesn't matter. However, with CSS positioning we run into // weird Firefox compositing bugs that cause the image to disappear after zooming // and opening the context menu. That's hard to pin down, but since it doesn't happen // with translate, let's just use that. this.img.style.transformOrigin = "0 0"; this.img.style.transform = \`translate(\${screen_width/2}px, \${screen_height/2}px) \` + \`scale(\${zoom_factor}) \` + \`translate(\${-this.width * x}px, \${-this.height * y}px) \` + \`\`; this.img.style.right = "auto"; this.img.style.bottom = "auto"; // If we have a secondary (preview) image, put it in the same place as the main image. if(this.preview_img) { this.preview_img.style.width = this.width + "px"; this.preview_img.style.height = this.height + "px"; this.preview_img.style.position = "absolute"; this.preview_img.style.right = "auto"; this.preview_img.style.bottom = "auto"; this.preview_img.style.transformOrigin = "0 0"; this.preview_img.style.transform = this.img.style.transform; } // Store the effective zoom in our tab info. This is in a format that makes it easy // to replicate the zoom in other UIs. Send this as extra data in the tab info. This // data isn't sent in realtime, since it would spam broadcasts as we zoom. It's just // sent when we lose focus. let top_left = this.get_image_position([0,0]); let current_zoom_desc = { left: -top_left[0] * this.onscreen_width / screen_width, // convert from image size to fraction of screen size top: -top_left[1] * this.onscreen_height / screen_height, width: zoom_factor * width / screen_width, height: zoom_factor * height / screen_height, }; SendImage.set_extra_data("illust_screen_pos", current_zoom_desc, true); } // Restore the pan and zoom state from history. restore_from_history = () => { let args = helpers.args.location; if(args.state.zoom == null) return; this.zoom_level = args.state.zoom?.zoom; this.locked_zoom = args.state.zoom?.lock; this.center_pos = [...args.state.zoom?.pos]; this.reposition(); this.set_initial_image_position = true; } // Save the pan and zoom state to history. save_to_history = () => { this.save_to_history_id = null; // Store the pan position at the center of the screen. let args = helpers.args.location; let screen_pos = [this.container_width / 2, this.container_height / 2]; args.state.zoom = { pos: this.center_pos, zoom: this.zoom_level, lock: this.locked_zoom, }; helpers.set_page_url(args, false /* add_to_history */); } // Schedule save_to_history to run. This is buffered so we don't call history.replaceState // too quickly. schedule_save_to_history() { this.cancel_save_to_history(); this.save_to_history_id = setTimeout(this.save_to_history, 250); } cancel_save_to_history() { if(this.save_to_history_id != null) { clearTimeout(this.save_to_history_id); this.save_to_history_id = null; } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/on_click_viewer.js `; ppixiv.resources["src/polyfills.js"] = `"use strict"; ppixiv.install_polyfills = function() { // Return true if name exists, eg. GM_xmlhttpRequest. var script_global_exists = function(name) { // For some reason, the script globals like GM and GM_xmlhttpRequest aren't // in window, so it's not clear how to check if they exist. Just try to // access it and catch the ReferenceError exception if it doesn't exist. try { eval(name); return true; } catch(e) { return false; } }; // If we have GM.xmlHttpRequest and not GM_xmlhttpRequest, set GM_xmlhttpRequest. if(script_global_exists("GM") && GM.xmlHttpRequest && !script_global_exists("GM_xmlhttpRequest")) window.GM_xmlhttpRequest = GM.xmlHttpRequest; // padStart polyfill: // https://github.com/uxitten/polyfill/blob/master/string.polyfill.js // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart if(!String.prototype.padStart) { String.prototype.padStart = function padStart(targetLength,padString) { targetLength = targetLength>>0; //truncate if number or convert non-number to 0; padString = String((typeof padString !== 'undefined' ? padString : ' ')); if (this.length > targetLength) { return String(this); } else { targetLength = targetLength-this.length; if (targetLength > padString.length) { padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed } return padString.slice(0,targetLength) + String(this); } }; } if(!("requestFullscreen" in Element.prototype)) { // Web API prefixing needs to be shot into the sun. if("webkitRequestFullScreen" in Element.prototype) { Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullScreen; HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.webkitCancelFullScreen; Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", { get: function() { return this.webkitFullscreenElement; } }); } else if("mozRequestFullScreen" in Element.prototype) { Element.prototype.requestFullscreen = Element.prototype.mozRequestFullScreen; HTMLDocument.prototype.exitFullscreen = HTMLDocument.prototype.mozCancelFullScreen; Object.defineProperty(HTMLDocument.prototype, "fullscreenElement", { get: function() { return this.mozFullScreenElement; } }); } } // Workaround for "Violentmonkey", which is missing exportFunction: if(!("exportFunction" in window)) { window.exportFunction = function(func) { return func; }; } // Make IDBRequest an async generator. // // Note that this will clobber onsuccess and onerror on the IDBRequest. if(!IDBRequest.prototype[Symbol.asyncIterator]) { // This is awful (is there no syntax sugar to make this more readable?), but it // makes IDBRequests much more sane to use. IDBRequest.prototype[Symbol.asyncIterator] = function() { return { next: () => { return new Promise((accept, reject) => { this.onsuccess = (e) => { let entry = e.target.result; if(entry == null) { accept({ done: true }); return; } accept({ value: entry, done: false }); entry.continue(); } this.onerror = (e) => { reject(e); }; }); } }; }; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/polyfills.js `; ppixiv.resources["src/progress_bar.js"] = `"use strict"; // A simple progress bar. // // Call bar.controller() to create a controller to update the progress bar. ppixiv.progress_bar = class { constructor(container) { this.container = container; this.bar = this.container.appendChild(helpers.create_node('\\
\\
\\ ')); this.bar.hidden = true; }; // Create a progress_bar_controller for this progress bar. // // If there was a previous controller, it will be detached. controller() { if(this.current_controller) { this.current_controller.detach(); this.current_controller = null; } this.current_controller = new progress_bar_controller(this); return this.current_controller; } } // This handles updating a progress_bar. // // This is separated from progress_bar, which allows us to transparently detach // the controller from a progress_bar. // // For example, if we load a video file and show the loading in the progress bar, and // the user then navigates to another video, we detach the first controller. This way, // the new load will take over the progress bar (whether or not we actually cancel the // earlier load) and progress bar users won't fight with each other. ppixiv.progress_bar_controller = class { constructor(bar) { this.progress_bar = bar; } set(value) { if(this.progress_bar == null) return; this.progress_bar.bar.hidden = (value == null); this.progress_bar.bar.classList.remove("hide"); this.progress_bar.bar.getBoundingClientRect(); if(value != null) this.progress_bar.bar.style.width = (value * 100) + "%"; } // Flash the current progress value and fade out. show_briefly() { this.progress_bar.bar.classList.add("hide"); } detach() { this.progress_bar = null; } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/progress_bar.js `; ppixiv.resources["src/seek_bar.js"] = `"use strict"; ppixiv.seek_bar = class { constructor(container) { this.mousedown = this.mousedown.bind(this); this.mouseup = this.mouseup.bind(this); this.mousemove = this.mousemove.bind(this); this.mouseover = this.mouseover.bind(this); this.mouseout = this.mouseout.bind(this); this.container = container; this.bar = this.container.appendChild(helpers.create_node('\\
\\
\\
\\
\\
\\ ')); this.bar.addEventListener("mousedown", this.mousedown); this.bar.addEventListener("mouseover", this.mouseover); this.bar.addEventListener("mouseout", this.mouseout); this.current_time = 0; this.duration = 1; this.refresh_visibility(); this.refresh(); this.set_callback(null); }; mousedown(e) { // Never start dragging while we have no callback. This generally shouldn't happen // since we should be hidden. if(this.callback == null) return; if(this.dragging) return; e.preventDefault(); this.dragging = true; helpers.set_class(this.bar, "dragging", this.dragging); this.refresh_visibility(); // Only listen to mousemove while we're dragging. Put this on window, so we get drags outside // the window. window.addEventListener("mousemove", this.mousemove); window.addEventListener("mouseup", this.mouseup); this.set_drag_pos(e); } mouseover() { this.hovering = true; this.refresh_visibility(); } mouseout() { this.hovering = false; this.refresh_visibility(); } refresh_visibility() { // Show the seek bar if the mouse is over it, or if we're actively dragging. // Only show if we're active. var visible = this.callback != null && (this.hovering || this.dragging); helpers.set_class(this.bar, "visible", visible); } stop_dragging() { if(!this.dragging) return; this.dragging = false; helpers.set_class(this.bar, "dragging", this.dragging); this.refresh_visibility(); window.removeEventListener("mousemove", this.mousemove); window.removeEventListener("mouseup", this.mouseup); if(this.callback) this.callback(false, null); } mouseup(e) { this.stop_dragging(); } mousemove(e) { this.set_drag_pos(e); } // The user clicked or dragged. Pause and seek to the clicked position. set_drag_pos(e) { // Get the mouse position relative to the seek bar. var bounds = this.bar.getBoundingClientRect(); var pos = (e.clientX - bounds.left) / bounds.width; pos = Math.max(0, Math.min(1, pos)); var time = pos * this.duration; // Tell the user to seek. this.callback(true, time); } // Set the callback. callback(pause, time) will be called when the user interacts // with the seek bar. The first argument is true if the video should pause (because // the user is dragging the seek bar), and time is the desired playback time. If callback // is null, remove the callback. set_callback(callback) { this.bar.hidden = callback == null; if(this.callback == callback) return; // Stop dragging on any previous caller before we replace the callback. if(this.callback != null) this.stop_dragging(); this.callback = callback; this.refresh_visibility(); }; set_duration(seconds) { this.duration = seconds; this.refresh(); }; set_current_time(seconds) { this.current_time = seconds; this.refresh(); }; refresh() { var position = this.duration > 0.0001? (this.current_time / this.duration):0; this.bar.querySelector(".seek-fill").style.width = (position * 100) + "%"; }; } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/seek_bar.js `; ppixiv.resources["src/struct.js"] = `"use strict"; // https://github.com/lyngklip/structjs/blob/master/struct.js // The MIT License (MIT) // Copyright (c) 2016 Aksel Jensen (TheRealAksel at github) // This is completely unreadable. Why would anyone write JS like this? /*eslint-env es6, node*/ ppixiv.struct = (function() { const rechk = /^([<>])?(([1-9]\\d*)?([xcbB?hHiIfdsp]))*\$/ const refmt = /([1-9]\\d*)?([xcbB?hHiIfdsp])/g const str = (v,o,c) => String.fromCharCode( ...new Uint8Array(v.buffer, v.byteOffset + o, c)) const rts = (v,o,c,s) => new Uint8Array(v.buffer, v.byteOffset + o, c) .set(s.split('').map(str => str.charCodeAt(0))) const pst = (v,o,c) => str(v, o + 1, Math.min(v.getUint8(o), c - 1)) const tsp = (v,o,c,s) => { v.setUint8(o, s.length); rts(v, o + 1, c - 1, s) } const lut = le => ({ x: c=>[1,c,0], c: c=>[c,1,o=>({u:v=>str(v, o, 1) , p:(v,c)=>rts(v, o, 1, c) })], '?': c=>[c,1,o=>({u:v=>Boolean(v.getUint8(o)),p:(v,B)=>v.setUint8(o,B)})], b: c=>[c,1,o=>({u:v=>v.getInt8( o ), p:(v,b)=>v.setInt8( o,b )})], B: c=>[c,1,o=>({u:v=>v.getUint8( o ), p:(v,B)=>v.setUint8( o,B )})], h: c=>[c,2,o=>({u:v=>v.getInt16( o,le), p:(v,h)=>v.setInt16( o,h,le)})], H: c=>[c,2,o=>({u:v=>v.getUint16( o,le), p:(v,H)=>v.setUint16( o,H,le)})], i: c=>[c,4,o=>({u:v=>v.getInt32( o,le), p:(v,i)=>v.setInt32( o,i,le)})], I: c=>[c,4,o=>({u:v=>v.getUint32( o,le), p:(v,I)=>v.setUint32( o,I,le)})], f: c=>[c,4,o=>({u:v=>v.getFloat32(o,le), p:(v,f)=>v.setFloat32(o,f,le)})], d: c=>[c,8,o=>({u:v=>v.getFloat64(o,le), p:(v,d)=>v.setFloat64(o,d,le)})], s: c=>[1,c,o=>({u:v=>str(v,o,c), p:(v,s)=>rts(v,o,c,s.slice(0,c ) )})], p: c=>[1,c,o=>({u:v=>pst(v,o,c), p:(v,s)=>tsp(v,o,c,s.slice(0,c - 1) )})] }) const errbuf = new RangeError("Structure larger than remaining buffer") const errval = new RangeError("Not enough values for structure") const struct = format => { let fns = [], size = 0, m = rechk.exec(format) if (!m) { throw new RangeError("Invalid format string") } const t = lut('<' === m[1]), lu = (n, c) => t[c](n ? parseInt(n, 10) : 1) while ((m = refmt.exec(format))) { ((r, s, f) => { for (let i = 0; i < r; ++i, size += s) { if (f) {fns.push(f(size))} } })(...lu(...m.slice(1)))} const unpack_from = (arrb, offs) => { if (arrb.byteLength < (offs|0) + size) { throw errbuf } let v = new DataView(arrb, offs|0) return fns.map(f => f.u(v)) } const pack_into = (arrb, offs, ...values) => { if (values.length < fns.length) { throw errval } if (arrb.byteLength < offs + size) { throw errbuf } const v = new DataView(arrb, offs) new Uint8Array(arrb, offs, size).fill(0) fns.forEach((f, i) => f.p(v, values[i])) } const pack = (...values) => { let b = new ArrayBuffer(size) pack_into(b, 0, ...values) return b } const unpack = arrb => unpack_from(arrb, 0) function* iter_unpack(arrb) { for (let offs = 0; offs + size <= arrb.byteLength; offs += size) { yield unpack_from(arrb, offs); } } return Object.freeze({ unpack, pack, unpack_from, pack_into, iter_unpack, format, size}) } return struct; })(); /* const pack = (format, ...values) => struct(format).pack(...values) const unpack = (format, buffer) => struct(format).unpack(buffer) const pack_into = (format, arrb, offs, ...values) => struct(format).pack_into(arrb, offs, ...values) const unpack_from = (format, arrb, offset) => struct(format).unpack_from(arrb, offset) const iter_unpack = (format, arrb) => struct(format).iter_unpack(arrb) const calcsize = format => struct(format).size module.exports = { struct, pack, unpack, pack_into, unpack_from, iter_unpack, calcsize } */ //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/struct.js `; ppixiv.resources["src/ugoira_downloader_mjpeg.js"] = `"use strict"; // Encode a Pixiv video to MJPEG, using an MKV container. // // Other than having to wrangle the MKV format, this is easy: the source files appear to always // be JPEGs, so we don't need to do any conversions and the encoding is completely lossless (other // than the loss Pixiv forces by reencoding everything to JPEG). The result is standard and plays // in eg. VLC, but it's not a WebM file and browsers don't support it. ppixiv.ugoira_downloader_mjpeg = class { constructor(illust_data, progress) { this.illust_data = illust_data; this.onprogress = progress; this.metadata = illust_data.ugoiraMetadata; this.mime_type = illust_data.ugoiraMetadata.mime_type; this.frames = []; this.load_all_frames(); } async load_all_frames() { let downloader = new ZipImageDownloader(this.metadata.originalSrc, { onprogress: (progress) => { if(!this.onprogress) return; try { this.onprogress.set(progress); } catch(e) { console.error(e); } }, }); while(1) { let file = await downloader.get_next_frame(); if(file == null) break; this.frames.push(file); } // Some posts have the wrong dimensions in illust_data (63162632). If we use it, the resulting // file won't play. Decode the first image to find the real resolution. var img = document.createElement("img"); var blob = new Blob([this.frames[0]], {type: this.mime_type || "image/png"}); var first_frame_url = URL.createObjectURL(blob); img.src = first_frame_url; await helpers.wait_for_image_load(img); URL.revokeObjectURL(first_frame_url); let width = img.naturalWidth; let height = img.naturalHeight; try { var encoder = new encode_mkv(width, height); // Add each frame to the encoder. var frame_count = this.illust_data.ugoiraMetadata.frames.length; for(var frame = 0; frame < frame_count; ++frame) { var frame_data = this.frames[frame]; let duration = this.metadata.frames[frame].delay; encoder.add(frame_data, duration); }; // There's no way to encode the duration of the final frame of an MKV, which means the last frame // will be effectively lost when looping. In theory the duration field on the file should tell the // player this, but at least VLC doesn't do that. // // Work around this by repeating the last frame with a zero duration. // // In theory we could set the "invisible" bit on this frame ("decoded but not displayed"), but that // doesn't seem to be used, at least not by VLC. var frame_data = this.frames[frame_count-1]; encoder.add(frame_data, 0); // Build the file. var mkv = encoder.build(); var filename = this.illust_data.userName + " - " + this.illust_data.illustId + " - " + this.illust_data.illustTitle + ".mkv"; helpers.save_blob(mkv, filename); } catch(e) { console.error(e); }; // Completed: if(this.onprogress) this.onprogress.set(null); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/ugoira_downloader_mjpeg.js `; ppixiv.resources["src/viewer.js"] = `"use strict"; // This is the base class for viewer classes, which are used to view a particular // type of content in the main display. ppixiv.viewer = class { constructor(container, illust_id) { this.illust_id = illust_id; this.active = false; } // Remove any event listeners, nodes, etc. and shut down so a different viewer can // be used. shutdown() { this.was_shutdown = true; } set page(page) { } get page() { return 0; } set active(value) { this._active = value; } get active() { return this._active; } // Return the file type for display in the UI, eg. "PNG". get current_image_type() { return null; } // If an image is displayed, clear it. // // This is only used with the illust viewer when changing manga pages in cases // where we don't want the old image to be displayed while the new one loads. set hide_image(value) { } get hide_image() { return false; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer.js `; ppixiv.resources["src/viewer_images.js"] = `"use strict"; // This is the viewer for static images. We take an illust_data and show // either a single image or navigate between an image sequence. ppixiv.viewer_images = class extends ppixiv.viewer { constructor(container, illust_id, options) { super(container, illust_id); this.container = container; this.options = options || {}; this.manga_page_bar = options.manga_page_bar; this.onkeydown = this.onkeydown.bind(this); this.index = options.manga_page || 0; // Create a click and drag viewer for the image. this.on_click_viewer = new on_click_viewer(); main_context_menu.get.on_click_viewer = this.on_click_viewer; this.load(); } async load() { // First, load early illust data. This is enough info to set up the image list // with preview URLs, so we can start the image view early. // // If this blocks to load, the full illust data will be loaded, so we'll never // run two separate requests here. let early_illust_data = await image_data.singleton().get_early_illust_data(this.illust_id); // Stop if we were removed before the request finished. if(this.was_shutdown) return; // Only add an entry for page 1. We don't have image dimensions for manga pages from // early data, so we can't use them for quick previews. this.images = [{ url: null, preview_url: early_illust_data.previewUrl, width: early_illust_data.width, height: early_illust_data.height, }]; this.refresh(); // Now wait for full illust info to load. this.illust_data = await image_data.singleton().get_image_info(this.illust_id); // Stop if we were removed before the request finished. if(this.was_shutdown) return; // Update the list to include the image URLs. this.images = []; for(var page of this.illust_data.mangaPages) { this.images.push({ url: page.urls.original, preview_url: page.urls.small, width: page.width, height: page.height, }); } this.refresh(); } // Note that this will always return JPG if all we have is the preview URL. get current_image_type() { return helpers.get_extension(this.url).toUpperCase(); } shutdown() { super.shutdown(); if(this.on_click_viewer) { this.on_click_viewer.disable(); this.on_click_viewer = null; } main_context_menu.get.on_click_viewer = null; } get page() { return this.index; } set page(page) { this.index = page; this.refresh(); } refresh() { // If we don't have this.images, load() hasn't set it up yet. if(this.images == null) return; // This will be null if this is a manga page that we don't have any info for yet. let current_image = this.images[this.index]; if(current_image == null) { console.info(\`No info for page \${this.index} yet\`); return; } if(this.on_click_viewer && current_image.url == this.on_click_viewer.url && current_image.preview_url == this.on_click_viewer.preview_url) return; // Create the new image and pass it to the viewer. this.url = current_image.url || current_image.preview_url; this.on_click_viewer.set_new_image(current_image.url, current_image.preview_url, this.container, current_image.width, current_image.height); // Decode the next and previous image. This reduces flicker when changing pages // since the image will already be decoded. if(this.index > 0 && this.index - 1 < this.images.length) helpers.decode_image(this.images[this.index - 1].url); if(this.index + 1 < this.images.length) helpers.decode_image(this.images[this.index + 1].url); // If we have a manga_page_bar, update to show the current page. if(this.manga_page_bar) { if(this.images.length == 1) this.manga_page_bar.set(null); else this.manga_page_bar.set((this.index+1) / this.images.length); } // If we were created with the restore_history option set, restore it now that // we have an image set up. This is done when we're restoring a browser state, so // only do this the first time. if(this.options.restore_history) { this.on_click_viewer.restore_from_history(); this.options.restore_history = false; } } onkeydown(e) { if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.keyCode) { case 36: // home e.stopPropagation(); e.preventDefault(); main_controller.singleton.show_illust(this.illust_data.id, { page: 0, }); return; case 35: // end e.stopPropagation(); e.preventDefault(); main_controller.singleton.show_illust(this.illust_data.id, { page: this.illust_data.pageCount - 1, }); return; } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_images.js `; ppixiv.resources["src/viewer_muted.js"] = `"use strict"; // This is used to display a muted image. ppixiv.viewer_muted = class extends ppixiv.viewer { constructor(container, illust_id) { super(container, illust_id); this.container = container; // Create the display. this.root = helpers.create_from_template(".template-muted"); container.appendChild(this.root); this.load(); } async load() { this.root.querySelector(".view-muted-image").addEventListener("click", (e) => { let args = helpers.args.location; args.hash.set("view-muted", "1"); helpers.set_page_url(args, false /* add_to_history */, "override-mute"); }); this.illust_data = await image_data.singleton().get_image_info(this.illust_id); // Stop if we were removed before the request finished. if(this.was_shutdown) return; // Show the user's avatar instead of the muted image. let user_info = await image_data.singleton().get_user_info(this.illust_data.userId); var img = this.root.querySelector(".muted-image"); img.src = user_info.imageBig; let muted_tag = muting.singleton.any_tag_muted(this.illust_data.tags.tags); let muted_user = muting.singleton.is_muted_user_id(this.illust_data.userId); let muted_label = this.root.querySelector(".muted-label"); if(muted_tag) tag_translations.get().set_translated_tag(muted_label, muted_tag); else if(muted_user) muted_label.innerText = this.illust_data.userName; } shutdown() { super.shutdown(); this.root.parentNode.removeChild(this.root); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_muted.js `; ppixiv.resources["src/viewer_ugoira.js"] = `"use strict"; ppixiv.viewer_ugoira = class extends ppixiv.viewer { constructor(container, illust_id, options) { super(container, illust_id); this.refresh_focus = this.refresh_focus.bind(this); this.clicked_canvas = this.clicked_canvas.bind(this); this.onkeydown = this.onkeydown.bind(this); this.drew_frame = this.drew_frame.bind(this); this.progress = this.progress.bind(this); this.seek_callback = this.seek_callback.bind(this); this.container = container; this.options = options; this.seek_bar = options.seek_bar; this.seek_bar.set_current_time(0); this.seek_bar.set_callback(this.seek_callback); // Create a canvas to render into. this.canvas = document.createElement("canvas"); this.canvas.hidden = true; this.canvas.className = "filtering"; this.canvas.style.width = "100%"; this.canvas.style.height = "100%"; this.canvas.style.objectFit = "contain"; this.container.appendChild(this.canvas); this.canvas.addEventListener("click", this.clicked_canvas, false); // True if we want to play if the window has focus. We always pause when backgrounded. let args = helpers.args.location; this.want_playing = !args.state.paused; // True if the user is seeking. We temporarily pause while seeking. This is separate // from this.want_playing so we stay paused after seeking if we were paused at the start. this.seeking = false; window.addEventListener("visibilitychange", this.refresh_focus); // This can be used to abort ZipImagePlayer's download. this.abort_controller = new AbortController; this.load(); } async load() { this.illust_data = await image_data.singleton().get_image_info(this.illust_id); // Stop if we were removed before the request finished. if(this.was_shutdown) return; this.create_preview_images(this.illust_data); // Create the player. this.player = new ZipImagePlayer({ metadata: this.illust_data.ugoiraMetadata, autoStart: false, source: this.illust_data.ugoiraMetadata.originalSrc, mime_type: this.illust_data.ugoiraMetadata.mime_type, signal: this.abort_controller.signal, autosize: true, canvas: this.canvas, loop: true, debug: false, progress: this.progress, drew_frame: this.drew_frame, }); this.refresh_focus(); } async create_preview_images(illust_data) { // Create an image to display the static image while we load. // // Like static image viewing, load the thumbnail, then the main image on top, since // the thumbnail will often be visible immediately. this.preview_img1 = document.createElement("img"); this.preview_img1.classList.add("low-res-preview"); this.preview_img1.style.position = "absolute"; this.preview_img1.style.width = "100%"; this.preview_img1.style.height = "100%"; this.preview_img1.style.objectFit = "contain"; this.preview_img1.src = illust_data.urls.small; this.container.appendChild(this.preview_img1); this.preview_img2 = document.createElement("img"); this.preview_img2.style.position = "absolute"; this.preview_img2.className = "filtering"; this.preview_img2.style.width = "100%"; this.preview_img2.style.height = "100%"; this.preview_img2.style.objectFit = "contain"; this.preview_img2.src = illust_data.urls.original; this.container.appendChild(this.preview_img2); // Allow clicking the previews too, so if you click to pause the video before it has enough // data to start playing, it'll still toggle to paused. this.preview_img1.addEventListener("click", this.clicked_canvas, false); this.preview_img2.addEventListener("click", this.clicked_canvas, false); // Wait for the high-res image to finish loading. helpers.wait_for_image_load(this.preview_img2); // Remove the low-res preview image when the high-res one finishes loading. this.preview_img1.remove(); } set active(active) { super.active = active; // Rewind the video when we're not visible. if(!active && this.player != null) this.player.rewind(); // Refresh playback, since we pause while the viewer isn't visible. this.refresh_focus(); } progress(value) { if(this.options.progress_bar) this.options.progress_bar.set(value); if(value == null) { // Once we send "finished", don't make any more progress calls. this.options.progress_bar = null; } } // Once we draw a frame, hide the preview and show the canvas. This avoids // flicker when the first frame is drawn. drew_frame() { if(this.preview_img1) this.preview_img1.hidden = true; if(this.preview_img2) this.preview_img2.hidden = true; this.canvas.hidden = false; if(this.seek_bar) { // Update the seek bar. var frame_time = this.player.get_current_frame_time(); this.seek_bar.set_current_time(this.player.get_current_frame_time()); this.seek_bar.set_duration(this.player.get_seekable_duration()); } } // This is sent manually by the UI handler so we can control focus better. onkeydown(e) { if(e.keyCode >= 49 && e.keyCode <= 57) { // 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up. e.stopPropagation(); e.preventDefault(); if(!this.player) return; var speed; switch(e.keyCode) { case 49: speed = 0.10; break; // 1 case 50: speed = 0.25; break; // 2 case 51: speed = 0.50; break; // 3 case 52: speed = 0.75; break; // 4 case 53: speed = 1.00; break; // 5 case 54: speed = 1.25; break; // 6 case 55: speed = 1.50; break; // 7 case 56: speed = 1.75; break; // 8 case 57: speed = 2.00; break; // 9 } this.player.set_speed(speed); return; } switch(e.keyCode) { case 32: // space e.stopPropagation(); e.preventDefault(); this.set_want_playing(!this.want_playing); return; case 36: // home e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.player.rewind(); return; case 35: // end e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.pause(); this.player.set_current_frame(this.player.get_frame_count() - 1); return; case 81: // q case 87: // w e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.pause(); var current_frame = this.player.get_current_frame(); var next = e.keyCode == 87; var new_frame = current_frame + (next?+1:-1); this.player.set_current_frame(new_frame); return; } } play() { this.set_want_playing(true); } pause() { this.set_want_playing(false); } // Set whether the user wants the video to be playing or paused. set_want_playing(value) { if(this.want_playing != value) { // Store the play/pause state in history, so if we navigate out and back in while // paused, we'll stay paused. let args = helpers.args.location; args.state.paused = !value; helpers.set_page_url(args, false, "updating-video-pause"); this.want_playing = value; } this.refresh_focus(); } shutdown() { super.shutdown(); // Cancel the player's download. this.abort_controller.abort(); if(this.seek_bar) { this.seek_bar.set_callback(null); this.seek_bar = null; } window.removeEventListener("visibilitychange", this.refresh_focus); // Send a finished progress callback if we were still loading. We won't // send any progress calls after this. this.progress(null); if(this.player) this.player.pause(); if(this.preview_img1) { this.preview_img1.remove(); this.preview_img1 = null; } if(this.preview_img2) { this.preview_img2.remove(); this.preview_img2 = null; } this.canvas.remove(); } refresh_focus() { if(this.player == null) return; let active = this.want_playing && !this.seeking && !window.document.hidden && this._active; if(active) this.player.play(); else this.player.pause(); }; clicked_canvas(e) { this.set_want_playing(!this.want_playing); this.refresh_focus(); } // This is called when the user interacts with the seek bar. seek_callback(pause, seconds) { this.seeking = pause; this.refresh_focus(); if(seconds != null) this.player.set_current_frame_time(seconds); }; } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/viewer_ugoira.js `; ppixiv.resources["src/zip_image_player.js"] = `"use strict"; // A wrapper for the clunky ReadableStream API that lets us do at basic // thing that API forgot about: read a given number of bytes at a time. ppixiv.IncrementalReader = class { constructor(reader, options={}) { this.reader = reader; this.position = 0; // Check if this is an ArrayBuffer. "reader instanceof ArrayBuffer" is // broken in Firefox (but what isn't?). if("byteLength" in reader) { this.input_buffer = new Int8Array(reader); this.input_buffer_finished = true; } else { this.input_buffer = new Int8Array(0); this.input_buffer_finished = false; } // If set, this is called with the current read position as we read data. this.onprogress = options.onprogress; } async read(bytes) { let buffer = new ArrayBuffer(bytes); let result = new Int8Array(buffer); let output_pos = 0; while(output_pos < bytes) { // See if we have leftover data in this.input_buffer. if(this.input_buffer.byteLength > 0) { // Create a view of the bytes we want to copy, then use set() to copy them to the // output. This is just memcpy(), why can't you just set(buf, srcPos, srcLen, dstPos)? let copy_bytes = Math.min(bytes-output_pos, this.input_buffer.byteLength); let buf = new Int8Array(this.input_buffer.buffer, this.input_buffer.byteOffset, copy_bytes); result.set(buf, output_pos); output_pos += copy_bytes; // Remove the data we read from the buffer. This is just making the view smaller. this.input_buffer = new Int8Array(this.input_buffer.buffer, this.input_buffer.byteOffset + copy_bytes); continue; } // If we need more data and there isn't any, we've passed EOF. if(this.input_buffer_finished) throw new Error("Incomplete file"); let { value, done } = await this.reader.read(); if(value == null) value = new Int8Array(0); this.input_buffer_finished = done; this.input_buffer = value; if(value) this.position += value.length; if(this.onprogress) this.onprogress(this.position); }; return buffer; } }; // Download a ZIP, returning files as they download in the order they're stored // in the ZIP. ppixiv.ZipImageDownloader = class { constructor(url, options={}) { this.url = url; // An optional AbortSignal. this.signal = options.signal; this.onprogress = options.onprogress; this.start_promise = this.start(); } async start() { let response = await helpers.send_pixiv_request({ method: "GET", url: this.url, responseType: "arraybuffer", signal: this.signal, }); // We could also figure out progress from frame numbers, but doing it with the actual // amount downloaded is more accurate, and the server always gives us content-length. this.total_length = response.headers.get("Content-Length"); if(this.total_length != null) this.total_length = parseInt(this.total_length); // Firefox is in the dark ages and can't stream data from fetch. Fall back // on loading the whole body if we don't have getReader. let fetch_reader; if(response.body.getReader) fetch_reader = response.body.getReader(); else fetch_reader = await response.arrayBuffer(); this.reader = new IncrementalReader(fetch_reader, { onprogress: (position) => { if(this.onprogress && this.total_length > 0) { let progress = position / this.total_length; this.onprogress(progress); } } }); } async get_next_frame() { // Wait for start_download to complete, if it hasn't yet. await this.start_promise; // Read the local file header up to the filename. let header = await this.reader.read(30); let view = new DataView(header); // Check the header. let magic = view.getUint32(0, true); if(magic == 0x02014b50) { // Once we see the central directory, we're at the end. return null; } if(magic != 0x04034b50) throw Error("Unrecognized file"); let compression = view.getUint16(8, true); if(compression != 0) throw Error("Unsupported compression method"); // Get the variable field lengths, and skip over the rest of the local file headers. let file_size = view.getUint32(22, true); let filename_size = view.getUint16(26, true); let extra_size = view.getUint16(28, true); await this.reader.read(filename_size); await this.reader.read(extra_size); // Read the file. return await this.reader.read(file_size); } }; ppixiv.ZipImagePlayer = class { constructor(options) { this.next_frame = this.next_frame.bind(this); this.op = options; // If true, continue playback when we get more data. this.waiting_for_frame = true; this.dead = false; this.context = options.canvas.getContext("2d"); this.frame_count = this.op.metadata.frames.length; // The frame that we want to be displaying: this.frame = 0; this.failed = false; // Make a list of timestamps for each frame. this.frameTimestamps = []; let milliseconds = 0; let last_frame_time = 0; for(let frame of this.op.metadata.frames) { this.frameTimestamps.push(milliseconds); milliseconds += frame.delay; last_frame_time = frame.delay; } this.total_length = milliseconds; // The duration to display on the seek bar. This doesn't include the duration of the // final frame. We can't seek to the actual end of the video past the end of the last // frame, and the end of the seek bar represents the beginning of the last frame. this.seekable_length = milliseconds - last_frame_time; this.frame_data = []; this.frame_images = []; this.speed = 1; this.paused = !this.op.autoStart; this.load(); } error(msg) { this.failed = true; throw Error("ZipImagePlayer error: " + msg); } async load() { this.downloader = new ZipImageDownloader(this.op.source, { signal: this.op.signal, }); let frame = 0; while(1) { let file; try { file = await this.downloader.get_next_frame(); } catch(e) { // This will usually be cancellation. console.info("Error downloading file", e); return; } if(file == null) break; // Read the frame data into a blob and store it. // // Don't decode it just yet. We'll decode it the first time it's displayed. This way, // we read the file as it comes in, but we won't burst decode every frame right at the // start. This is important if the video ZIP is coming out of cache, since the browser // can't cache the image decodes and we'll cause a big burst of CPU load. let mime_type = this.op.metadata.mime_type || "image/png"; let blob = new Blob([file], {type: mime_type}); this.frame_data.push(blob); // Call progress. This is relative to frame timestamps, so load progress lines up // with the seek bar. if(this.op.progress) { let progress = this.frameTimestamps[frame] / this.total_length; this.op.progress(progress); } frame++; // We have more data to potentially decode, so start decode_frames if it's not already running. this.decode_frames(); } // Call completion. if(this.op.progress) this.op.progress(null); } // Load the next frame into this.frame_images. async decode_frames() { // If this is already running, don't start another. if(this.loading_frames) return; try { this.loading_frames = true; while(await this.decode_one_frame()) { } } finally { this.loading_frames = false; } } // Decode up to one frame ahead of this.frame, so we don't wait until we need a // frame to start decoding it. Return true if we decoded a frame and should be // called again to see if we can decode another. async decode_one_frame() { let ahead = 0; for(ahead = 0; ahead < 2; ++ahead) { let frame = this.frame + ahead; // Stop if we don't have data for this frame. If we don't have this frame, we won't // have any after either. let blob = this.frame_data[frame]; if(blob == null) return; // Skip this frame if it's already decoded. if(this.frame_images[frame]) continue; let url = URL.createObjectURL(blob); let image = document.createElement("img"); image.src = url; await helpers.wait_for_image_load(image); URL.revokeObjectURL(url); this.frame_images[frame] = image; // If we were stalled waiting for data, display the frame. It's possible the frame // changed while we were blocking and we won't actually have the new frame, but we'll // just notice and turn waiting_for_frame back on. if(this.waiting_for_frame) { this.waiting_for_frame = false; this.display_frame(); } if(this.dead) return false; return true; } return false; } async display_frame() { if(this.dead) return; this.decode_frames(); // If we don't have the frame yet, just record that we want to be called when the // frame is decoded and stop. decode_frames will call us when there's a frame to display. if(!this.frame_images[this.frame]) { // We haven't downloaded this far yet. Show the frame when we get it. this.waiting_for_frame = true; return; } let image = this.frame_images[this.frame]; if(this.op.autosize) { if(this.context.canvas.width != image.width || this.context.canvas.height != image.height) { // make the canvas autosize itself according to the images drawn on it // should set it once, since we don't have variable sized frames this.context.canvas.width = image.width; this.context.canvas.height = image.height; } }; this.drawn_frame = this.frame; this.context.clearRect(0, 0, this.op.canvas.width, this.op.canvas.height); this.context.drawImage(image, 0, 0); // If the user wants to know when the frame is ready, call it. if(this.op.drew_frame) { helpers.yield(() => { this.op.drew_frame(null); }); } if(this.paused) return; let meta = this.op.metadata.frames[this.frame]; this.pending_frame_metadata = meta; this.refresh_timer(); } unset_timer() { if(!this.timer) return; clearTimeout(this.timer); this.timer = null; } refresh_timer() { if(this.paused) return; this.unset_timer(); this.timer = setTimeout(this.next_frame, this.pending_frame_metadata.delay / this.speed); } get_frame_duration() { let meta = this.op.metadata.frames[this.frame]; return meta.delay; } next_frame(frame) { this.timer = null; if(this.frame >= (this.frame_count - 1)) { if(!this.op.loop) { this.pause(); return; } this.frame = 0; } else { this.frame += 1; } this.display_frame(); } play() { if(this.dead) return; if(this.paused) { this.paused = false; this.display_frame(); } } pause() { if(this.dead) return; if(!this.paused) { this.unset_timer(); this.paused = true; } } toggle_pause() { if(this.paused) this.play(); else this.pause(); } rewind() { if(this.dead) return; this.frame = 0; this.unset_timer(); this.display_frame(); } set_speed(value) { this.speed = value; // Refresh the timer, so we don't wait a long time if we're changing from a very slow // playback speed. this.refresh_timer(); } stop() { this.dead = true; this.unset_timer(); this.frame_images = null; } get_current_frame() { return this.frame; } set_current_frame(frame) { frame %= this.frame_count; if(frame < 0) frame += this.frame_count; this.frame = frame; this.display_frame(); } get_total_duration() { return this.total_length / 1000; } get_seekable_duration() { return this.seekable_length / 1000; } get_current_frame_time() { return this.frameTimestamps[this.frame] / 1000; } // Set the video to the closest frame to the given time. set_current_frame_time(seconds) { // We don't actually need to check all frames, but there's no need to optimize this. let closest_frame = null; let closest_error = null; for(let frame = 0; frame < this.op.metadata.frames.length; ++frame) { // Only seek to images that we've downloaded. If we reach a frame we don't have // yet, stop. if(!this.frame_data[frame]) break; let error = Math.abs(seconds - this.frameTimestamps[frame]/1000); if(closest_frame == null || error < closest_error) { closest_frame = frame; closest_error = error; } } this.frame = closest_frame; this.display_frame(); } get_frame_count() { return this.frame_count; } } /* * The MIT License (MIT) * * Copyright (c) 2014 Pixiv Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/zip_image_player.js `; ppixiv.resources["src/screen.js"] = `"use strict"; // The base class for our main screens. ppixiv.screen = class { constructor(container) { this.container = container; // Make our container focusable, so we can give it keyboard focus when we // become active. this.container.tabIndex = -1; } // Handle a key input. This is only called while the screen is active. handle_onkeydown(e) { } // Return the view that navigating back in the popup menu should go to. get navigate_out_target() { return null; } // If this screen is displaying an image, return its ID. // If this screen is displaying a user's posts, return "user:ID". // Otherwise, return null. get displayed_illust_id() { return null; } // If this screen is displaying a manga page, return its ID. Otherwise, return null. // If this is non-null, displayed_illust_id will always also be non-null. get displayed_illust_page() { return null; } // These are called to restore the scroll position on navigation. scroll_to_top() { } restore_scroll_position() { } scroll_to_illust_id(illust_id, manga_page) { } async set_active(active) { // Show or hide the screen. this.container.hidden = !active; if(active) { // Focus the container, so it receives keyboard events, eg. home/end. this.container.focus(); } else { // When the screen isn't active, send viewhidden to close all popup menus inside it. view_hidden_listener.send_viewhidden(this.container); } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen.js `; ppixiv.resources["src/screen_illust.js"] = `"use strict"; // The main UI. This handles creating the viewers and the global UI. ppixiv.screen_illust = class extends ppixiv.screen { constructor(container) { super(container); this.onwheel = this.onwheel.bind(this); this.refresh_ui = this.refresh_ui.bind(this); this.data_source_updated = this.data_source_updated.bind(this); this.current_illust_id = -1; this.latest_navigation_direction_down = true; this.container = container; this.progress_bar = main_controller.singleton.progress_bar; // Create a UI box and put it in its container. var ui_container = this.container.querySelector(".ui"); this.ui = new image_ui(ui_container, this.progress_bar); var ui_box = this.container.querySelector(".ui-box"); var ui_visibility_changed = () => { // Hide the dropdown tag widget when the hover UI is hidden. let visible = ui_box.classList.contains("hovering-over-box") || ui_box.classList.contains("hovering-over-sphere"); if(!visible) { this.ui.bookmark_tag_widget.visible = false; // XXX remove view_hidden_listener.send_viewhidden(ui_box); } // Tell the image UI when it's visible. this.ui.visible = visible; }; ui_box.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-box", true); ui_visibility_changed(); }); ui_box.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-box", false); ui_visibility_changed(); }); var hover_circle = this.container.querySelector(".ui .hover-circle"); hover_circle.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", true); ui_visibility_changed(); }); hover_circle.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", false); ui_visibility_changed(); }); image_data.singleton().user_modified_callbacks.register(this.refresh_ui); image_data.singleton().illust_modified_callbacks.register(this.refresh_ui); settings.register_change_callback("recent-bookmark-tags", this.refresh_ui); // Remove the "flash" class when the page change indicator's animation finishes. let page_change_indicator = this.container.querySelector(".page-change-indicator"); page_change_indicator.addEventListener("animationend", (e) => { console.log("done", e.target); page_change_indicator.classList.remove("flash"); }); new hide_mouse_cursor_on_idle(this.container.querySelector(".mouse-hidden-box")); // this.manga_thumbnails = new manga_thumbnail_widget(this.container.querySelector(".manga-thumbnail-container")); this.container.addEventListener("wheel", this.onwheel, { passive: false }); // A bar showing how far along in an image sequence we are: this.manga_page_bar = new progress_bar(this.container.querySelector(".ui-box")).controller(); this.seek_bar = new seek_bar(this.container.querySelector(".ugoira-seek-bar")); this.set_active(false, { }); this.flashed_page_change = false; } set_data_source(data_source) { if(data_source == this.data_source) return; if(this.data_source != null) { this.data_source.remove_update_listener(this.data_source_updated); this.data_source = null; } this.data_source = data_source; this.ui.data_source = data_source; if(this.data_source != null) { this.data_source.add_update_listener(this.data_source_updated); this.refresh_ui(); } } get _hide_image() { return this.container.querySelector(".image-container").hidden; } set _hide_image(value) { this.container.querySelector(".image-container").hidden = value; } async set_active(active, { illust_id, page, data_source, restore_history }) { this._active = active; await super.set_active(active); // If we have a viewer, tell it if we're active. if(this.viewer != null) this.viewer.active = this._active; if(!active) { this.cancel_async_navigation(); // Remove any image we're displaying, so if we show another image later, we // won't show the previous image while the new one's data loads. if(this.viewer != null) this._hide_image = true; // Stop showing the user in the context menu, and stop showing the current page. main_context_menu.get.user_id = null; main_context_menu.get.page = null; this.flashed_page_change = false; this.stop_displaying_image(); return; } this.set_data_source(data_source); this.show_image(illust_id, page, restore_history); } // Show an image. If manga_page is -1, show the last page. async show_image(illust_id, manga_page, restore_history) { helpers.set_class(document.body, "force-ui", unsafeWindow.debug_show_ui); // Reset the manga page change indicator when we change images. this.flashed_page_change = false; // If we previously set a pending navigation, this navigation overrides it. this.cancel_async_navigation(); // Remember that this is the image we want to be displaying. this.wanted_illust_id = illust_id; this.wanted_illust_page = manga_page; // Get very basic illust info. This is enough to tell which viewer to use, how // many pages it has, and whether it's muted. This will always complete immediately // if we're coming from a search or anywhere else that will already have this info, // but it can block if we're loading from scratch. let early_illust_data = await image_data.singleton().get_early_illust_data(illust_id); // If we were deactivated while waiting for image info or the image we want to show has changed, stop. if(!this.active || this.wanted_illust_id != illust_id || this.wanted_illust_page != manga_page) { console.log("show_image: illust ID or page changed while async, stopping"); return; } // Check if we got illust info. This usually means it's been deleted. if(early_illust_data == null) { let message = image_data.singleton().get_illust_load_error(illust_id); message_widget.singleton.show(message); message_widget.singleton.clear_timer(); return; } // If manga_page is -1, update wanted_illust_page with the last page now that we know // what it is. if(manga_page == -1) manga_page = early_illust_data.pageCount - 1; else manga_page = helpers.clamp(manga_page, 0, early_illust_data.pageCount-1); this.wanted_illust_page = manga_page; // If this image is already loaded, just make sure it's not hidden. if( this.wanted_illust_id == this.current_illust_id && this.wanted_illust_page == this.viewer.page && this.viewer != null && this.hiding_muted_image == this.view_muted && // view-muted not changing !this._hide_image) { console.log(\`illust \${illust_id} page \${this.wanted_illust_page} is already displayed\`); return; } console.log(\`Showing image \${illust_id} page \${manga_page}\`); helpers.set_title_and_icon(early_illust_data); // Tell the preloader about the current image. image_preloader.singleton.set_current_image(illust_id, manga_page); // If we adjusted the page, update the URL. Allow "page" to be 1 or not present for // page 1. var args = helpers.args.location; var wanted_page_arg = early_illust_data.pageCount > 1? (manga_page + 1):1; let current_page_arg = args.hash.get("page") || "1"; if(current_page_arg != wanted_page_arg) { if(wanted_page_arg != null) args.hash.set("page", wanted_page_arg); else args.hash.delete("page"); console.log("Updating URL with page number:", args.hash.toString()); helpers.set_page_url(args, false /* add_to_history */); } // This is the first image we're displaying if we previously had no illust ID, or // if we were hidden. let is_first_image_displayed = this.current_illust_id == -1 || this._hide_image; // Speculatively load the next image, which is what we'll show if you press page down, so // advancing through images is smoother. // // We don't do this when showing the first image, since the most common case is simply // viewing a single image and not navigating to any others, so this avoids making // speculative loads every time you load a single illustration. if(!is_first_image_displayed) { // get_navigation may block to load more search results. Run this async without // waiting for it. (async() => { let { illust_id: new_illust_id, page: new_page } = await this.get_navigation(this.latest_navigation_direction_down); // Let image_preloader handle speculative loading. If preload_illust_id is null, // we're telling it that we don't need to load anything. image_preloader.singleton.set_speculative_image(new_illust_id, new_page); })(); } // If the illust ID isn't changing, just update the viewed page. if(illust_id == this.current_illust_id && this.viewer != null && this.viewer.page != this.wanted_illust_page) { console.log("Image ID not changed, setting page", this.wanted_illust_page, "of image", this.current_illust_id); this._hide_image = false; this.viewer.page = this.wanted_illust_page; if(this.manga_thumbnails) this.manga_thumbnails.current_page_changed(manga_page); this.refresh_ui(); return; } // Finalize the new illust ID. this.current_illust_id = illust_id; this.current_user_id = early_illust_data.userId; this.viewing_manga = early_illust_data.pageCount > 1; // for navigate_out_target this.ui.illust_id = illust_id; this.refresh_ui(); if(this.update_mute(early_illust_data)) return; // If the image has the ドット絵 tag, enable nearest neighbor filtering. helpers.set_class(document.body, "dot", helpers.tags_contain_dot(early_illust_data)); // Dismiss any message when changing images. message_widget.singleton.hide(); this.remove_viewer(); // Create the image viewer. var progress_bar = this.progress_bar.controller(); let image_container = this.container.querySelector(".image-container"); if(early_illust_data.illustType == 2) this.viewer = new viewer_ugoira(image_container, illust_id, { progress_bar: progress_bar, seek_bar: this.seek_bar, }); else { this.viewer = new viewer_images(image_container, illust_id, { progress_bar: progress_bar, manga_page_bar: this.manga_page_bar, manga_page: manga_page, restore_history: restore_history, }); } // If the viewer was hidden, unhide it now that the new one is set up. this._hide_image = false; this.viewer.active = this._active; // Refresh the UI now that we have a new viewer. this.refresh_ui(); } get view_muted() { return helpers.args.location.hash.get("view-muted") == "1"; } should_hide_muted_image(early_illust_data) { let muted_tag = muting.singleton.any_tag_muted(early_illust_data.tags); let muted_user = muting.singleton.is_muted_user_id(early_illust_data.userId); if(this.view_muted || (!muted_tag && !muted_user)) return { is_muted: false }; return { is_muted: true, muted_tag: muted_tag, muted_user: muted_user }; } update_mute(early_illust_data) { // Check if this post is muted. let { is_muted } = this.should_hide_muted_image(early_illust_data); this.hiding_muted_image = this.view_muted; if(!is_muted) return false; // Tell the thumbnail view about the image. If the image is muted, disable thumbs. if(this.manga_thumbnails) this.manga_thumbnails.set_illust_info(null); // If the image is muted, load a dummy viewer. let image_container = this.container.querySelector(".image-container"); this.remove_viewer(); this.viewer = new viewer_muted(image_container, this.current_illust_id); this._hide_image = false; return true; } // Remove the old viewer, if any. remove_viewer() { if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } } // If we started navigating to a new image and were delayed to load data (either to load // the image or to load a new page), cancel it and stay where we are. cancel_async_navigation() { // If we previously set a pending navigation, this navigation overrides it. if(this.pending_navigation == null) return; console.info("Cancelling async navigation"); this.pending_navigation = null; } // Stop displaying any image (and cancel any wanted navigation), putting us back // to where we were before displaying any images. // // This will also prevent the next image displayed from triggering speculative // loading, which we don't want to do when clicking an image in the thumbnail // view. stop_displaying_image() { if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } if(this.manga_thumbnails) this.manga_thumbnails.set_illust_info(null); this.wanted_illust_id = null; this.current_illust_id = null; this.wanted_illust_page = 0; this.current_illust_id = -1; this.refresh_ui(); } data_source_updated() { this.refresh_ui(); } get active() { return this._active; } // Refresh the UI for the current image. refresh_ui() { // Don't refresh if the thumbnail view is active. We're not visible, and we'll just // step over its page title, etc. if(!this._active) return; // Tell the UI which page is being viewed. var page = this.viewer != null? this.viewer.page:0; this.ui.set_displayed_page_info(page); // Tell the context menu which user is being viewed. main_context_menu.get.user_id = this.current_user_id; main_context_menu.get.page = page; // Pull out info about the user and illustration. var illust_id = this.current_illust_id; // Update the disable UI button to point at the current image's illustration page. var disable_button = this.container.querySelector(".disable-ui-button"); disable_button.href = "/artworks/" + illust_id + "#no-ppixiv"; // If we're not showing an image yet, hide the UI and don't try to update it. helpers.set_class(this.container.querySelector(".ui"), "disabled", illust_id == -1); if(illust_id == -1) return; this.ui.refresh(); } onwheel(e) { if(!this._active) return; // Don't intercept wheel scrolling over the description box. if(e.target.closest(".description") != null) return; var down = e.deltaY > 0; this.move(down, e.shiftKey /* skip_manga_pages */); } get displayed_illust_id() { return this.wanted_illust_id; } get displayed_illust_page() { return this.wanted_illust_page; } get navigate_out_target() { // If we're viewing a manga post, exit to the manga page view instead of the search. if(this.viewing_manga) return "manga"; else return "search"; } handle_onkeydown(e) { // Let the viewer handle the input first. if(this.viewer && this.viewer.onkeydown) { this.viewer.onkeydown(e); if(e.defaultPrevented) return; } this.ui.handle_onkeydown(e); if(e.defaultPrevented) return; if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.keyCode) { case 37: // left case 38: // up case 33: // pgup e.preventDefault(); e.stopPropagation(); this.move(false, e.shiftKey /* skip_manga_pages */); break; case 39: // right case 40: // down case 34: // pgdn e.preventDefault(); e.stopPropagation(); this.move(true, e.shiftKey /* skip_manga_pages */); break; } } // Get the illust_id and page navigating down (or up) will go to. // // This may trigger loading the next page of search results, if we've reached the end. async get_navigation(down, { skip_manga_pages=false }={}) { // Check if we're just changing pages within the same manga post. let leaving_manga_post = false; if(!skip_manga_pages && this.wanted_illust_id != null) { // Using early_illust_data here means we can handle page navigation earlier, if // the user navigates before we have full illust info. let early_illust_data = await image_data.singleton().get_early_illust_data(this.wanted_illust_id); let num_pages = early_illust_data.pageCount; if(num_pages > 1) { var old_page = this.displayed_illust_page; var new_page = old_page + (down? +1:-1); new_page = Math.max(0, Math.min(num_pages - 1, new_page)); if(new_page != old_page) return { illust_id: this.wanted_illust_id, page: new_page }; // If the page didn't change, we reached the end of the manga post. leaving_manga_post = true; } } // If we have a target illust_id, move relative to it. Otherwise, move relative to the // displayed image. This way, if we navigate repeatedly before a previous navigation // finishes, we'll keep moving rather than waiting for each navigation to complete. var navigate_from_illust_id = this.wanted_illust_id; if(navigate_from_illust_id == null) navigate_from_illust_id = this.current_illust_id; // Get the next (or previous) illustration after the current one. This will be null if we've // reached the end of the list, or if it requires loading the next page of search results. var new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down); if(new_illust_id == null) { // We didn't have the new illustration, so we may need to load another page of search results. // Find the page the current illustration is on. let next_page = this.data_source.id_list.get_page_for_neighboring_illust(navigate_from_illust_id, down); // If we can't find the next page, then the current image isn't actually loaded in // the current search results. This can happen if the page is reloaded: we'll show // the previous image, but we won't have the results loaded (and the results may have // changed). Just jump to the first image in the results so we get back to a place // we can navigate from. // // Note that we use id_list.get_first_id rather than get_current_illust_id, which is // just the image we're already on. if(next_page == null) { // We should normally know which page the illustration we're currently viewing is on. console.warn("Don't know the next page for illust", navigate_from_illust_id); new_illust_id = this.data_source.id_list.get_first_id(); if(new_illust_id != null) return { illust_id: new_illust_id }; return { }; } console.log("Loaded the next page of results:", next_page); // The page shouldn't already be loaded. Double-check to help prevent bugs that might // spam the server requesting the same page over and over. if(this.data_source.id_list.is_page_loaded(next_page)) { console.error("Page", next_page, "is already loaded"); return { }; } // Ask the data source to load it. let new_page_loaded = this.data_source.load_page(next_page, { cause: "illust navigation" }); // Wait for results. new_page_loaded = await new_page_loaded; this.pending_navigation = null; if(new_page_loaded) { // Now that we've loaded data, try to find the new image again. new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down); } console.log("Retrying navigation after data load"); } // If we didn't get a page, we're at the end of the search results. Flash the // indicator to show we've reached the end and stop. if(new_illust_id == null) { console.log("Reached the end of the list"); this.flash_end_indicator(down, "last-image"); return { illust_id: null, page: null, end: true }; } let page = down || skip_manga_pages? 0:-1; return { illust_id: new_illust_id, page: page, leaving_manga_post: leaving_manga_post }; } // Navigate to the next or previous image. // // If skip_manga_pages is true, jump past any manga pages in the current illustration. If // this is true and we're navigating backwards, we'll also jump to the first manga page // instead of the last. async move(down, skip_manga_pages) { // Remember whether we're navigating forwards or backwards, for preloading. this.latest_navigation_direction_down = down; this.cancel_async_navigation(); let pending_navigation = this.pending_navigation = new Object(); // See if we should change the manga page. This may block if it needs to load // the next page of search results. let { illust_id: new_illust_id, page, end, leaving_manga_post } = await this.get_navigation(down, { skip_manga_pages: skip_manga_pages, }); // If this.pending_navigation is no longer the same as pending_navigation, we navigated since // we requested this load and this navigation is stale, so stop. if(this.pending_navigation != pending_navigation) { console.error("Aborting stale navigation"); return { stale: true }; } // If we didn't get a page, we're at the end of the search results. Flash the // indicator to show we've reached the end and stop. if(end) { console.log("Reached the end of the list"); this.flash_end_indicator(down, "last-image"); return; } // If we're confirming leaving a manga post, do that now. This is done after we load the // new page of search results if needed, so we know whether we've actually reached the end // and should show the end indicator above instead. if(leaving_manga_post && !this.flashed_page_change && 0) { this.flashed_page_change = true; this.flash_end_indicator(down, "last-page"); // Start preloading the next image, so we load faster if the user scrolls again to go // to the next image. if(new_illust_id != null) image_data.singleton().get_image_info(new_illust_id); return; } // Go to the new illustration if we have one. if(new_illust_id != null) main_controller.singleton.show_illust(new_illust_id, { page: page }); } flash_end_indicator(down, icon) { let indicator = this.container.querySelector(".page-change-indicator"); indicator.dataset.icon = icon; indicator.dataset.side = down? "right":"left"; indicator.classList.remove("flash"); // Call getAnimations() so the animation is removed immediately: indicator.getAnimations(); indicator.classList.add("flash"); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_illust.js `; ppixiv.resources["src/screen_search.js"] = `"use strict"; // The search UI. ppixiv.screen_search = class extends ppixiv.screen { constructor(container) { super(container); this.thumbs_loaded = this.thumbs_loaded.bind(this); this.data_source_updated = this.data_source_updated.bind(this); this.onwheel = this.onwheel.bind(this); // this.onmousemove = this.onmousemove.bind(this); this.refresh_thumbnail = this.refresh_thumbnail.bind(this); this.refresh_images = this.refresh_images.bind(this); this.update_from_settings = this.update_from_settings.bind(this); this.thumbnail_onclick = this.thumbnail_onclick.bind(this); this.submit_user_search = this.submit_user_search.bind(this); this.set_active(false, { }); this.thumbnail_templates = {}; window.addEventListener("thumbnailsLoaded", this.thumbs_loaded); window.addEventListener("focus", this.visible_thumbs_changed); this.container.addEventListener("wheel", this.onwheel, { passive: false }); // this.container.addEventListener("mousemove", this.onmousemove); image_data.singleton().user_modified_callbacks.register(this.refresh_ui.bind(this)); // When a bookmark is modified, refresh the heart icon. image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail); this.thumbnail_dimensions_style = helpers.create_style(""); document.body.appendChild(this.thumbnail_dimensions_style); // Create the avatar widget shown on the artist data source. this.avatar_widget = new avatar_widget({ parent: this.container.querySelector(".avatar-container"), changed_callback: this.data_source_updated, big: true, mode: "dropdown", }); // Create the tag widget used by the search data source. this.tag_widget = new tag_widget({ parent: this.container.querySelector(".related-tag-list"), format_link: function(tag) { // The recommended tag links are already on the search page, and retain other // search settings. let url = page_manager.singleton().get_url_for_tag_search(tag, ppixiv.location); url.searchParams.delete("p"); return url.toString(); }.bind(this), }); // Don't scroll thumbnails when scrolling tag dropdowns. // FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though // they seem like the same thing. this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true); this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true); // Set up hover popups. dropdown_menu_opener.create_handlers(this.container, [".navigation-menu-box", ".thumbnail-settings-menu-box", ".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box", ".search-tags-box"]); // As an optimization, start loading image info on mousedown. We don't navigate until click, // but this lets us start loading image info a bit earlier. this.container.querySelector(".thumbnails").addEventListener("mousedown", async (e) => { if(e.button != 0) return; // Don't do this when viewing followed users, since we'll be loading the user rather than the post. if(this.data_source && this.data_source.search_mode == "users") return; var a = e.target.closest("a.thumbnail-link"); if(a == null) return; if(a.dataset.illustId == null) return; let illust_data = await image_data.singleton().get_image_info(a.dataset.illustId); // This is a bit optimistic, but if we get a result before the user releases the mouse, start // preloading the image. This would be more effective if we had the image URL in thumbnail data. helpers.preload_images([illust_data.urls.original]); }, true); this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search.bind(this)); this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new.bind(this)); this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick); // Clear recent illusts: this.container.querySelector("[data-type='clear-recents']").addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); await ppixiv.recently_seen_illusts.get().clear(); this.refresh_search(); }); var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box"); menu_option.add_settings(settings_menu); settings.register_change_callback("thumbnail-size", () => { // refresh_images first to update thumbnail_dimensions_style. this.refresh_images(); }); settings.register_change_callback("theme", this.update_from_settings); settings.register_change_callback("disable_thumbnail_zooming", this.update_from_settings); settings.register_change_callback("disable_thumbnail_panning", this.update_from_settings); settings.register_change_callback("ui-on-hover", this.update_from_settings); settings.register_change_callback("no-hide-cursor", this.update_from_settings); settings.register_change_callback("no_recent_history", this.update_from_settings); // Create the tag dropdown for the search page input. new tag_search_box_widget(this.container.querySelector(".tag-search-box")); // Create the tag dropdown for the search input in the menu dropdown. new tag_search_box_widget(this.container.querySelector(".navigation-search-box")); // Handle submitting searches on the user search page. this.container.querySelector(".user-search-box .search-submit-button").addEventListener("click", this.submit_user_search); helpers.input_handler(this.container.querySelector(".user-search-box input.search-users"), this.submit_user_search); // Create IntersectionObservers for thumbs that are completely onscreen, nearly onscreen (should // be preloaded), and farther off (but not so far they should be unloaded). this.intersection_observers = []; this.intersection_observers.push(new IntersectionObserver((entries) => { for(let entry of entries) helpers.set_dataset(entry.target.dataset, "fullyOnScreen", entry.isIntersecting); this.load_needed_thumb_data(); this.first_visible_thumbs_changed(); }, { root: this.container, threshold: 1, })); this.intersection_observers.push(new IntersectionObserver((entries) => { for(let entry of entries) helpers.set_dataset(entry.target.dataset, "nearby", entry.isIntersecting); // Set up any thumbs that just came nearby, and see if we need to load more search results. this.set_visible_thumbs(); this.load_needed_thumb_data(); }, { root: this.container, // This margin determines how far in advance we load the next page of results. rootMargin: "50%", })); this.intersection_observers.push(new IntersectionObserver((entries) => { for(let entry of entries) helpers.set_dataset(entry.target.dataset, "visible", entry.isIntersecting); this.visible_thumbs_changed(); }, { root: this.container, rootMargin: "0%", })); /* * Add a slight delay before hiding the UI. This allows opening the UI by swiping past the top * of the window, without it disappearing as soon as the mouse leaves the window. This doesn't * affect opening the UI. * * We're actually handling the manga UI's top-ui-box here too. */ for(let box of document.querySelectorAll(".top-ui-box")) new hover_with_delay(box, 0, 0.25); this.update_from_settings(); this.refresh_images(); this.load_needed_thumb_data(); this.refresh_whats_new_button(); } // This is called as the user scrolls and different thumbs are fully onscreen, // to update the page URL. first_visible_thumbs_changed() { // Find the first thumb that's fully onscreen. let first_thumb = this.container.querySelector(\`.thumbnails > [data-id][data-fully-on-screen]\`); if(!first_thumb) return; // If the data source supports a start page, update the page number in the URL to reflect // the first visible thumb. if(this.data_source == null || !this.data_source.supports_start_page || first_thumb.dataset.page == null) return; main_controller.singleton.temporarily_ignore_onpopstate = true; try { let args = helpers.args.location; this.data_source.set_start_page(args, first_thumb.dataset.page); helpers.set_page_url(args, false, "viewing-page"); } finally { main_controller.singleton.temporarily_ignore_onpopstate = false; } } // The thumbs actually visible onscreen have changed, or the window has gained focus. // Store recently viewed thumbs. visible_thumbs_changed = () => { // Don't add recent illusts if we're viewing recent illusts. if(this.data_source && this.data_source.name == "recent") return; let visible_illust_ids = []; for(let element of this.container.querySelectorAll(\`.thumbnails > [data-id][data-visible]:not([data-special])\`)) { let { type, id } = helpers.parse_id(element.dataset.id); if(type != "illust") continue; visible_illust_ids.push(id); } ppixiv.recently_seen_illusts.get().add_illusts(visible_illust_ids); } refresh_search() { main_controller.singleton.refresh_current_data_source(); } // Set or clear the updates class on the "what's new" button. refresh_whats_new_button() { let last_viewed_version = settings.get("whats-new-last-viewed-version", 0); // This was stored as a string before, since it came from GM_info.script.version. Make // sure it's an integer. last_viewed_version = parseInt(last_viewed_version); let new_updates = last_viewed_version < whats_new.latest_interesting_history_revision(); helpers.set_class(this.container.querySelector(".whats-new-button"), "updates", new_updates); } whats_new() { settings.set("whats-new-last-viewed-version", whats_new.latest_history_revision()); this.refresh_whats_new_button(); new whats_new(document.body.querySelector(".whats-new-box")); } /* This scrolls the thumbnail when you hover over it. It's sort of neat, but it's pretty * choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail, * causing it to pop to a new location. onmousemove(e) { var thumb = e.target.closest(".thumbnail-box a"); if(thumb == null) return; var bounds = thumb.getBoundingClientRect(); var x = e.clientX - bounds.left; var y = e.clientY - bounds.top; x = 100 * x / thumb.offsetWidth; y = 100 * y / thumb.offsetHeight; var img = thumb.querySelector("img.thumb"); img.style.objectPosition = x + "% " + y + "%"; } */ onwheel(e) { // Stop event propagation so we don't change images on any viewer underneath the thumbs. e.stopPropagation(); }; initial_refresh_ui() { if(this.data_source != null) { var ui_box = this.container.querySelector(".thumbnail-ui-box"); this.data_source.initial_refresh_thumbnail_ui(ui_box, this); } } set_data_source(data_source) { if(this.data_source == data_source) return; // Remove listeners from the old data source. if(this.data_source != null) this.data_source.remove_update_listener(this.data_source_updated); // If the search mode is changing (eg. we're going from a list of illustrations to a list // of users), remove thumbs so we recreate them. Otherwise, refresh_images will reuse them // and they can be left on the wrong display type. var old_search_mode = this.data_source? this.data_source.search_mode:""; var new_search_mode = data_source? data_source.search_mode:""; if(old_search_mode != new_search_mode) { var ul = this.container.querySelector(".thumbnails"); while(ul.firstElementChild != null) { let node = ul.firstElementChild; node.remove(); // We should be able to just remove the element and get a callback that it's no longer visible. // This works in Chrome since IntersectionObserver uses a weak ref, but Firefox is stupid and leaks // the node. for(let observer of this.intersection_observers) observer.unobserve(node); } } this.data_source = data_source; if(this.data_source == null) return; // If we disabled loading more pages earlier, reenable it. this.disable_loading_more_pages = false; // Disable the avatar widget unless the data source enables it. this.avatar_widget.visible = false; // Listen to the data source loading new pages, so we can refresh the list. this.data_source.add_update_listener(this.data_source_updated); this.load_needed_thumb_data(); }; restore_scroll_position() { // If we saved a scroll position when navigating away from a data source earlier, // restore it now. Only do this once. if(this.data_source.thumbnail_view_scroll_pos != null) { this.container.scrollTop = this.data_source.thumbnail_view_scroll_pos; delete this.data_source.thumbnail_view_scroll_pos; } else this.scroll_to_top(); } scroll_to_top() { this.container.scrollTop = 0; } refresh_ui() { if(!this.active) return; var element_displaying = this.container.querySelector(".displaying"); element_displaying.hidden = this.data_source.get_displaying_text == null; if(this.data_source.get_displaying_text != null) { // get_displaying_text can either be a string or an element. let text = this.data_source.get_displaying_text(); helpers.remove_elements(element_displaying); if(typeof text == "string") element_displaying.innerText = text; else if(text instanceof HTMLElement) { helpers.remove_elements(element_displaying); element_displaying.appendChild(text); } } // Set up bookmark and following search links. for(let link of this.container.querySelectorAll('.following-users-link[data-which="public"]')) link.href = \`/users/\${window.global_data.user_id}/following#ppixiv\`; for(let link of this.container.querySelectorAll('.following-users-link[data-which="private"]')) link.href = \`/users/\${window.global_data.user_id}/following?rest=hide#ppixiv\`; for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="all"]')) link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv\`; for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="public"]')) link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks#ppixiv?show-all=0\`; for(let link of this.container.querySelectorAll('.bookmarks-link[data-which="private"]')) link.href = \`/users/\${window.global_data.user_id}/bookmarks/artworks?rest=hide#ppixiv?show-all=0\`; helpers.set_page_title(this.data_source.page_title || "Loading..."); var ui_box = this.container.querySelector(".thumbnail-ui-box"); this.data_source.refresh_thumbnail_ui(ui_box, this); this.refresh_ui_for_user_id(); }; // Return the user ID we're viewing, or null if we're not viewing anything specific to a user. get viewing_user_id() { if(this.data_source == null) return null; return this.data_source.viewing_user_id; } // If the data source has an associated artist, return the "user:ID" for the user, so // when we navigate back to an earlier search, pulse_thumbnail will know which user to // flash. get displayed_illust_id() { if(this.data_source == null) return super.displayed_illust_id; let user_id = this.data_source.viewing_user_id; if(user_id != null) return "user:" + user_id; return super.displayed_illust_id; } // Call refresh_ui_for_user_info with the user_info for the user we're viewing, // if the user ID has changed. async refresh_ui_for_user_id() { // If we're viewing ourself (our own bookmarks page), hide the user-related UI. var initial_user_id = this.viewing_user_id; var user_id = initial_user_id == window.global_data.user_id? null:initial_user_id; var user_info = await image_data.singleton().get_user_info_full(user_id); // Stop if the user ID changed since we started this request, or if we're no longer active. if(this.viewing_user_id != initial_user_id || !this.active) return; // Set the bookmarks link. var bookmarks_link = this.container.querySelector(".bookmarks-link"); bookmarks_link.hidden = user_info == null; if(user_info != null) { var bookmarks_url = \`/users/\${user_info.userId}/bookmarks/artworks#ppixiv\`; bookmarks_link.href = bookmarks_url; bookmarks_link.dataset.popup = user_info? (\`View \${user_info.name}'s bookmarks\`):"View bookmarks"; } // Set the similar artists link. var similar_artists_link = this.container.querySelector(".similar-artists-link"); similar_artists_link.hidden = user_info == null; if(user_info) similar_artists_link.href = "/discovery/users#ppixiv?user_id=" + user_info.userId; // Set the following link. var following_link = this.container.querySelector(".following-link"); following_link.hidden = user_info == null; if(user_info != null) { let following_url = "/users/" + user_info.userId + "/following#ppixiv"; following_link.href = following_url; following_link.dataset.popup = user_info? ("View " + user_info.name + "'s followed users"):"View following"; } let extra_links = []; // Set the webpage link. // // If the webpage link is on a known site, disable the webpage link and add this to the // generic links list, so it'll use the specialized icon. var webpage_url = user_info && user_info.webpage; if(webpage_url != null && this.find_link_image_type(webpage_url)) { extra_links.push(webpage_url); webpage_url = null; } var webpage_link = this.container.querySelector(".webpage-link"); webpage_link.hidden = webpage_url == null; if(webpage_url != null) { webpage_link.href = webpage_url; webpage_link.dataset.popup = webpage_url; } // Set the circle.ms link. var circlems_url = user_info && user_info.social && user_info.social.circlems && user_info.social.circlems.url; var circlems_link = this.container.querySelector(".circlems-icon"); circlems_link.hidden = circlems_url == null; if(circlems_url != null) circlems_link.href = circlems_url; // Set the twitter link. var twitter_url = user_info && user_info.social && user_info.social.twitter && user_info.social.twitter.url; var twitter_link = this.container.querySelector(".twitter-icon"); twitter_link.hidden = twitter_url == null; if(twitter_url != null) { twitter_link.href = twitter_url; var path = new URL(twitter_url).pathname; var parts = path.split("/"); twitter_link.dataset.popup = parts.length > 1? ("@" + parts[1]):"Twitter"; } // Set the pawoo link. var pawoo_url = user_info && user_info.social && user_info.social.pawoo && user_info.social.pawoo.url; var pawoo_link = this.container.querySelector(".pawoo-icon"); pawoo_link.hidden = pawoo_url == null; if(pawoo_url != null) pawoo_link.href = pawoo_url; // Set the "send a message" link. var contact_link = this.container.querySelector(".contact-link"); contact_link.hidden = user_info == null; if(user_info != null) contact_link.href = "/messages.php?receiver_id=" + user_info.userId; // Remove any extra buttons that we added earlier. let row = this.container.querySelector(".button-row"); for(let div of row.querySelectorAll(".extra-profile-link-button")) div.remove(); // Find any other links in the user's profile text. if(user_info != null) { let div = document.createElement("div"); div.innerHTML = user_info.commentHtml; for(let link of div.querySelectorAll("a")) extra_links.push(helpers.fix_pixiv_link(link.href)); } // Let the data source add more links. if(this.data_source != null) this.data_source.add_extra_links(extra_links); let count = 0; for(let url of extra_links) { url = new URL(url); let entry = helpers.create_from_template(".template-extra-profile-link-button"); let a = entry.querySelector(".extra-link"); a.href = url; a.dataset.popup = a.href; let link_type = this.find_link_image_type(url); if(link_type != null) { entry.querySelector(".default-icon").hidden = true; entry.querySelector(link_type).hidden = false; } // Put these at the beginning, so they don't change the positioning of the other // icons. row.insertBefore(entry, row.querySelector(".first-icon")); count++; // Limit this in case people are putting a million links in their profiles. if(count == 4) break; } // Tell the context menu which user is being viewed (if we're viewing a user-specific // search). main_context_menu.get.user_id = user_id; } // Use different icons for sites where you can give the artist money. This helps make // the string of icons more meaningful (some artists have a lot of them). find_link_image_type(url) { url = new URL(url); let alt_icons = { ".shopping-cart": [ "dlsite.com", "fanbox.cc", "fantia.jp", "skeb.jp", "ko-fi.com", "dmm.co.jp", ] }; // Special case for old Fanbox URLs that were under the Pixiv domain. if((url.hostname == "pixiv.net" || url.hostname == "www.pixiv.net") && url.pathname.startsWith("/fanbox/")) return ".shopping-cart"; for(let alt in alt_icons) { // "domain.com" matches domain.com and *.domain.com. for(let domain of alt_icons[alt]) { if(url.hostname == domain) return alt; if(url.hostname.endsWith("." + domain)) return alt; } } return null; }; async set_active(active, { data_source }) { if(this._active == active && this.data_source == data_source) return; let was_active = this._active; this._active = active; // We're either becoming active or inactive, or our data source is being changed. // Store our scroll position on the data source, so we can restore it if it's // reactivated. There's only one instance of thumbnail_view, so this is safe. // Only do this if we were previously active, or we're hidden and scrollTop may // be 0. if(was_active && this.data_source) this.data_source.thumbnail_view_scroll_pos = this.container.scrollTop; await super.set_active(active); if(active) { this.set_data_source(data_source); this.initial_refresh_ui(); this.refresh_ui(); // Refresh the images now, so it's possible to scroll to entries, but wait to start // loading data to give the caller a chance to call scroll_to_illust_id(), which needs // to happen after refresh_images but before load_needed_thumb_data. This way, if // we're showing a page far from the top, we won't load the first page that we're about // to scroll away from. this.refresh_images(); helpers.yield(() => { this.load_needed_thumb_data(); }); } else { this.stop_pulsing_thumbnail(); main_context_menu.get.user_id = null; } } get active() { return this._active; } data_source_updated() { this.refresh_images(); this.load_needed_thumb_data(); this.refresh_ui(); } // Recreate thumbnail images (the actual elements). // // This is done when new pages are loaded, to create the correct number of images. // We don't need to do this when scrolling around or when new thumbnail data is available. refresh_images() { // Make a list of [illust_id, page] thumbs to add. let images_to_add = []; if(this.data_source != null) { let id_list = this.data_source.id_list; let min_page = id_list.get_lowest_loaded_page(); let max_page = id_list.get_highest_loaded_page(); let items_per_page = this.data_source.estimated_items_per_page; for(let page = min_page; page <= max_page; ++page) { let illust_ids = id_list.illust_ids_by_page.get(page); if(illust_ids == null) { // This page isn't loaded. Fill the gap with items_per_page blank entries. for(let idx = 0; idx < items_per_page; ++idx) images_to_add.push([null, page]); continue; } // Create an image for each ID. for(let illust_id of illust_ids) images_to_add.push({id: illust_id, page: page}); } // If this data source supports a start page and we started after page 1, add the "load more" // button at the beginning. // // The page number for this button is the same as the thumbs that follow it, not the // page it'll load if clicked, so scrolling to it doesn't make us think we're scrolled // to that page. if(this.data_source.initial_page > 1) images_to_add.splice(0, 0, { id: "special:previous-page", page: this.data_source.initial_page }); } // Add thumbs. // // Most of the time we're just adding thumbs to the list. Avoid removing or recreating // thumbs that aren't actually changing, which reduces flicker. // // Do this by looking for a range of thumbnails that matches a range in images_to_add. // If we're going to display [0,1,2,3,4,5,6,7,8,9], and the current thumbs are [4,5,6], // then 4,5,6 matches and can be reused. We'll add [0,1,2,3] to the beginning and [7,8,9] // to the end. // // Most of the time we're just appending. The main time that we add to the beginning is // the "load previous results" button. let ul = this.container.querySelector(".thumbnails"); let next_node = ul.firstElementChild; // Make a dictionary of all illust IDs and pages, so we can look them up quickly. let images_to_add_index = {}; for(let i = 0; i < images_to_add.length; ++i) { let entry = images_to_add[i]; let illust_id = entry.id; let page = entry.page; let index = illust_id + "/" + page; images_to_add_index[index] = i; } let get_node_idx = function(node) { if(node == null) return null; let illust_id = node.dataset.id; let page = node.dataset.page; let index = illust_id + "/" + page; return images_to_add_index[index]; } // Find the first match (4 in the above example). let first_matching_node = next_node; while(first_matching_node && get_node_idx(first_matching_node) == null) first_matching_node = first_matching_node.nextElementSibling; // If we have a first_matching_node, walk forward to find the last matching node (6 in // the above example). let last_matching_node = first_matching_node; if(last_matching_node != null) { // Make sure the range is contiguous. first_matching_node and all nodes through last_matching_node // should match a range exactly. If there are any missing entries, stop. let next_expected_idx = get_node_idx(last_matching_node) + 1; while(last_matching_node && get_node_idx(last_matching_node.nextElementSibling) == next_expected_idx) { last_matching_node = last_matching_node.nextElementSibling; next_expected_idx++; } } // If we have a matching range, save the scroll position relative to it, so if we add // new elements at the top, we stay scrolled where we are. Otherwise, just restore the // current scroll position. let save_scroll = new SaveScrollPosition(this.container); if(first_matching_node) save_scroll.save_relative_to(first_matching_node); // If we have a range, delete all items outside of it. Otherwise, just delete everything. while(first_matching_node && first_matching_node.previousElementSibling) first_matching_node.previousElementSibling.remove(); while(last_matching_node && last_matching_node.nextElementSibling) last_matching_node.nextElementSibling.remove(); if(!first_matching_node && !last_matching_node) helpers.remove_elements(ul); // If we have a matching range, add any new elements before it. if(first_matching_node) { let first_idx = get_node_idx(first_matching_node); for(let idx = first_idx - 1; idx >= 0; --idx) { let entry = images_to_add[idx]; let illust_id = entry.id; let page = entry.page; let node = this.create_thumb(illust_id, page); first_matching_node.insertAdjacentElement("beforebegin", node); first_matching_node = node; } } // Add any new elements after the range. If we don't have a range, just add everything. let last_idx = -1; if(last_matching_node) last_idx = get_node_idx(last_matching_node); for(let idx = last_idx + 1; idx < images_to_add.length; ++idx) { let entry = images_to_add[idx]; let illust_id = entry.id; let page = entry.page; let node = this.create_thumb(illust_id, page); ul.appendChild(node); } if(this.container.offsetWidth == 0) return; let thumbnail_size = settings.get("thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".screen-search-container", { wide: true, size: thumbnail_size, max_columns: 5, // Set a minimum padding to make sure there's room for the popup text to fit between images. min_padding: 15, }); // Restore the value of scrollTop from before we updated. For some reason, Firefox // modifies scrollTop after we add a bunch of items, which causes us to scroll to // the wrong position, even though scrollRestoration is disabled. save_scroll.restore(); } // Start loading data pages that we need to display visible thumbs, and start // loading thumbnail data for nearby thumbs. async load_needed_thumb_data() { // elements is a list of elements that are onscreen (or close to being onscreen). // We want thumbnails loaded for these, even if we need to load more thumbnail data. // // nearby_elements is a list of elements that are a bit further out. If we load // thumbnail data for elements, we'll load these instead. That way, if we scroll // up a bit and two more thumbs become visible, we'll load a bigger chunk. // That way, we make fewer batch requests instead of requesting two or three // thumbs at a time. // Make a list of pages that we need loaded, and illustrations that we want to have // set. var wanted_illust_ids = []; let elements = this.get_visible_thumbnails(); for(var element of elements) { if(element.dataset.id != null) { // If this is an illustration, add it to wanted_illust_ids so we load its thumbnail // info. Don't do this if it's a user. if(helpers.parse_id(element.dataset.id).type == "illust") wanted_illust_ids.push(element.dataset.id); } } // We load pages when the last thumbs on the previous page are loaded, but the first // time through there's no previous page to reach the end of. Always make sure the // first page is loaded (usually page 1). let load_page = null; let first_page = this.data_source? this.data_source.initial_page:1; if(this.data_source && !this.data_source.is_page_loaded_or_loading(first_page)) load_page = first_page; // If the last thumb in the list is being loaded, we need the next page to continue. // Note that since get_visible_thumbnails returns thumbs before they actually scroll // into view, this will happen before the last thumb is actually visible to the user. var ul = this.container.querySelector(".thumbnails"); if(load_page == null && elements.length > 0 && elements[elements.length-1] == ul.lastElementChild) { let last_element = elements[elements.length-1]; load_page = parseInt(last_element.dataset.page)+1; } // Hide "no results" if it's shown while we load data. this.container.querySelector(".no-results").hidden = true; if(load_page != null) { var result = await this.data_source.load_page(load_page, { cause: "thumbnails" }); // If this page didn't load, it probably means we've reached the end, so stop trying // to load more pages. if(!result) this.disable_loading_more_pages = true; } // If we have no IDs and nothing is loading, the data source is empty (no results). if(this.data_source && this.data_source.id_list.get_first_id() == null && !this.data_source.any_page_loading) { console.log("Showing no results"); this.container.querySelector(".no-results").hidden = false; } if(!thumbnail_data.singleton().are_all_ids_loaded_or_loading(wanted_illust_ids)) { // At least one visible thumbnail needs to be loaded, so load more data at the same // time. let nearby_illust_ids = this.get_thumbs_to_load(); // Load the thumbnail data if needed. // // Loading thumbnail info here very rarely happens anymore, since every data // source provides thumbnail info with its illust IDs. thumbnail_data.singleton().get_thumbnail_info(nearby_illust_ids); } this.set_visible_thumbs(); } // Handle clicks on the "load previous results" button. // // If we let the regular click handling in main_controller.set_current_data_source do this, // it'll notice that the requested page isn't loaded and create a new data source. We know // we can view the previous page, so special case this so we don't lose the pages that are // already loaded. // // This can also trigger for the "return to start" button if we happen to be on page 2. async thumbnail_onclick(e) { // This only matters if the data source supports start pages. if(!this.data_source.supports_start_page) return; let a = e.target.closest("A"); if(a == null) return; // Don't do this for the "return to start" button. That page does link to the previous // page, but that button should always refresh so we scroll to the top, and not just add // the previous page above where we are like this does. if(a.classList.contains("load-first-page-link")) return; if(a.classList.contains("load-previous-page-link")) { let page = this.data_source.id_list.get_lowest_loaded_page() - 1; this.load_page(page); e.preventDefault(); e.stopImmediatePropagation(); } } // See if we can load page in-place. Return true if we were able to, and the click that // requested it should be cancelled, or false if we can't and it should be handled as a // regular navigation. async load_page(page) { // We can only add pages that are immediately before or after the pages we currently have. let min_page = this.data_source.id_list.get_lowest_loaded_page(); let max_page = this.data_source.id_list.get_highest_loaded_page(); if(page < min_page-1) return false; if(page > max_page+1) return false; console.log("Loading page:", page); await this.data_source.load_page(page, { cause: "previous page" }); return true; } update_from_settings() { var thumbnail_mode = settings.get("thumbnail-size"); this.set_visible_thumbs(); this.refresh_images(); helpers.set_class(document.body, "light", settings.get("theme") == "light"); helpers.set_class(document.body, "disable-thumbnail-panning", settings.get("disable_thumbnail_panning")); helpers.set_class(document.body, "disable-thumbnail-zooming", settings.get("disable_thumbnail_zooming")); helpers.set_class(document.body, "ui-on-hover", settings.get("ui-on-hover")); helpers.set_class(this.container.querySelector(".recent-history-link"), "disabled", !ppixiv.recently_seen_illusts.get().enabled); // Flush the top UI transition, so it doesn't animate weirdly when toggling ui-on-hover. for(let box of document.querySelectorAll(".top-ui-box")) { box.classList.add("disable-transition"); box.offsetHeight; box.classList.remove("disable-transition"); } } // Set the URL for all loaded thumbnails that are onscreen. // // This won't trigger loading any data (other than the thumbnails themselves). set_visible_thumbs() { // Make a list of IDs that we're assigning. var elements = this.get_visible_thumbnails(); var illust_ids = []; for(var element of elements) { if(element.dataset.id == null) continue; illust_ids.push(element.dataset.id); } for(var element of elements) { var illust_id = element.dataset.id; if(illust_id == null) continue; var search_mode = this.data_source.search_mode; let { id: thumb_id, type: thumb_type } = helpers.parse_id(illust_id); let thumb_data = {}; // For illustrations, get thumbnail info. If we don't have it yet, skip the image (leave it pending) // and we'll come back once we have it. if(thumb_type == "illust") { // Get thumbnail info. var info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(info == null) continue; } // Leave it alone if it's already been loaded. if(!("pending" in element.dataset)) continue; // Why is this not working in FF? It works in the console, but not here. Sandboxing // issue? // delete element.dataset.pending; element.removeAttribute("data-pending"); if(thumb_type == "user" || thumb_type == "bookmarks") { // This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset // of info. let user_id = thumb_id; var link = element.querySelector("a.thumbnail-link"); if(thumb_type == "user") link.href = \`/users/\${user_id}/artworks#ppixiv\`; else link.href = \`/users/\${user_id}/bookmarks/artworks#ppixiv\`; link.dataset.userId = user_id; let quick_user_data = thumbnail_data.singleton().get_quick_user_data(user_id); if(quick_user_data == null) { // We should always have this data for users if the data source asked us to display this user. throw "Missing quick user data for user ID " + user_id; } var thumb = element.querySelector(".thumb"); thumb.src = quick_user_data.profileImageUrl; var label = element.querySelector(".thumbnail-label"); label.hidden = false; label.querySelector(".label").innerText = quick_user_data.userName; // Point the "similar illustrations" thumbnail button to similar users for this result, so you can // chain from one set of suggested users to another. element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + user_id; continue; } if(thumb_type != "illust") throw "Unexpected thumb type: " + thumb_type; // Set this thumb. var url = info.url; var thumb = element.querySelector(".thumb"); // Check if this illustration is muted (blocked). var muted_tag = muting.singleton.any_tag_muted(info.tags); var muted_user = muting.singleton.is_muted_user_id(info.userId); if(muted_tag || muted_user) { element.classList.add("muted"); // The image will be obscured, but we still shouldn't load the image the user blocked (which // is something Pixiv does wrong). Load the user profile image instead. thumb.src = info.profileImageUrl; let muted_label = element.querySelector(".muted-label"); // Quick hack to look up translations, since we're not async: (async() => { if(muted_tag) muted_tag = await tag_translations.get().get_translation(muted_tag); muted_label.textContent = muted_tag? muted_tag:info.userName; })(); // We can use this if we want a "show anyway' UI. thumb.dataset.mutedUrl = url; } else { thumb.src = url; // The search page thumbs are always square (aspect ratio 1). helpers.set_thumbnail_panning_direction(element, info.width, info.height, 1); } // Set the link. Setting dataset.illustId will allow this to be handled with in-page // navigation, and the href will allow middle click, etc. to work normally. // // If we're on the followed users page, set these to the artist page instead. var link = element.querySelector("a.thumbnail-link"); if(search_mode == "users") { link.href = "/users/" + info.userId + "#ppixiv"; } else { link.href = "/artworks/" + illust_id + "#ppixiv"; } link.dataset.illustId = illust_id; link.dataset.userId = info.userId; // Don't show this UI when we're in the followed users view. if(search_mode == "illusts") { if(info.illustType == 2) element.querySelector(".ugoira-icon").hidden = false; if(info.pageCount > 1) { var pageCountBox = element.querySelector(".page-count-box"); pageCountBox.hidden = false; pageCountBox.href = link.href + "?view=manga"; element.querySelector(".page-count-box .page-count").textContent = info.pageCount; } } helpers.set_class(element, "dot", helpers.tags_contain_dot(info)); // On most pages, the suggestions button in thumbnails shows similar illustrations. On following, // show similar artists instead. if(search_mode == "users") element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + info.userId; else element.querySelector("A.similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1"; this.refresh_bookmark_icon(element); // Set the label. This is only actually shown in following views. var label = element.querySelector(".thumbnail-label"); if(search_mode == "users") { label.hidden = false; label.querySelector(".label").innerText = info.userName; } else { label.hidden = true; } } if(this.data_source != null) { // Set the link for the first page and previous page buttons. Most of the time this is handled // by our in-page click handler. let page = this.data_source.get_start_page(helpers.args.location); let previous_page_link = this.container.querySelector("a.load-previous-page-link"); if(previous_page_link) { let args = helpers.args.location; this.data_source.set_start_page(args, page-1); previous_page_link.href = args.url; } let first_page_link = this.container.querySelector("a.load-first-page-link"); if(first_page_link) { let args = helpers.args.location; this.data_source.set_start_page(args, 1); first_page_link.href = args.url; } } } // Refresh the thumbnail for illust_id. // // This is used to refresh the bookmark icon when changing a bookmark. refresh_thumbnail(illust_id) { var ul = this.container.querySelector(".thumbnails"); var thumbnail_element = ul.querySelector("[data-id=\\"" + illust_id + "\\"]"); if(thumbnail_element == null) return; this.refresh_bookmark_icon(thumbnail_element); } // Set the bookmarked heart for thumbnail_element. This can change if the user bookmarks // or un-bookmarks an image. refresh_bookmark_icon(thumbnail_element) { if(this.data_source && this.data_source.search_mode == "users") return; var illust_id = thumbnail_element.dataset.id; if(illust_id == null) return; // Get thumbnail info. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info == null) return; var show_bookmark_heart = thumbnail_info.bookmarkData != null; if(this.data_source != null && !this.data_source.show_bookmark_icons) show_bookmark_heart = false; thumbnail_element.querySelector(".heart.public").hidden = !show_bookmark_heart || thumbnail_info.bookmarkData.private; thumbnail_element.querySelector(".heart.private").hidden = !show_bookmark_heart || !thumbnail_info.bookmarkData.private; } // Return a list of thumbnails that are either visible, or close to being visible // (so we load thumbs before they actually come on screen). get_visible_thumbnails() { // If the container has a zero height, that means we're hidden and we don't want to load // thumbnail data at all. if(this.container.offsetHeight == 0) return []; // Don't include data-special, which are non-thumb entries like "load previous results". return this.container.querySelectorAll(\`.thumbnails > [data-id][data-nearby]:not([data-special])\`); } // Get a given number of thumb that should be loaded, starting with thumbs that are onscreen // and working outwards until we have enough. get_thumbs_to_load(count=100) { // If the container has a zero height, that means we're hidden and we don't want to load // thumbnail data at all. if(this.container.offsetHeight == 0) return []; let results = []; let add_element = (element) => { if(element == null) return; if(element.dataset.id == null) return; let { type, id } = helpers.parse_id(element.dataset.id); if(type != "illust") return; // Skip this thumb if it's already loading. if(thumbnail_data.singleton().is_id_loaded_or_loading(id)) return; results.push(id); } let onscreen_thumbs = this.container.querySelectorAll(\`.thumbnails > [data-id][data-fully-on-screen]\`); if(onscreen_thumbs.length == 0) return []; // First, add all thumbs that are onscreen, so these are prioritized. for(let thumb of onscreen_thumbs) add_element(thumb); // Walk forwards and backwards around the initial results. let forwards = onscreen_thumbs[onscreen_thumbs.length-1]; let backwards = onscreen_thumbs[0]; while(forwards || backwards) { if(results.length >= count) break; if(forwards) forwards = forwards.nextElementSibling; if(backwards) backwards = backwards.previousElementSibling; add_element(forwards); add_element(backwards); } return results; } // Create a thumb placeholder. This doesn't load the image yet. // // illust_id is the illustration this will be if it's displayed, or null if this // is a placeholder for pages we haven't loaded. page is the page this illustration // is on (whether it's a placeholder or not). create_thumb(illust_id, page) { let template_type = ".template-thumbnail"; if(illust_id == "special:previous-page") template_type = ".template-load-previous-results"; // Cache a reference to the thumbnail template. We can do this a lot, and this // query takes a lot of page setup time if we run it for each thumb. if(this.thumbnail_templates[template_type] == null) this.thumbnail_templates[template_type] = helpers.get_template(template_type); let entry = helpers.create_from_template(this.thumbnail_templates[template_type]); // If this is a non-thumb entry, mark it so we ignore it for "nearby thumb" handling, etc. if(illust_id == "special:previous-page") entry.dataset.special = 1; // Mark that this thumb hasn't been filled in yet. entry.dataset.pending = true; if(illust_id != null) entry.dataset.id = illust_id; entry.dataset.page = page; for(let observer of this.intersection_observers) observer.observe(entry); return entry; } // This is called when thumbnail_data has loaded more thumbnail info. thumbs_loaded(e) { this.set_visible_thumbs(); } // Scroll to illust_id if it's available. This is called when we display the thumbnail view // after coming from an illustration. scroll_to_illust_id(illust_id) { var thumb = this.container.querySelector("[data-id='" + illust_id + "']"); if(thumb == null) return; // If the item isn't visible, center it. var scroll_pos = this.container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight) this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2; }; pulse_thumbnail(illust_id) { var thumb = this.container.querySelector("[data-id='" + illust_id + "']"); if(thumb == null) return; this.stop_pulsing_thumbnail(); this.flashing_image = thumb; thumb.classList.add("flash"); }; // Work around a bug in CSS animations: even if animation-iteration-count is 1, // the animation will play again if the element is hidden and displayed again, which // causes previously-flashed thumbnails to flash every time we exit and reenter // thumbnails. stop_pulsing_thumbnail() { if(this.flashing_image == null) return; this.flashing_image.classList.remove("flash"); this.flashing_image = null; }; // Handle submitting searches on the user search page. submit_user_search(e) { let search = this.container.querySelector(".user-search-box input.search-users").value; let url = new URL("/search_user.php#ppixiv", ppixiv.location); url.searchParams.append("nick", search); url.searchParams.append("s_mode", "s_usr"); helpers.set_page_url(url, true); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_search.js `; ppixiv.resources["src/screen_manga.js"] = `"use strict"; // A full page viewer for manga thumbnails. // // This is similar to the main search view. It doesn't share code, since it // works differently enough that it would complicate things too much. ppixiv.screen_manga = class extends ppixiv.screen { constructor(container) { super(container); this.refresh_ui = this.refresh_ui.bind(this); this.window_onresize = this.window_onresize.bind(this); this.refresh_count = 0; window.addEventListener("resize", this.window_onresize); // If the "view muted image" button is clicked, add view-muted to the URL. this.container.querySelector(".view-muted-image").addEventListener("click", (e) => { let args = helpers.args.location; args.hash.set("view-muted", "1"); helpers.set_page_url(args, false /* add_to_history */, "override-mute"); }); this.progress_bar = main_controller.singleton.progress_bar; this.ui = new image_ui(this.container.querySelector(".ui-container"), this.progress_bar); this.scroll_positions_by_illust_id = {}; image_data.singleton().user_modified_callbacks.register(this.refresh_ui); image_data.singleton().illust_modified_callbacks.register(this.refresh_ui); settings.register_change_callback("manga-thumbnail-size", this.refresh_ui); // Create a style for our thumbnail style. this.thumbnail_dimensions_style = helpers.create_style(""); document.body.appendChild(this.thumbnail_dimensions_style); this.set_active(false, { }); } window_onresize(e) { if(!this._active) return; this.refresh_ui(); } async set_active(active, { illust_id }) { if(this.illust_id != illust_id) { // The load itself is async and might not happen immediately if we don't have page info yet. // Clear any previous image list so it doesn't flash on screen while we load the new info. let ul = this.container.querySelector(".thumbnails"); helpers.remove_elements(ul); this.illust_id = illust_id; this.illust_info = null; this.ui.illust_id = illust_id; // Refresh even if illust_id is null, so we quickly clear the screen. await this.refresh_ui(); } if(this._active && !active) { // Save the old scroll position. if(this.illust_id != null) { console.log("save scroll position for", this.illust_id, this.container.scrollTop); this.scroll_positions_by_illust_id[this.illust_id] = this.container.scrollTop; } // Hide the dropdown tag widget. this.ui.bookmark_tag_widget.visible = false; // Stop showing the user in the context menu. main_context_menu.get.user_id = null; } this._active = active; // This will hide or unhide us. await super.set_active(active); if(!active || this.illust_id == null) return; // The rest of the load happens async. Although we're already in an async // function, it should return without waiting for API requests. this.async_set_image(); } async async_set_image() { console.log("Loading manga screen for:", this.illust_id); // Load image info. var illust_info = await image_data.singleton().get_image_info(this.illust_id); if(illust_info.id != this.illust_id) return; this.illust_info = illust_info; await this.refresh_ui(); } get view_muted() { return helpers.args.location.hash.get("view-muted") == "1"; } should_hide_muted_image() { let muted_tag = muting.singleton.any_tag_muted(image_data.from_tag_list(this.illust_info.tags)); let muted_user = muting.singleton.is_muted_user_id(this.illust_info.userId); if(this.view_muted || (!muted_tag && !muted_user)) return { is_muted: false }; return { is_muted: true, muted_tag: muted_tag, muted_user: muted_user }; } update_mute() { // Check if this post is muted. let { is_muted, muted_tag, muted_user } = this.should_hide_muted_image(); this.hiding_muted_image = this.view_muted; this.container.querySelector(".muted-text").hidden = !is_muted; if(!is_muted) return false; let muted_label = this.container.querySelector(".muted-label"); if(muted_tag) tag_translations.get().set_translated_tag(muted_label, muted_tag); else if(muted_user) muted_label.innerText = this.illust_info.userName; return true; } refresh_ui = async () => { if(!this._active) return; helpers.set_title_and_icon(this.illust_info); var original_scroll_top = this.container.scrollTop; var ul = this.container.querySelector(".thumbnails"); helpers.remove_elements(ul); if(this.illust_info == null) return; // Tell the context menu which user is being viewed. main_context_menu.get.user_id = this.illust_info.userId; if(this.update_mute()) return; // Get the aspect ratio to crop images to. var ratio = this.get_display_aspect_ratio(this.illust_info.mangaPages); let thumbnail_size = settings.get("manga-thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".screen-manga-container", { wide: true, size: thumbnail_size, ratio: ratio, // We preload this page anyway since it doesn't cause a lot of API calls, so we // can allow a high column count and just let the size take over. max_columns: 15, }); for(var page = 0; page < this.illust_info.mangaPages.length; ++page) { var manga_page = this.illust_info.mangaPages[page]; var entry = this.create_thumb(page, manga_page); var link = entry.querySelector(".thumbnail-link"); helpers.set_thumbnail_panning_direction(entry, manga_page.width, manga_page.height, ratio); ul.appendChild(entry); } // Restore the value of scrollTop from before we updated. For some reason, Firefox // modifies scrollTop after we add a bunch of items, which causes us to scroll to // the wrong position, even though scrollRestoration is disabled. this.container.scrollTop = original_scroll_top; } get active() { return this._active; } get displayed_illust_id() { return this.illust_id; } // Navigating out goes back to the search. get navigate_out_target() { return "search"; } // Given a list of manga infos, return the aspect ratio we'll crop them to. get_display_aspect_ratio(manga_info) { // A lot of manga posts use the same resolution for all images, or just have // one or two exceptions for things like title pages. If most images have // about the same aspect ratio, use it. var total = 0; for(var manga_page of manga_info) total += manga_page.width / manga_page.height; var average_aspect_ratio = total / manga_info.length; var illusts_far_from_average = 0; for(var manga_page of manga_info) { var ratio = manga_page.width / manga_page.height; if(Math.abs(average_aspect_ratio - ratio) > 0.1) illusts_far_from_average++; } // If we didn't find a common aspect ratio, just use square thumbs. if(illusts_far_from_average > 3) return 1; else return average_aspect_ratio; } get_display_resolution(width, height) { var fit_width = 300; var fit_height = 300; var ratio = width / fit_width; if(ratio > 1) { height /= ratio; width /= ratio; } var ratio = height / fit_height; if(ratio > 1) { height /= ratio; width /= ratio; } return [width, height]; } create_thumb(page_idx, manga_page) { if(this.thumbnail_template == null) this.thumbnail_template = document.body.querySelector(".template-manga-view-thumbnail"); var element = helpers.create_from_template(this.thumbnail_template); // These URLs should be the 540x540_70 master version, which is a non-squared high-res // thumbnail. These tend to be around 30-40k, so loading a full manga set of them is // quick. // // XXX: switch this to 540x540_10_webp in Chrome, around 5k? var thumb = element.querySelector(".thumb"); var url = manga_page.urls.small; // url = url.replace("/540x540_70/", "/540x540_10_webp/"); thumb.src = url; var size = this.get_display_resolution(manga_page.width, manga_page.height); thumb.width = size[0]; thumb.height = size[1]; var link = element.querySelector("a.thumbnail-link"); link.href = "/artworks/" + this.illust_id + "#ppixiv?page=" + (page_idx+1); link.dataset.illustId = this.illust_id; link.dataset.pageIdx = page_idx; // We don't use intersection checking for the manga view right now. Mark entries // with all of the "image onscreen" tags. element.dataset.nearby = true; element.dataset.fartherAway = true; element.dataset.fullyOnScreen = true; element.dataset.pageIdx = page_idx; return element; } scroll_to_top() { // Read offsetHeight to force layout to happen. If we don't do this, setting scrollTop // sometimes has no effect in Firefox. this.container.offsetHeight; this.container.scrollTop = 0; console.log("scroll to top", this.container.scrollTop, this.container.hidden, this.container.offsetHeight); } restore_scroll_position() { // If we saved a scroll position when navigating away from a data source earlier, // restore it now. Only do this once. var scroll_pos = this.scroll_positions_by_illust_id[this.illust_id]; if(scroll_pos != null) { console.log("scroll pos:", scroll_pos); this.container.scrollTop = scroll_pos; delete this.scroll_positions_by_illust_id[this.illust_id]; } else this.scroll_to_top(); } scroll_to_illust_id(illust_id, manga_page) { if(manga_page == null) return; var thumb = this.container.querySelector('[data-page-idx="' + manga_page + '"]'); if(thumb == null) return; console.log("Scrolling to", thumb); // If the item isn't visible, center it. var scroll_pos = this.container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight) this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2; } handle_onkeydown(e) { this.ui.handle_onkeydown(e); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/screen_manga.js `; ppixiv.resources["src/image_ui.js"] = `"use strict"; // This handles the overlay UI on the illustration page. ppixiv.image_ui = class { constructor(container, progress_bar) { this.clicked_download = this.clicked_download.bind(this); this.refresh = this.refresh.bind(this); this.container = container; this.progress_bar = progress_bar; this.ui = helpers.create_from_template(".template-image-ui"); this.container.appendChild(this.ui); this.avatar_widget = new avatar_widget({ parent: this.container.querySelector(".avatar-popup"), mode: "dropdown", }); this.tag_widget = new tag_widget({ parent: this.container.querySelector(".tag-list"), }); // Set up hover popups. dropdown_menu_opener.create_handlers(this.container, [".image-settings-menu-box"]); image_data.singleton().illust_modified_callbacks.register(this.refresh); this.bookmark_tag_widget = new bookmark_tag_list_widget(this.container.querySelector(".popup-bookmark-tag-dropdown-container")); this.toggle_tag_widget = new toggle_bookmark_tag_list_widget(this.container.querySelector(".button-bookmark-tags"), this.bookmark_tag_widget); this.like_button = new like_button_widget(this.container.querySelector(".button-like")); // The bookmark buttons, and clicks in the tag dropdown: this.bookmark_buttons = []; for(var a of this.container.querySelectorAll(".button-bookmark")) this.bookmark_buttons.push(new bookmark_button_widget(a, a.classList.contains("private"), this.bookmark_tag_widget)); for(let button of this.container.querySelectorAll(".download-button")) button.addEventListener("click", this.clicked_download); this.container.querySelector(".download-manga-button").addEventListener("click", this.clicked_download); this.container.querySelector(".navigate-out-button").addEventListener("click", function(e) { main_controller.singleton.navigate_out(); }.bind(this)); var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box"); menu_option.add_settings(settings_menu); } set visible(value) { this.avatar_widget.visible = value; } set data_source(data_source) { if(this._data_source == data_source) return; this._data_source = data_source; this.refresh(); } shutdown() { image_data.singleton().illust_modified_callbacks.unregister(this.refresh); this.avatar_widget.shutdown(); } get illust_id() { return this._illust_id; } set illust_id(illust_id) { if(this._illust_id == illust_id) return; this._illust_id = illust_id; this.illust_data = null; this.like_button.illust_id = illust_id; this.bookmark_tag_widget.illust_id = illust_id; this.toggle_tag_widget.illust_id = illust_id; for(let button of this.bookmark_buttons) button.illust_id = illust_id; if(illust_id == null) { this.refresh(); return; } image_data.singleton().get_image_info(illust_id).then((illust_info) => { if(illust_info.illustId != this._illust_id) return; this.illust_data = illust_info; this.refresh(); }); } handle_onkeydown(e) { } refresh() { if(this.illust_data == null) return; var illust_data = this.illust_data; var illust_id = illust_data.illustId; let user_id = illust_data.userId; // Show the author if it's someone else's post, or the edit link if it's ours. var our_post = global_data.user_id == user_id; this.container.querySelector(".author-block").hidden = our_post; this.container.querySelector(".edit-post").hidden = !our_post; this.container.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illust_id; this.avatar_widget.set_user_id(user_id); this.tag_widget.set(illust_data.tags); var element_title = this.container.querySelector(".title"); element_title.textContent = illust_data.illustTitle; element_title.href = "/artworks/" + illust_id + "#ppixiv"; var element_author = this.container.querySelector(".author"); element_author.textContent = illust_data.userName; element_author.href = \`/users/\${user_id}#ppixiv\`; this.container.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1"; this.container.querySelector(".similar-artists-button").href = "/discovery/users#ppixiv?user_id=" + user_id; this.container.querySelector(".similar-bookmarks-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv"; // Fill in the post info text. this.set_post_info(this.container.querySelector(".post-info")); // The comment (description) can contain HTML. var element_comment = this.container.querySelector(".description"); element_comment.hidden = illust_data.illustComment == ""; element_comment.innerHTML = illust_data.illustComment; helpers.fix_pixiv_links(element_comment); helpers.make_pixiv_links_internal(element_comment); // Set the download button popup text. if(this.illust_data != null) { let download_image_button = this.container.querySelector(".download-image-button"); download_image_button.hidden = !actions.is_download_type_available("image", this.illust_data); let download_manga_button = this.container.querySelector(".download-manga-button"); download_manga_button.hidden = !actions.is_download_type_available("ZIP", this.illust_data); let download_video_button = this.container.querySelector(".download-video-button"); download_video_button.hidden = !actions.is_download_type_available("MKV", this.illust_data); } // Set the popup for the thumbnails button. var navigate_out_label = main_controller.singleton.navigate_out_label; var title = navigate_out_label != null? ("Return to " + navigate_out_label):""; this.container.querySelector(".navigate-out-button").dataset.popup = title; } set_post_info(post_info_container) { var illust_data = this.illust_data; var set_info = (query, text) => { var node = post_info_container.querySelector(query); node.innerText = text; node.hidden = text == ""; }; var seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000; set_info(".post-age", helpers.age_to_string(seconds_old) + " ago"); post_info_container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate); var info = ""; // Add the resolution and file type if available. if(this.displayed_page != null && this.illust_data != null) { var page_info = this.illust_data.mangaPages[this.displayed_page]; info += page_info.width + "x" + page_info.height; } var ext = this.viewer? this.viewer.current_image_type:null; if(ext != null) info += " " + ext; set_info(".image-info", info); var duration = ""; if(illust_data.illustType == 2) { var seconds = 0; for(var frame of illust_data.ugoiraMetadata.frames) seconds += frame.delay / 1000; var duration = seconds.toFixed(duration >= 10? 0:1); duration += seconds == 1? " second":" seconds"; } set_info(".ugoira-duration", duration); set_info(".ugoira-frames", illust_data.illustType == 2? (illust_data.ugoiraMetadata.frames.length + " frames"):""); // Add the page count for manga. var page_text = ""; if(illust_data.pageCount > 1 && this.displayed_page != null) page_text = "Page " + (this.displayed_page+1) + "/" + illust_data.pageCount; set_info(".page-count", page_text); } // Set the resolution to display in image info. If both are null, no resolution // is displayed. set_displayed_page_info(page) { console.assert(page == null || page >= 0); this.displayed_page = page; this.refresh(); } clicked_download(e) { if(this.illust_data == null) return; var clicked_button = e.target.closest(".download-button"); if(clicked_button == null) return; e.preventDefault(); e.stopPropagation(); let download_type = clicked_button.dataset.download; actions.download_illust(this.illust_id, this.progress_bar.controller(), download_type, this.displayed_page); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/image_ui.js `; ppixiv.resources["src/tag_search_dropdown_widget.js"] = `"use strict"; // Handle showing the search history and tag edit dropdowns. ppixiv.tag_search_box_widget = class { constructor(container) { this.input_onfocus = this.input_onfocus.bind(this); this.input_onblur = this.input_onblur.bind(this); this.container_onmouseenter = this.container_onmouseenter.bind(this); this.container_onmouseleave = this.container_onmouseleave.bind(this); this.submit_search = this.submit_search.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); this.dropdown_widget = new tag_search_dropdown_widget(container); this.edit_widget = new tag_search_edit_widget(container); this.container.addEventListener("mouseenter", this.container_onmouseenter); this.container.addEventListener("mouseleave", this.container_onmouseleave); this.input_element.addEventListener("focus", this.input_onfocus); this.input_element.addEventListener("blur", this.input_onblur); let edit_button = this.container.querySelector(".edit-search-button"); if(edit_button) { edit_button.addEventListener("click", (e) => { // Toggle the edit widget, hiding the search history dropdown if it's shown. if(this.dropdown_widget.shown) this.hide(); if(this.edit_widget.shown) this.hide(); else this.show_edit(); }); } // Search submission: helpers.input_handler(this.input_element, this.submit_search); this.container.querySelector(".search-submit-button").addEventListener("click", this.submit_search); // Hide the dropdowns on navigation. new view_hidden_listener(this.input_element, (e) => { this.hide(); }); } async show_history() { // Don't show history if search editing is already open. if(this.edit_widget.shown) return; this.dropdown_widget.show(); } show_edit() { // Don't show search editing if history is already open. if(this.dropdown_widget.shown) return; this.edit_widget.show(); // Disable adding searches to search history while the edit dropdown is open. Otherwise, // every time a tag is toggled, that combination of tags is added to search history by // data_source_search, which makes a mess. helpers.disable_adding_search_tags(true); } hide() { helpers.disable_adding_search_tags(false); this.dropdown_widget.hide(); this.edit_widget.hide(); } container_onmouseenter(e) { this.mouse_over_parent = true; } container_onmouseleave(e) { this.mouse_over_parent = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } // Show the dropdown when the input is focused. Hide it when the input is both // unfocused and this.container isn't being hovered. This way, the input focus // can leave the input box to manipulate the dropdown without it being hidden, // but we don't rely on hovering to keep the dropdown open. input_onfocus(e) { this.input_focused = true; if(!this.dropdown_widget.shown && !this.edit_widget.shown) this.show_history(); } input_onblur(e) { this.input_focused = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } submit_search(e) { // This can be sent to either the search page search box or the one in the // navigation dropdown. Figure out which one we're on. var search_box = e.target.closest(".search-box"); var tags = this.input_element.value.trim(); if(tags.length == 0) return; // Add this tag to the recent search list. helpers.add_recent_search_tag(tags); // If we're submitting by pressing enter on an input element, unfocus it and // close any widgets inside it (tag dropdowns). if(e.target instanceof HTMLInputElement) { e.target.blur(); view_hidden_listener.send_viewhidden(e.target); } // Run the search. helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(tags, ppixiv.location), true); } } ppixiv.tag_search_dropdown_widget = class { constructor(container) { this.dropdown_onclick = this.dropdown_onclick.bind(this); this.input_onkeydown = this.input_onkeydown.bind(this); this.input_oninput = this.input_oninput.bind(this); this.populate_dropdown = this.populate_dropdown.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); this.input_element.addEventListener("keydown", this.input_onkeydown); this.input_element.addEventListener("input", this.input_oninput); // Refresh the dropdown when the tag search history changes. window.addEventListener("recent-tag-searches-changed", this.populate_dropdown); // Add the dropdown widget. this.tag_dropdown = helpers.create_from_template(".template-tag-dropdown"); this.tag_dropdown.addEventListener("click", this.dropdown_onclick); this.container.appendChild(this.tag_dropdown); this.current_autocomplete_results = []; // input-dropdown is resizable. Save the size when the user drags it. this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown"); let observer = new MutationObserver((mutations) => { // resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads // as 0 here. settings.set("tag-dropdown-width", this.input_dropdown.style.width); }); observer.observe(this.input_dropdown, { attributes: true }); // Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly. this.input_dropdown.style.width = settings.get("tag-dropdown-width", "400px"); this.shown = false; this.tag_dropdown.hidden = true; // Sometimes the popup closes when searches are clicked and sometimes they're not. Make sure // we always close on navigation. this.tag_dropdown.addEventListener("click", (e) => { if(e.defaultPrevented) return; let a = e.target.closest("A"); if(a == null) return; this.input_element.blur(); this.hide(); }); } dropdown_onclick(e) { var remove_entry = e.target.closest(".remove-history-entry"); if(remove_entry != null) { // Clicked X to remove a tag from history. e.stopPropagation(); e.preventDefault(); var tag = e.target.closest(".entry").dataset.tag; helpers.remove_recent_search_tag(tag); return; } // Close the dropdown if the user clicks a tag (but not when clicking // remove-history-entry). if(e.target.closest(".tag")) this.hide(); } input_onkeydown(e) { // Only handle inputs when we're open. if(this.tag_dropdown.hidden) return; switch(e.keyCode) { case 38: // up arrow case 40: // down arrow e.preventDefault(); e.stopImmediatePropagation(); this.move(e.keyCode == 40); break; } } input_oninput(e) { if(this.tag_dropdown.hidden) return; // Clear the selection on input. this.set_selection(null); // Update autocomplete when the text changes. this.run_autocomplete(); } async show() { if(this.shown) return; this.shown = true; // Fill in the dropdown before displaying it. If hide() is called before this // finishes this will return false, so stop. if(!await this.populate_dropdown()) return; this.tag_dropdown.hidden = false; } hide() { if(!this.shown) return; this.shown = false; // If populate_dropdown is still running, cancel it. this.cancel_populate_dropdown(); this.tag_dropdown.hidden = true; // Make sure the input isn't focused. this.input_element.blur(); } async run_autocomplete() { // If true, this is a value change caused by keyboard navigation. Don't run autocomplete, // since we don't want to change the dropdown due to navigating in it. if(this.navigating) return; var tags = this.input_element.value.trim(); // Stop if we're already up to date. if(this.most_recent_search == tags) return; if(this.autocomplete_request != null) { // If an autocomplete request is already running, let it finish before we // start another. This matches the behavior of Pixiv's input forms. console.log("Delaying search for", tags); return; } if(tags == "") { // Don't send requests with an empty string. Just finish the search synchronously, // so we clear the autocomplete immediately. if(this.abort_autocomplete != null) this.abort_autocomplete.abort(); this.autocomplete_request_finished("", { candidates: [] }); return; } // Run the search. try { this.abort_autocomplete = new AbortController(); var result = await helpers.rpc_get_request("/rpc/cps.php", { keyword: tags, }, { signal: this.abort_autocomplete.signal, }); this.autocomplete_request_finished(tags, result); } catch(e) { console.info("Tag autocomplete aborted:", e); } finally { this.abort_autocomplete = null; } } // A tag autocomplete request finished. autocomplete_request_finished(tags, result) { this.most_recent_search = tags; this.abort_autocomplete = null; // Store the new results. this.current_autocomplete_results = result.candidates || []; // Refresh the dropdown with the new results. this.populate_dropdown(); // If the input element's value has changed since we started this search, we // stalled any other autocompletion. Start it now. if(tags != this.input_element.value) { console.log("Run delayed autocomplete"); this.run_autocomplete(); } } // tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations. create_entry(tag_search, translated_tags) { var entry = helpers.create_from_template(".template-tag-dropdown-entry"); entry.dataset.tag = tag_search; let translated_tag = translated_tags[tag_search]; if(translated_tag) entry.dataset.translated_tag = translated_tag; let tag_container = entry.querySelector(".search"); for(let tag of helpers.split_search_tags(tag_search)) { if(tag == "") continue; // Force "or" lowercase. if(tag.toLowerCase() == "or") tag = "or"; let span = document.createElement("span"); span.dataset.tag = tag; span.classList.add("word"); if(tag == "or") span.classList.add("or"); else span.classList.add("tag"); // Split off - prefixes to look up the translation, then add it back. let prefix_and_tag = helpers.split_tag_prefixes(tag); let translated_tag = translated_tags[prefix_and_tag[1]]; if(translated_tag) translated_tag = prefix_and_tag[0] + translated_tag; span.innerText = translated_tag || tag; if(translated_tag) span.dataset.translated_tag = translated_tag; tag_container.appendChild(span); } var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location); entry.href = url; return entry; } set_selection(idx) { // Temporarily set this.navigating to true. This lets run_autocomplete know that // it shouldn't run an autocomplete request for this value change. this.navigating = true; try { // If there's an autocomplete request in the air, cancel it. if(this.abort_autocomplete != null) this.abort_autocomplete.abort(); // Clear any old selection. var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry"); if(this.selected_idx != null) all_entries[this.selected_idx].classList.remove("selected"); // Set the new selection. this.selected_idx = idx; if(this.selected_idx != null) { var new_entry = all_entries[this.selected_idx]; new_entry.classList.add("selected"); this.input_element.value = new_entry.dataset.tag; } } finally { this.navigating = false; } } // Select the next or previous entry in the dropdown. move(down) { var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry"); // Stop if there's nothing in the list. var total_entries = all_entries.length; if(total_entries == 0) return; var idx = this.selected_idx; if(idx == null) idx = down? 0:(total_entries-1); else idx += down? +1:-1; idx %= total_entries; this.set_selection(idx); } // Populate the tag dropdown. // // This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection. // Network APIs should be async, but local I/O should not be forced async.) If another // call to populate_dropdown() is made before this completes or cancel_populate_dropdown // cancels it, return false. If it completes, return true. async populate_dropdown() { // If another populate_dropdown is already running, cancel it and restart. this.cancel_populate_dropdown(); // Set populate_dropdown_abort to an AbortController for this call. let abort_controller = this.populate_dropdown_abort = new AbortController(); let abort_signal = abort_controller.signal; var tag_searches = settings.get("recent-tag-searches") || []; // Separate tags in each search, so we can look up translations. // var all_tags = {}; for(let tag_search of tag_searches) { for(let tag of helpers.split_search_tags(tag_search)) { tag = helpers.split_tag_prefixes(tag)[1]; all_tags[tag] = true; } } all_tags = Object.keys(all_tags); let translated_tags = await tag_translations.get().get_translations(all_tags, "en"); // Check if we were aborted while we were loading tags. if(abort_signal && abort_signal.aborted) { console.log("populate_dropdown_inner aborted"); return false; } var list = this.tag_dropdown.querySelector(".input-dropdown-list"); helpers.remove_elements(list); this.selected_idx = null; var autocompleted_tags = this.current_autocomplete_results; for(var tag of autocompleted_tags) { var entry = this.create_entry(tag.tag_name, translated_tags); entry.classList.add("autocomplete"); list.appendChild(entry); } for(var tag of tag_searches) { var entry = this.create_entry(tag, translated_tags); entry.classList.add("history"); list.appendChild(entry); } return true; } cancel_populate_dropdown() { if(this.populate_dropdown_abort == null) return; this.populate_dropdown_abort.abort(); } } ppixiv.tag_search_edit_widget = class { constructor(container) { this.dropdown_onclick = this.dropdown_onclick.bind(this); this.populate_dropdown = this.populate_dropdown.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); // Refresh the dropdown when the tag search history changes. window.addEventListener("recent-tag-searches-changed", this.populate_dropdown); // Add the dropdown widget. this.tag_dropdown = helpers.create_from_template(".template-edit-search-dropdown"); this.tag_dropdown.addEventListener("click", this.dropdown_onclick); this.container.appendChild(this.tag_dropdown); // Refresh tags if the user edits the search directly. this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); }); // input-dropdown is resizable. Save the size when the user drags it. this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown"); let observer = new MutationObserver((mutations) => { // resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads // as 0 here. settings.set("search-edit-dropdown-width", this.input_dropdown.style.width); }); observer.observe(this.input_dropdown, { attributes: true }); // Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly. this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px"); this.shown = false; this.tag_dropdown.hidden = true; } dropdown_onclick(e) { e.preventDefault(); e.stopImmediatePropagation(); // Clicking tags toggles the tag in the search box. let tag = e.target.closest(".tag"); if(tag == null) return; this.toggle_tag(tag.dataset.tag); // Control-clicking the tag probably caused its enclosing search link to be focused, which will // cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter // will submit the search. this.input_element.focus(); } async show() { if(this.shown) return; this.shown = true; // Fill in the dropdown before displaying it. If hide() is called before this // finishes this will return false, so stop. if(!await this.populate_dropdown()) return; this.tag_dropdown.hidden = false; } hide() { if(!this.shown) return; this.shown = false; // If populate_dropdown is still running, cancel it. this.cancel_populate_dropdown(); this.tag_dropdown.hidden = true; // Make sure the input isn't focused. this.input_element.blur(); } // tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations. create_entry(tag_search, translated_tags) { var entry = helpers.create_from_template(".template-edit-search-dropdown-entry"); entry.dataset.tag = tag_search; let translated_tag = translated_tags[tag_search]; if(translated_tag) entry.dataset.translated_tag = translated_tag; let tag_container = entry.querySelector(".search"); for(let tag of helpers.split_search_tags(tag_search)) { if(tag == "") continue; let span = document.createElement("span"); span.dataset.tag = tag; span.classList.add("word"); if(tag != "or") span.classList.add("tag"); // Split off - prefixes to look up the translation, then add it back. let prefix_and_tag = helpers.split_tag_prefixes(tag); let translated_tag = translated_tags[prefix_and_tag[1]]; if(translated_tag) translated_tag = prefix_and_tag[0] + translated_tag; span.innerText = translated_tag || tag; if(translated_tag) span.dataset.translated_tag = translated_tag; tag_container.appendChild(span); } var url = page_manager.singleton().get_url_for_tag_search(tag_search, ppixiv.location); entry.querySelector("A.search").href = url; return entry; } // Populate the tag dropdown. // // This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection. // Network APIs should be async, but local I/O should not be forced async.) If another // call to populate_dropdown() is made before this completes or cancel_populate_dropdown // cancels it, return false. If it completes, return true. async populate_dropdown() { // If another populate_dropdown is already running, cancel it and restart. this.cancel_populate_dropdown(); // Set populate_dropdown_abort to an AbortController for this call. let abort_controller = this.populate_dropdown_abort = new AbortController(); let abort_signal = abort_controller.signal; var tag_searches = settings.get("recent-tag-searches") || []; // Individually show all tags in search history. var all_tags = {}; for(let tag_search of tag_searches) { for(let tag of helpers.split_search_tags(tag_search)) { tag = helpers.split_tag_prefixes(tag)[1]; // Ignore "or". if(tag == "" || tag == "or") continue; all_tags[tag] = true; } } all_tags = Object.keys(all_tags); let translated_tags = await tag_translations.get().get_translations(all_tags, "en"); // Sort tags by their translation. all_tags.sort((lhs, rhs) => { if(translated_tags[lhs]) lhs = translated_tags[lhs]; if(translated_tags[rhs]) rhs = translated_tags[rhs]; return lhs.localeCompare(rhs); }); // Check if we were aborted while we were loading tags. if(abort_signal && abort_signal.aborted) { console.log("populate_dropdown_inner aborted"); return false; } var list = this.tag_dropdown.querySelector(".input-dropdown-list"); helpers.remove_elements(list); for(var tag of all_tags) { var entry = this.create_entry(tag, translated_tags); list.appendChild(entry); } this.refresh_highlighted_tags(); return true; } cancel_populate_dropdown() { if(this.populate_dropdown_abort == null) return; this.populate_dropdown_abort.abort(); } refresh_highlighted_tags() { let tags = helpers.split_search_tags(this.input_element.value); var list = this.tag_dropdown.querySelector(".input-dropdown-list"); for(let tag_entry of list.querySelectorAll("[data-tag]")) { let tag = tag_entry.dataset.tag; let tag_selected = tags.indexOf(tag) != -1; helpers.set_class(tag_entry, "highlight", tag_selected); } } // Add or remove tag from the tag search. This doesn't affect -tag searches. toggle_tag(tag) { console.log("Toggle tag:", tag); let tags = helpers.split_search_tags(this.input_element.value); let idx = tags.indexOf(tag); if(idx != -1) tags.splice(idx, 1); else tags.push(tag); this.input_element.value = tags.join(" "); this.refresh_highlighted_tags(); // Navigate to the edited search immediately. Don't add these to history, since it // spams navigation history. helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value, ppixiv.location), false); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/tag_search_dropdown_widget.js `; ppixiv.resources["src/recently_seen_illusts.js"] = `"use strict"; ppixiv.recently_seen_illusts = class { // Return the singleton, creating it if needed. static get() { if(recently_seen_illusts._singleton == null) recently_seen_illusts._singleton = new recently_seen_illusts(); return recently_seen_illusts._singleton; }; constructor() { this.db = new key_storage("ppixiv-recent-illusts", { db_upgrade: this.db_upgrade }); settings.register_change_callback("no_recent_history", this.update_from_settings); this.update_from_settings(); } get enabled() { return !settings.get("no_recent_history"); } update_from_settings = () => { // If the user disables recent history, clear our storage. if(!this.enabled) { console.log("Clearing history"); this.clear(); } } db_upgrade = (e) => { // Create our object store with an index on last_seen. let db = e.target.result; let store = db.createObjectStore("ppixiv-recent-illusts"); store.createIndex("last_seen", "last_seen"); } async add_illusts(illust_ids) { // Clean up old illusts. We don't need to wait for this. this.purge_old_illusts(); // Stop if we're not enabled. if(!this.enabled) return; let time = Date.now(); let data = {}; let idx = 0; for(let illust_id of illust_ids) { // Store thumbnail info with the image. Every data_source these days is able // to fill in thumbnail data as part of the request, so we store the thumbnail // info to be able to do the same in data_source.recent. We're called when // a thumbnail is being displayed, so let thumb_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumb_info == null) continue; data[illust_id] = { // Nudge the time back slightly as we go, so illustrations earlier in the list will // be treated as older. This causes them to sort earlier in the recent illustrations // view. If we don't do this, they'll be displayed in an undefined order. last_seen: time - idx, thumb_info: thumb_info, }; idx++; } // Batch write: await this.db.multi_set(data); } async clear() { } // Return illust_ids for recently viewed illusts, most recent first. async get_recent_illust_ids() { return await this.db.db_op(async (db) => { let store = this.db.get_store(db); return await this.get_stored_illusts(store, "new"); }); } // Return thumbnail data for the given illust IDs if we have it. async get_thumbnail_info(illust_ids) { return await this.db.db_op(async (db) => { let store = this.db.get_store(db); // Load the thumbnail info in bulk. let promises = {}; for(let illust_id of illust_ids) promises[illust_id] = key_storage.async_store_get(store, illust_id); await Promise.all(Object.values(promises)); let results = []; for(let illust_id of illust_ids) { let entry = await promises[illust_id]; if(entry && entry.thumb_info) results.push(entry.thumb_info); } return results; }); } // Clean up IDs that haven't been seen in a while. async purge_old_illusts() { await this.db.db_op(async (db) => { let store = this.db.get_store(db); let ids_to_delete = await this.get_stored_illusts(store, "old"); if(ids_to_delete.length == 0) return; await this.db.multi_delete(ids_to_delete); }); } // Get illusts in the database. If which is "new", return ones that we want to display // to the user. If it's "old", return ones that should be deleted. async get_stored_illusts(store, which="new") { // Read illustrations seen within the last hour, newest first. let index = store.index("last_seen"); let starting_from = Date.now() - (60*60*1000); let query = which == "new"? IDBKeyRange.lowerBound(starting_from):IDBKeyRange.upperBound(starting_from); let cursor = index.openCursor(query, "prev"); let results = []; for await (let entry of cursor) results.push(entry.primaryKey); return results; } async get_stored_illust_thumbnail_info(illust_ids) { return await this.db.db_op(async (db) => { let store = this.db.get_store(db); }); } // Clear history. async clear() { return await this.db.clear(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/recently_seen_illusts.js `; ppixiv.resources["src/tag_translations.js"] = `"use strict"; ppixiv.tag_translations = class { // Return the singleton, creating it if needed. static get() { if(tag_translations._singleton == null) tag_translations._singleton = new tag_translations(); return tag_translations._singleton; }; constructor() { this.db = new key_storage("ppixiv-tag-translations"); } // Store a list of tag translations. // // tag_list is a dictionary: // { // original_tag: { // en: "english tag", // } // } async add_translations_dict(tags) { let translations = []; for(let tag of Object.keys(tags)) { let tag_info = tags[tag]; let tag_translation = {}; for(let lang of Object.keys(tag_info)) { if(tag_info[lang] == "") continue; tag_translation[lang] = tag_info[lang]; } if(Object.keys(tag_translation).length > 0) { translations.push({ tag: tag, translation: tag_translation, }); } } this.add_translations(translations); } // Store a list of tag translations. // // tag_list is a list of // { // tag: "original tag", // translation: { // en: "english tag", // }, // } // // This is the same format that Pixiv uses in newer APIs. Note that we currently only store // English translations. async add_translations(tag_list) { let data = {}; for(let tag of tag_list) { // If a tag has no keys and no romanization, skip it so we don't fill our database // with useless entries. if((tag.translation == null || Object.keys(tag.translation).length == 0) && tag.romaji == null) continue; // Remove empty translation values. let translation = {}; for(let lang of Object.keys(tag.translation || {})) { let value = tag.translation[lang]; if(value != "") translation[lang] = value; } // Store the tag data that we care about. We don't need to store post-specific info // like "deletable". let tag_info = { tag: tag.tag, translation: translation, }; if(tag.romaji) tag_info.romaji = tag.romaji; data[tag.tag] = tag_info; } // Batch write: await this.db.multi_set(data); } async get_tag_info(tags) { // If the user has disabled translations, don't return any. if(settings.get("disable-translations")) return {}; let result = {}; let translations = await this.db.multi_get(tags); for(let i = 0; i < tags.length; ++i) { if(translations[i] == null) continue; result[tags[i]] = translations[i]; } return result; } async get_translations(tags, language) { let info = await this.get_tag_info(tags); let result = {}; for(let tag of tags) { if(info[tag] == null || info[tag].translation == null) continue; // Skip this tag if we don't have a translation for this language. let translation = info[tag].translation[language]; if(translation == null) continue; result[tag] = translation; } return result; } // Given a tag search, return a translated search. async translate_tag_list(tags, language) { // Pull out individual tags, removing -prefixes. let split_tags = helpers.split_search_tags(tags); let tag_list = []; for(let tag of split_tags) { let prefix_and_tag = helpers.split_tag_prefixes(tag); tag_list.push(prefix_and_tag[1]); } // Get translations. let translated_tags = await this.get_translations(tag_list, language); // Put the search back together. let result = []; for(let one_tag of split_tags) { let prefix_and_tag = helpers.split_tag_prefixes(one_tag); let prefix = prefix_and_tag[0]; let tag = prefix_and_tag[1]; if(translated_tags[tag]) tag = translated_tags[tag]; result.push(prefix + tag); } return result; } // A shortcut to retrieve one translation. If no translation is available, returns the // original tag. async get_translation(tag, language="en") { let translated_tags = await tag_translations.get().get_translations([tag], "en"); if(translated_tags[tag]) return translated_tags[tag]; else return tag; } // Set the innerText of an element to tag, translating it if possible. // // This is async to look up the tag translation, but it's safe to release this // without awaiting. async set_translated_tag(element, tag) { let original_tag = tag; element.dataset.tag = original_tag; tag = await this.get_translation(tag); // Stop if another call was made here while we were async. if(element.dataset.tag != original_tag) return; element.innerText = tag; } } // This updates the pp_tag_translations IDB store to ppixiv-tag-translations. // // The older database code kept the database open all the time. That's normal in every // database in the world, except for IDB where it'll wedge everything (even the Chrome // inspector window) if you try to change object stores. Read it out and write it to a // new database, so users upgrading don't have to restart their browser to get tag translations // back. // // This doesn't delete the old database, since for some reason that fires versionchange, which // might make other tabs misbehave since they're not expecting it. We can add some code to // clean up the old database later on when we can assume everybody has done this migration. ppixiv.update_translation_storage = class { static run() { let update = new this(); update.update(); } constructor() { this.name = "pp_tag_translations"; } async db_op(func) { let db = await this.open_database(); try { return await func(db); } finally { db.close(); } } open_database() { return new Promise((resolve, reject) => { let request = indexedDB.open("ppixiv"); request.onsuccess = e => { resolve(e.target.result); }; request.onerror = e => { resolve(null); }; }); } async_store_get(store) { return new Promise((resolve, reject) => { let request = store.getAll(); request.onsuccess = e => resolve(e.target.result); request.onerror = reject; }); } async update() { // Firefox is missing indexedDB.databases, so Firefox users get to wait for // tag translations to repopulate. if(!indexedDB.databases) return; // If the ppixiv-tag-translations database exists, assume this migration has already been done. // First see if the old database exists and the new one doesn't. let found = false; for(let db of await indexedDB.databases()) { if(db.name == "ppixiv-tag-translations") return; if(db.name == "ppixiv") found = true; } if(!found) return; console.log("Migrating translation database"); // Open the old db. return await this.db_op(async (db) => { if(db == null) return; let transaction = db.transaction(this.name, "readonly"); let store = transaction.objectStore(this.name); let results = await this.async_store_get(store); let translations = []; for(let result of results) { try { if(!result.tag || !result.translation) continue; let data = { tag: result.tag, translation: { }, }; if(result.romaji) data.romaji = result.romaji; let empty = true; for(let lang in result.translation) { let translated = result.translation[lang]; if(!translated) continue; data.translation[lang] = translated; empty = false; } if(empty) continue; translations.push(data); } catch(e) { // Tolerate errors, in case there's weird junk in this database. console.log("Error updating tag:", result); } } await tag_translations.get().add_translations(translations); }); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/tag_translations.js `; ppixiv.resources["src/thumbnail_data.js"] = `"use strict"; // This handles batch fetching data for thumbnails. // // We can load a bunch of images at once with illust_list.php. This isn't enough to // display the illustration, since it's missing a lot of data, but it's enough for // displaying thumbnails (which is what the page normally uses it for). ppixiv.thumbnail_data = class { constructor() { this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this); // Cached data: this.thumbnail_data = { }; this.quick_user_data = { }; this.user_profile_urls = {}; // IDs that we're currently requesting: this.loading_ids = {}; }; // Return the singleton, creating it if needed. static singleton() { if(thumbnail_data._singleton == null) thumbnail_data._singleton = new thumbnail_data(); return thumbnail_data._singleton; }; // Return true if all thumbs in illust_ids have been loaded, or are currently loading. // // We won't start fetching IDs that aren't loaded. are_all_ids_loaded_or_loading(illust_ids) { for(var illust_id of illust_ids) { if(this.thumbnail_data[illust_id] == null && !this.loading_ids[illust_id]) return false; } return true; } is_id_loaded_or_loading(illust_id) { return this.thumbnail_data[illust_id] != null || this.loading_ids[illust_id]; } // Return thumbnail data for illud_id, or null if it's not loaded. // // The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info // to load thumbnail data in batches. get_one_thumbnail_info(illust_id) { return this.thumbnail_data[illust_id]; } // Return thumbnail data for illust_ids, and start loading any requested IDs that aren't // already loaded. get_thumbnail_info(illust_ids) { var result = {}; var needed_ids = []; for(var illust_id of illust_ids) { var data = this.thumbnail_data[illust_id]; if(data == null) { // If this is a user:user_id instead of an illust ID, make sure we don't request it // as an illust ID. if(illust_id.indexOf(":") != -1) continue; needed_ids.push(illust_id); continue; } result[illust_id] = data; } // Load any thumbnail data that we didn't have. if(needed_ids.length) this.load_thumbnail_info(needed_ids); return result; } // Load thumbnail info for the given list of IDs. async load_thumbnail_info(illust_ids) { // Make a list of IDs that we're not already loading. var ids_to_load = []; for(var id of illust_ids) if(this.loading_ids[id] == null) ids_to_load.push(id); if(ids_to_load.length == 0) return; for(var id of ids_to_load) this.loading_ids[id] = true; // There's also // // https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&... // // which is used by newer pages. That's useful since it tells us whether each // image is bookmarked. However, it doesn't tell us the user's name or profile image // URL, and for some reason it's limited to a particular user. Hopefully they'll // have an updated generic illustration lookup call if they ever update the // regular search pages, and we can switch to it then. var result = await helpers.rpc_get_request("/rpc/illust_list.php", { illust_ids: ids_to_load.join(","), // Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150 // ones we'll get if we don't (though changing the URL is easy enough too). page: "discover", // We do our own muting, but for some reason this flag is needed to get bookmark info. exclude_muted_illusts: 1, }); this.loaded_thumbnail_info(result, "illust_list"); } // Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys. get thumbnail_info_map_illust_list() { if(this._thumbnail_info_map_illust_list != null) return this._thumbnail_info_map_illust_list; this._thumbnail_info_map_illust_list = [ ["illust_id", "id"], ["url", "url"], ["tags", "tags"], ["illust_user_id", "userId"], ["illust_width", "width"], ["illust_height", "height"], ["illust_type", "illustType"], ["illust_page_count", "pageCount"], ["illust_title", "title"], ["user_profile_img", "profileImageUrl"], ["user_name", "userName"], // illust_list.php doesn't give the creation date. [null, "createDate"], ]; return this._thumbnail_info_map_illust_list; }; get thumbnail_info_map_ranking() { if(this._thumbnail_info_map_ranking != null) return this._thumbnail_info_map_ranking; this._thumbnail_info_map_ranking = [ ["illust_id", "id"], ["url", "url"], ["tags", "tags"], ["user_id", "userId"], ["width", "width"], ["height", "height"], ["illust_type", "illustType"], ["illust_page_count", "pageCount"], ["title", "title"], ["profile_img", "profileImageUrl"], ["user_name", "userName"], ["illust_upload_timestamp", "createDate"], ]; return this._thumbnail_info_map_ranking; }; // 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"); } return url.toString(); } // This is called when we have new thumbnail data available. thumb_result is // an array of thumbnail items. // // This can come from a bunch of different places, which all return the same data, but // each in a different way: // // name URL // normal /ajax/user/id/illusts/bookmarks // illust_list illust_list.php // following bookmark_new_illust.php // following search.php // rankings ranking.php // // We map each of these to "normal". // // These have the same data, but for some reason everything has different names. // Remap them to "normal", and check that all fields we expect exist, to make it // easier to notice if something is wrong. loaded_thumbnail_info(thumb_result, source) { if(thumb_result.error) return; let remapped_thumb_info = null; for(var thumb_info of thumb_result) { // Ignore entries with "isAdContainer". These aren't search results at all and just contain // stuff we're not interested in. if(thumb_info.isAdContainer) continue; if(source == "normal") { // The data is already in the format we want. Just check that all keys we // expect exist, and remove any keys we don't know about so we don't use them // accidentally. let thumbnail_info_map = this.thumbnail_info_map_ranking; remapped_thumb_info = { }; for(let pair of thumbnail_info_map) { let key = pair[1]; if(!(key in thumb_info)) { console.warn("Thumbnail info is missing key:", key); continue; } remapped_thumb_info[key] = thumb_info[key]; } if(!('bookmarkData' in thumb_info)) console.warn("Thumbnail info is missing key: bookmarkData"); else { remapped_thumb_info.bookmarkData = thumb_info.bookmarkData; // See above. if(remapped_thumb_info.bookmarkData != null) delete remapped_thumb_info.bookmarkData.bookmarkId; } } else if(source == "illust_list" || source == "rankings") { // Get the mapping for this mode. let thumbnail_info_map = source == "illust_list"? this.thumbnail_info_map_illust_list: this.thumbnail_info_map_ranking; remapped_thumb_info = { }; for(let pair of thumbnail_info_map) { let from_key = pair[0]; let to_key = pair[1]; if(from_key == null) { // This is just for illust_list createDate. remapped_thumb_info[to_key] = null; continue; } if(!(from_key in thumb_info)) { console.warn("Thumbnail info is missing key:", from_key); continue; } let value = thumb_info[from_key]; remapped_thumb_info[to_key] = value; } // Make sure that the illust IDs and user IDs are strings. remapped_thumb_info.id = "" + remapped_thumb_info.id; remapped_thumb_info.userId = "" + remapped_thumb_info.userId; // Bookmark data is a special case. // // The old API has is_bookmarked: true, bookmark_id: "id" and bookmark_illust_restrict: 0 or 1. // bookmark_id and bookmark_illust_restrict are omitted if is_bookmarked is false. // // The new API is a dictionary: // // bookmarkData = { // bookmarkId: id, // private: false // } // // or null if not bookmarked. // // A couple sources of thumbnail data (bookmark_new_illust.php and search.php) // don't return the bookmark ID. We don't use this (we only edit bookmarks from // the image page, where we have full image data), so we omit bookmarkId from this // data. // // Some pages return buggy results. /ajax/user/id/profile/all includes bookmarkData, // but private is always false, so we can't tell if it's a private bookmark. This is // a site bug that we can't do anything about (it affects the site too). remapped_thumb_info.bookmarkData = null; if(!('is_bookmarked' in thumb_info)) console.warn("Thumbnail info is missing key: is_bookmarked"); if(thumb_info.is_bookmarked) { remapped_thumb_info.bookmarkData = { // See above. // bookmarkId: thumb_info.bookmark_id, private: thumb_info.bookmark_illust_restrict == 1, }; } // illustType can be a string in these instead of an int, so convert it. remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType); if(source == "rankings") { // Rankings thumbnail info gives createDate as a Unix timestamp. Convert // it to the same format as everything else. let date = new Date(remapped_thumb_info.createDate*1000); remapped_thumb_info.createDate = date.toISOString(); } else if(source == "illust_list") { // This is the only source of thumbnail data that doesn't give createDate at // all. This source is very rarely used now, so just fill in a bogus date. remapped_thumb_info.createDate = new Date(0).toISOString(); } } else throw "Unrecognized source: " + source; // These fields are strings in some sources. Switch them to ints. for(let key of ["pageCount", "width", "height"]) { if(remapped_thumb_info[key] != null) remapped_thumb_info[key] = parseInt(remapped_thumb_info[key]); } // Different APIs return different thumbnail URLs. remapped_thumb_info.url = this.get_high_res_thumbnail_url(remapped_thumb_info.url); remapped_thumb_info.mangaPages = []; for(let page = 0; page < remapped_thumb_info.pageCount; ++page) { let url = this.get_high_res_thumbnail_url(remapped_thumb_info.url, page); remapped_thumb_info.mangaPages.push(url); } thumb_info = remapped_thumb_info; // Store the data. this.add_thumbnail_info(thumb_info); var illust_id = thumb_info.id; delete this.loading_ids[illust_id]; // Cache the user's profile URL. This lets us display it more quickly when we // haven't loaded user info yet. let profile_image_url = thumb_info.profileImageUrl; profile_image_url = profile_image_url.replace("_50.", "_170."), this.user_profile_urls[thumb_info.userId] = profile_image_url; } // Broadcast that we have new thumbnail data available. window.dispatchEvent(new Event("thumbnailsLoaded")); }; // Store thumbnail info. add_thumbnail_info(thumb_info) { var illust_id = thumb_info.id; this.thumbnail_data[illust_id] = thumb_info; } is_muted(thumb_info) { if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id)) return true; if(muting.singleton.any_tag_muted(thumb_info.tags)) return true; return false; } // This is a simpler form of thumbnail data for user info. This is just the bare minimum // info we need to be able to show a user thumbnail on the search page. This is used when // we're displaying lots of users in search results. // // We can get this info from two places, the following page (data_source_follows) and the // user recommendations page (data_source_discovery_users). Of course, since Pixiv never // does anything the same way twice, they have different formats. // // The only info we need is: // userId // userName // profileImageUrl add_quick_user_data(user_data, source) { let data = null; if(source == "following") { data = { userId: user_data.userId, userName: user_data.userName, profileImageUrl: user_data.profileImageUrl, }; } else if(source == "recommendations") { data = { userId: user_data.userId, userName: user_data.name, profileImageUrl: user_data.imageBig, }; } else if(source == "users_bookmarking_illust" || source == "user_search") { data = { userId: user_data.user_id, userName: user_data.user_name, profileImageUrl: user_data.profile_img, }; } else throw "Unknown source: " + source; this.quick_user_data[data.userId] = data; } get_quick_user_data(user_id) { return this.quick_user_data[user_id]; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/thumbnail_data.js `; ppixiv.resources["src/manga_thumbnail_widget.js"] = `"use strict"; class scroll_handler { constructor(container) { this.container = container; } // Bring item into view. We'll also try to keep the next and previous items visible. scroll_into_view(item) { // Make sure item is a direct child of the container. if(item.parentNode != this.container) { console.error("Node", item, "isn't in scroller", this.container); return; } // Scroll so the items to the left and right of the current thumbnail are visible, // so you can tell whether there's another entry to scroll to. If we can't fit // them, center the selection. var scroller_left = this.container.getBoundingClientRect().left; var left = item.offsetLeft - scroller_left; if(item.previousElementSibling) left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left); var right = item.offsetLeft + item.offsetWidth - scroller_left; if(item.nextElementSibling) right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left); var new_left = this.container.scrollLeft; if(new_left > left) new_left = left; if(new_left + this.container.offsetWidth < right) new_left = right - this.container.offsetWidth; this.container.scrollLeft = new_left; // If we didn't fit the previous and next entries, there isn't enough space. This // might be a wide thumbnail or the window might be very narrow. Just center the // selection. Note that we need to compare against the value we assigned and not // read scrollLeft back, since the API is broken and reads back the smoothed value // rather than the target we set. if(new_left > left || new_left + this.container.offsetWidth < right) { this.center_item(item); } } // Scroll the given item to the center. center_item(item) { var scroller_left = this.container.getBoundingClientRect().left; var left = item.offsetLeft - scroller_left; left += item.offsetWidth/2; left -= this.container.offsetWidth / 2; this.container.scrollLeft = left; } /* Snap to the target position, cancelling any smooth scrolling. */ snap() { this.container.style.scrollBehavior = "auto"; if(this.container.firstElementChild) this.container.firstElementChild.getBoundingClientRect(); this.container.getBoundingClientRect(); this.container.style.scrollBehavior = ""; } }; ppixiv.manga_thumbnail_widget = class { constructor(container) { this.onclick = this.onclick.bind(this); this.onmouseenter = this.onmouseenter.bind(this); this.onmouseleave = this.onmouseleave.bind(this); this.check_image_loads = this.check_image_loads.bind(this); this.window_onresize = this.window_onresize.bind(this); window.addEventListener("resize", this.window_onresize); this.container = container; this.container.addEventListener("click", this.onclick); this.container.addEventListener("mouseenter", this.onmouseenter); this.container.addEventListener("mouseleave", this.onmouseleave); this.cursor = document.createElement("div"); this.cursor.classList.add("thumb-list-cursor"); this.scroll_box = this.container.querySelector(".manga-thumbnails"); this.scroller = new scroll_handler(this.scroll_box); this.visible = false; this.set_illust_info(null); } // Both Firefox and Chrome have some nasty layout bugs when resizing the window, // causing the flexbox and the images inside it to be incorrect. Work around it // by forcing a refresh. window_onresize(e) { this.refresh(); } onmouseenter(e) { this.hovering = true; this.refresh_visible(); } onmouseleave(e) { this.stop_hovering(); } stop_hovering() { this.hovering = false; this.refresh_visible(); } refresh_visible() { this.visible = this.hovering; } get visible() { return this.container.classList.contains("visible"); } set visible(visible) { if(visible == this.visible) return; helpers.set_class(this.container, "visible", visible); if(!visible) this.stop_hovering(); } onclick(e) { var arrow = e.target.closest(".manga-thumbnail-arrow"); if(arrow != null) { e.preventDefault(); e.stopPropagation(); var left = arrow.dataset.direction == "left"; console.log("scroll", left); var new_page = this.current_page + (left? -1:+1); if(new_page < 0 || new_page >= this.entries.length) return; main_controller.singleton.show_illust(this.illust_info.illustId, { page: new_page, }); /* var entry = this.entries[new_page]; if(entry == null) return; this.scroller.scroll_into_view(entry); */ return; } var thumb = e.target.closest(".manga-thumbnail-box"); if(thumb != null) { e.preventDefault(); e.stopPropagation(); var new_page = parseInt(thumb.dataset.page); main_controller.singleton.show_illust(this.illust_info.illustId, { page: new_page, }); return; } } set_illust_info(illust_info) { if(illust_info == this.illust_info) return; // Only display if we have at least two pages. if(illust_info != null && illust_info.pageCount < 2) illust_info = null; // If we're not on a manga page, hide ourselves entirely, including the hover box. this.container.hidden = illust_info == null; this.illust_info = illust_info; if(illust_info == null) this.stop_hovering(); // Refresh the thumb images. this.refresh(); // Start or stop check_image_loads if needed. if(this.illust_info == null && this.check_image_loads_timer != null) { clearTimeout(this.check_image_loads_timer); this.check_image_loads_timer = null; } this.check_image_loads(); } snap_transition() { this.scroller.snap(); } // This is called when the manga page is changed externally. current_page_changed(page) { // Ignore page changes if we're not displaying anything. if(this.illust_info == null) return this.current_page = page; if(this.current_page == null) return; // Find the entry for the page. var entry = this.entries[this.current_page]; if(entry == null) { console.error("Scrolled to unknown page", this.current_page); return; } this.scroller.scroll_into_view(entry); if(this.selected_entry) helpers.set_class(this.selected_entry, "selected", false); this.selected_entry = entry; if(this.selected_entry) { helpers.set_class(this.selected_entry, "selected", true); this.update_cursor_position(); } } update_cursor_position() { // Wait for images to know their size before positioning the cursor. if(this.selected_entry == null || this.waiting_for_images || this.cursor.parentNode == null) return; // Position the cursor to the position of the selection. this.cursor.style.width = this.selected_entry.offsetWidth + "px"; var scroller_left = this.scroll_box.getBoundingClientRect().left; var base_left = this.cursor.parentNode.getBoundingClientRect().left; var position_left = this.selected_entry.getBoundingClientRect().left; var left = position_left - base_left; this.cursor.style.left = left + "px"; } // We can't update the UI properly until we know the size the thumbs will be, // and the site doesn't tell us the size of manga pages (only the first page). // Work around this by hiding until we have naturalWidth for all images, which // will allow layout to complete. There's no event for this for some reason, // so the only way to detect it is with a timer. // // This often isn't needed because of image preloading. check_image_loads() { if(this.illust_info == null) return; this.check_image_loads_timer = null; var all_images_loaded = true; for(var img of this.container.querySelectorAll("img.manga-thumb")) { if(img.naturalWidth == 0) all_images_loaded = false; } // If all images haven't loaded yet, check again. if(!all_images_loaded) { this.waiting_for_images = true; this.check_image_loads_timer = setTimeout(this.check_image_loads, 10); return; } this.waiting_for_images = false; // Now that we know image sizes and layout can update properly, we can update the cursor's position. this.update_cursor_position(); } refresh() { if(this.cursor.parentNode) this.cursor.parentNode.removeChild(this.cursor); var ul = this.container.querySelector(".manga-thumbnails"); helpers.remove_elements(ul); this.entries = []; if(this.illust_info == null) return; // Add left and right padding elements to center the list if needed. var left_padding = document.createElement("div"); left_padding.style.flex = "1"; ul.appendChild(left_padding); for(var page = 0; page < this.illust_info.pageCount; ++page) { var page_info = this.illust_info.mangaPages[page]; var url = page_info.urls.small; var img = document.createElement("img"); var entry = helpers.create_from_template(".template-manga-thumbnail"); entry.dataset.page = page; entry.querySelector("img.manga-thumb").src = url; ul.appendChild(entry); this.entries.push(entry); } var right_padding = document.createElement("div"); right_padding.style.flex = "1"; ul.appendChild(right_padding); // Place the cursor inside the first entry, so it follows it around as we scroll. this.entries[0].appendChild(this.cursor); this.update_cursor_position(); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/manga_thumbnail_widget.js `; ppixiv.resources["src/page_manager.js"] = `"use strict"; // This handles: // // - Keeping track of whether we're active or not. If we're inactive, we turn off // and let the page run normally. // - Storing state in the address bar. // // We're active by default on illustration pages, and inactive by default on others. // // If we're active, we'll store our state in the hash as "#ppixiv/...". The start of // the hash will always be "#ppixiv", so we can tell it's our data. If we're on a page // where we're inactive by default, this also remembers that we've been activated. // // If we're inactive on a page where we're active by default, we'll always put something // other than "#ppixiv" in the address bar. It doesn't matter what it is. This remembers // that we were deactivated, and remains deactivated even if the user clicks an anchor // in the page that changes the hash. // // If we become active or inactive after the page loads, we refresh the page. // // We have two sets of query parameters: args stored in the URL query, and args stored in // the hash. For example, in: // // https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234 // // our query args are p=2, and our hash args are illust_id=1234. We use query args to // store state that exists in the underlying page, and hash args to store state that // doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off. ppixiv.page_manager = class { constructor() { this.window_popstate = this.window_popstate.bind(this); window.addEventListener("popstate", this.window_popstate, true); this.data_sources_by_canonical_url = {}; this.active = this._active_internal(); }; // Return the singleton, creating it if needed. static singleton() { if(page_manager._singleton == null) page_manager._singleton = new page_manager(); return page_manager._singleton; }; // Return the data source for a URL, or null if the page isn't supported. get_data_source_for_url(url) { // url is usually document.location, which for some reason doesn't have .searchParams. var url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); let first_part = helpers.get_page_type_from_url(url); if(first_part == "artworks") { return data_sources.current_illust; } else if(first_part == "users") { // This is one of: // // /users/12345 // /users/12345/artworks // /users/12345/illustrations // /users/12345/manga // /users/12345/bookmarks // /users/12345/following // // All of these except for bookmarks are handled by data_sources.artist. let mode = helpers.get_path_part(url, 2); if(mode == "following") return data_sources.follows; if(mode != "bookmarks") return data_sources.artist; // Handle a special case: we're called by early_controller just to find out if // the current page is supported or not. This happens before window.global_data // exists, so we can't check if we're viewing our own bookmarks or someone else's. // In this case we don't need to, since the caller just wants to see if we return // a data source or not. if(window.global_data == null) return data_sources.bookmarks; // If show-all=0 isn't in the hash, and we're not viewing someone else's bookmarks, // we're viewing all bookmarks, so use data_sources.bookmarks_merged. Otherwise, // use data_sources.bookmarks. var args = new helpers.args(url); var user_id = helpers.get_path_part(url, 1); if(user_id == null) user_id = window.global_data.user_id; var viewing_own_bookmarks = user_id == window.global_data.user_id; var both_public_and_private = viewing_own_bookmarks && args.hash.get("show-all") != "0"; return both_public_and_private? data_sources.bookmarks_merged:data_sources.bookmarks; } else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php") return data_sources.new_illust; else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php") return data_sources.bookmarks_new_illust; else if(url.pathname == "/history.php") return data_sources.recent; else if(first_part == "tags") return data_sources.search; else if(url.pathname == "/discovery") return data_sources.discovery; else if(url.pathname == "/discovery/users") return data_sources.discovery_users; else if(url.pathname == "/bookmark_detail.php") { // If we've added "recommendations" to the hash info, this was a recommendations link. let args = new helpers.args(url); if(args.hash.get("recommendations")) return data_sources.related_illusts; else return data_sources.related_favorites; } else if(url.pathname == "/ranking.php") return data_sources.rankings; else if(url.pathname == "/search_user.php") return data_sources.search_users; else return null; }; // Create the data source for a given URL. // // If we've already created a data source for this URL, the same one will be // returned. // // If force is true, we'll always create a new data source, replacing any // previously created one. create_data_source_for_url(url, force) { var data_source_class = this.get_data_source_for_url(url); if(data_source_class == null) { console.error("Unexpected path:", url.pathname); return; } // Canonicalize the URL to see if we already have a data source for this URL. let canonical_url = data_source_class.get_canonical_url(url); // console.log("url", url.toString(), "becomes", canonical_url); if(!force && canonical_url in this.data_sources_by_canonical_url) { // console.log("Reusing data source for", url.toString()); return this.data_sources_by_canonical_url[canonical_url]; } // console.log("Creating new data source for", url.toString()); var source = new data_source_class(url.href); this.data_sources_by_canonical_url[canonical_url] = source; return source; } // If we have the given data source cached, discard it, so it'll be recreated // the next time it's used. discard_data_source(data_source) { let urls_to_remove = []; for(let url in this.data_sources_by_canonical_url) { if(this.data_sources_by_canonical_url[url] === data_source) urls_to_remove.push(url); } for(let url of urls_to_remove) delete this.data_sources_by_canonical_url[url]; } // Return true if it's possible for us to be active on this page. available_for_url(url) { // We support the page if it has a data source. return this.get_data_source_for_url(url) != null; }; window_popstate(e) { var currently_active = this._active_internal(); if(this.active == currently_active) return; // Stop propagation, so other listeners don't see this. For example, this prevents // the thumbnail viewer from turning on or off as a result of us changing the hash // to "#no-ppixiv". e.stopImmediatePropagation(); if(this.active == currently_active) return; this.store_ppixiv_disabled(!currently_active); console.log("Active state changed"); // The URL has changed and caused us to want to activate or deactivate. Reload the // page. // // We'd prefer to reload with cache, like a regular navigation, but Firefox seems // to reload without cache no matter what we do, even though document.location.reload // is only supposed to bypass cache on reload(true). There doesn't seem to be any // reliable workaround. document.location.reload(); } store_ppixiv_disabled(disabled) { // Remember that we're enabled or disabled in this tab. if(disabled) window.sessionStorage.ppixiv_disabled = 1; else delete window.sessionStorage.ppixiv_disabled; } // Return true if we're active by default on the current page. active_by_default() { // If the disabled-by-default setting is enabled, disable by default until manually // turned on. if(settings.get("disabled-by-default")) return false; // If this is set, the user clicked the "return to Pixiv" button. Stay disabled // in this tab until we're reactivated. if(window.sessionStorage.ppixiv_disabled) return false; return this.available_for_url(ppixiv.location); }; // Return true if we're currently active. // // This is cached at the start of the page and doesn't change unless the page is reloaded. _active_internal() { // If the hash is empty, use the default. if(ppixiv.location.hash == "") return this.active_by_default(); // If we have a hash and it's not #ppixiv, then we're explicitly disabled. If we // # do have a #ppixiv hash, we're explicitly enabled. // // If we're explicitly enabled but aren't actually available, we're disabled. This // makes sure we don't break pages if we accidentally load them with a #ppixiv hash, // or if we remove support for a page that people have in their browser session. return helpers.parse_hash(ppixiv.location) != null && this.available_for_url(ppixiv.location); }; // Given a list of tags, return the URL to use to search for them. This differs // depending on the current page. get_url_for_tag_search(tags, url) { url = helpers.get_url_without_language(url); let type = helpers.get_page_type_from_url(url); if(type == "tags") { // If we're on search already, just change the search tag, so we preserve other settings. // /tags/tag/artworks -> /tag/new tag/artworks let parts = url.pathname.split("/"); parts[2] = encodeURIComponent(tags); url.pathname = parts.join("/"); } else { // If we're not, change to search and remove the rest of the URL. url = new URL("/tags/" + encodeURIComponent(tags) + "/artworks#ppixiv", url); } return url; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/page_manager.js `; ppixiv.resources["src/remove_link_interstitial.js"] = `"use strict"; // Fix Pixiv's annoying link interstitials. // // External links on Pixiv go through a pointless extra page. This seems like // they're trying to mask the page the user is coming from, but that's what // rel=noreferrer is for. Search for these links and fix them. // // This also removes target=_blank, which is just obnoxious. If I want a new // tab I'll middle click. (function() { // Ignore iframes. if(window.top != window.self) return; var observer = new window.MutationObserver(function(mutations) { for(var mutation of mutations) { if(mutation.type != 'childList') return; for(var node of mutation.addedNodes) { if(node.querySelectorAll == null) continue; helpers.fix_pixiv_links(node); } } }); window.addEventListener("DOMContentLoaded", function() { helpers.fix_pixiv_links(document.body); observer.observe(window.document.body, { // We could listen to attribute changes so we'll fix links that have their // target changed after they're added to the page, but unless there are places // where that's needed, let's just listen to node additions so we don't trigger // too often. attributes: false, childList: true, subtree: true }); }, true); })(); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/remove_link_interstitial.js `; ppixiv.resources["src/image_preloading.js"] = `"use strict"; // Handle preloading images. // // If we have a reasonably fast connection and the site is keeping up, we can just preload // blindly and let the browser figure out priorities. However, if we preload too aggressively // for the connection and loads start to back up, it can cause image loading to become delayed. // For example, if we preload 100 manga page images, and then back out of the page and want to // view something else, the browser won't load anything else until those images that we no // longer need finish loading. // // image_preloader is told the illust_id that we're currently showing, and the ID that we want // to speculatively load. We'll run loads in parallel, giving the current image's resources // priority and cancelling loads when they're no longer needed. // // This doesn't handle thumbnail preloading. Those are small and don't really need to be // cancelled, and since we don't fill the browser's load queue here, we shouldn't prevent // thumbnails from being able to load. // A base class for fetching a single resource: class preloader { constructor() { this.abort_controller = new AbortController(); } // Cancel the fetch. cancel() { if(this.abort_controller == null) return; this.abort_controller.abort(); this.abort_controller = null; } } // Load a single image with : class img_preloader extends preloader { constructor(url) { super(); this.url = url; } // Start the fetch. This should only be called once. async start() { await helpers.decode_image(this.url, this.abort_controller.signal); } } // Load a resource with fetch. class fetch_preloader extends preloader { constructor(url) { super(); this.url = url; } async start() { let request = await helpers.send_pixiv_request({ url: this.url, method: "GET", signal: this.abort_controller.signal, }); // Wait for the body to download before completing. Ignore errors here (they'll // usually be cancellations). try { await request.text(); } catch(e) { } } } // The image preloader singleton. ppixiv.image_preloader = class { // Return the singleton, creating it if needed. static get singleton() { if(image_preloader._singleton == null) image_preloader._singleton = new image_preloader(); return image_preloader._singleton; }; constructor() { // The _preloader objects that we're currently running. this.preloads = []; // A queue of URLs that we've finished preloading recently. We use this to tell if // we don't need to run a preload. this.recently_preloaded_urls = []; } // Set the illust_id the user is currently viewing. If illust_id is null, the user isn't // viewing an image (eg. currently viewing thumbnails). async set_current_image(illust_id, page) { if(this.current_illust_id == illust_id && this.current_illust_page == page) return; this.current_illust_id = illust_id; this.current_illust_page = page; this.current_illust_info = null; if(this.current_illust_id == null) return; // Get the image data. This will often already be available. let illust_info = await image_data.singleton().get_image_info(this.current_illust_id); if(this.current_illust_id != illust_id || this.current_illust_info != null) return; // Stop if the illust was changed while we were loading. if(this.current_illust_id != illust_id && this.current_illust_page != page) return; // Store the illust_info for current_illust_id. this.current_illust_info = illust_info; // Preload thumbnails. this.preload_thumbs(illust_info); this.check_fetch_queue(); } // Set the illust_id we want to speculatively load, which is the next or previous image in // the current search. If illust_id is null, we don't want to speculatively load anything. // If page is -1, the caller wants to preload the last manga page. async set_speculative_image(illust_id, page) { if(this.speculative_illust_id == illust_id && this.speculative_illust_page == page) return; this.speculative_illust_id = illust_id; this.speculative_illust_page = page; this.speculative_illust_info = null; if(this.speculative_illust_id == null) return; // Get the image data. This will often already be available. let illust_info = await image_data.singleton().get_image_info(this.speculative_illust_id); if(this.speculative_illust_id != illust_id || this.speculative_illust_info != null) return; // Stop if the illust was changed while we were loading. if(this.speculative_illust_id != illust_id && this.speculative_illust_page != page) return; // Store the illust_info for current_illust_id. this.speculative_illust_info = illust_info; // Preload thumbnails. this.preload_thumbs(illust_info); this.check_fetch_queue(); } // See if we need to start or stop preloads. We do this when we have new illustration info, // and when a fetch finishes. check_fetch_queue() { // console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null); // Make a list of fetches that we want to be running, in priority order. let wanted_preloads = []; if(this.current_illust_info != null) wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info, this.current_illust_page)); if(this.speculative_illust_info != null) wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info, this.speculative_illust_page)); // Remove all preloads from wanted_preloads that we've already finished recently. let filtered_preloads = []; for(let preload of wanted_preloads) { if(this.recently_preloaded_urls.indexOf(preload.url) == -1) filtered_preloads.push(preload); } // If we don't want any preloads, stop. If we have any running preloads, let them continue. if(filtered_preloads.length == 0) { // console.log("Nothing to do"); return; } // Discard preloads beyond the number we want to be running. If we're loading more than this, // we'll start more as these finish. let concurrent_preloads = 5; filtered_preloads.splice(concurrent_preloads); // console.log("Preloads:", filtered_preloads.length); // If any preload in the list is running, stop. We only run one preload at a time, so just // let it finish. let any_preload_running = false; for(let preload of filtered_preloads) { let active_preload = this._find_active_preload_by_url(preload.url); if(active_preload != null) return; } // No preloads are running, so start the highest-priority preload. // // updated_preload_list allows us to run multiple preloads at a time, but we currently // run them in serial. let unwanted_preloads; let updated_preload_list = []; for(let preload of filtered_preloads) { // Start this preload. // console.log("Start preload:", preload.url); let promise = preload.start(); let aborted = false; promise.catch((e) => { if(e.name == "AbortError") aborted = true; }); promise.finally(() => { // Add the URL to recently_preloaded_urls, so we don't try to preload this // again for a while. We do this even on error, so we don't try to load // failing images repeatedly. // // Don't do this if the request was aborted, since that just means the user // navigated away. if(!aborted) { this.recently_preloaded_urls.push(preload.url); this.recently_preloaded_urls.splice(0, this.recently_preloaded_urls.length - 1000); } // When the preload finishes (successful or not), remove it from the list. let idx = this.preloads.indexOf(preload); if(idx == -1) { console.error("Preload finished, but we weren't running it:", preload.url); return; } this.preloads.splice(idx, 1); // See if we need to start another preload. this.check_fetch_queue(); }); updated_preload_list.push(preload); break; } // Cancel preloads in this.preloads that aren't in updated_preload_list. These are // preloads that we either don't want anymore, or which have been pushed further down // the priority queue and overridden. for(let preload of this.preloads) { if(updated_preload_list.indexOf(preload) != -1) continue; console.log("Cancelling preload:", preload.url); preload.cancel(); // Preloads stay in the list until the cancellation completes. updated_preload_list.push(preload); } this.preloads = updated_preload_list; } // Return the preloader if we're currently preloading url. _find_active_preload_by_url(url) { for(let preload of this.preloads) if(preload.url == url) return preload; return null; } // Return an array of preloaders to load resources for the given illustration. create_preloaders_for_illust(illust_data, page) { // Don't precache muted images. if(muting.singleton.any_tag_muted(illust_data.tags.tags)) return []; if(muting.singleton.is_muted_user_id(illust_data.userId)) return []; // If this is a video, preload the ZIP. if(illust_data.illustType == 2) { let results = []; results.push(new fetch_preloader(illust_data.ugoiraMetadata.originalSrc)); // Preload the original image too, which viewer_ugoira displays if the ZIP isn't // ready yet. results.push(new img_preloader(illust_data.urls.original)); return results; } // If page is -1, preload the last page. if(page == -1) page = illust_data.mangaPages.length-1; // Otherwise, preload the images. Preload thumbs first, since they'll load // much faster. let results = []; for(let page of illust_data.mangaPages) results.push(new img_preloader(page.urls.small)); // Preload the requested page. if(page < illust_data.mangaPages.length) results.push(new img_preloader(illust_data.mangaPages[page].urls.original)); return results; } preload_thumbs(illust_info) { if(illust_info == null) return; // We're only interested in preloading thumbs for manga pages. if(illust_info.pageCount < 2) return; // Preload thumbs directly rather than queueing, since they load quickly and // this reduces flicker in the manga thumbnail bar. let thumbs = []; for(let page of illust_info.mangaPages) thumbs.push(page.urls.small); helpers.preload_images(thumbs); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/image_preloading.js `; ppixiv.resources["src/whats_new.js"] = `"use strict"; // This should be inside whats_new, but Firefox is in the dark ages and doesn't support class fields. let _update_history = [ { version: 104, text: "Bookmarks can now be shuffled, to view them in random order. " + "

" + "Bookmarking an image now always likes it, like Pixiv's mobile app. " + "(Having an option for this didn't seem useful.)" + "

" + "Added a Recent History search, to show recent search results. This can be turned " + "off in settings." }, { version: 102, boring: true, text: "Animations now start playing much faster." }, { version: 100, text: "Enabled auto-liking images on bookmark by default, to match the Pixiv mobile apps. " + "If you've previously changed this in preferences, your setting should stay the same." + "

" + "Added a download button for the current page when viewing manga posts." }, { version: 97, text: "Holding Ctrl now displays the popup menu, to make it easier to use for people on touchpads.

" + "

" + "Keyboard hotkeys reworked, and can now be used while hovering over search results.

" + "

" +
	    "Ctrl-V           - like image\\n" +
	    "Ctrl-B           - bookmark\\n" +
	    "Ctrl-Alt-B       - bookmark privately\\n" +
	    "Ctrl-Shift-B     - remove bookmark\\n" +
	    "Ctrl-Alt-Shift-M - add bookmark tag\\n" +
	    "Ctrl-F           - follow\\n" +
	    "Ctrl-Alt-F       - follow privately\\n" +
	    "Ctrl-Shift-F     - unfollow\\n" +
	    "
" }, { version: 89, text: "Reworked zooming to make it more consistent and easier to use.

" + "

" + "You can now zoom images to 100% to view them at actual size." }, { version: 82, text: "Press Ctrl-Alt-Shift-B to bookmark an image with a new tag." }, { version: 79, text: "Added support for viewing new R-18 works by followed users." }, { version: 77, text: "Added user searching." + "

" + "Commercial/subscription links in user profiles (Fanbox, etc.) now use a different icon." }, { version: 74, text: "Viewing your followed users by tag is now supported." + "

" + "You can now view other people who bookmarked an image, to see what else they've bookmarked. " + "This is available from the top-left hover menu." }, { version: 72, text: "The followed users page now remembers which page you were on if you reload the page, to make " + "it easier to browse your follows if you have a lot of them." + "

" + "Returning to followed users now flashes who you were viewing like illustrations do," + "to make it easier to pick up where you left off." + "

" + "Added a browser back button to the context menu, to make navigation easier in fullscreen " + "when the browser back button isn't available." }, { version: 68, text: "You can now go to either the first manga page or the page list from search results. " + "Click the image to go to the first page, or the page count to go to the page list." + "

" + "Our button is now in the bottom-left when we're disabled, since Pixiv now puts a menu " + "button in the top-left and we were covering it up." }, { version: 65, text: "Bookmark viewing now remembers which page you were on if the page is reloaded." + "

"+ "Zooming is now in smaller increments, to make it easier to zoom to the level you want." }, { version: 57, text: "Search for similar artists. Click the recommendations item at the top of the artist page, " + "or in the top-left when viewing an image." + "

"+ "You can also now view suggested artists." }, { version: 56, text: "Tag translations are now supported. This can be turned off in preferences. " + "

" + "Added quick tag search editing. After searching for a tag, click the edit button " + "to quickly add and remove tags." }, { version: 55, text: "The \\"original\\" view is now available in Rankings." + "

" + "Hiding the mouse cursor can now be disabled in preferences.", }, { version: 49, text: "Add \\"Hover to show UI\\" preference, which is useful for low-res monitors." }, { version: 47, text: "You can now view the users you're following with \\"Followed Users\\". This shows each " + "user's most recent post." }, ]; ppixiv.whats_new = class { // Return the newest revision that exists in history. This is always the first // history entry. static latest_history_revision() { return _update_history[0].version; } // Return the latest interesting history entry. // // We won't highlight the "what's new" icon for boring history entries. static latest_interesting_history_revision() { for(let history of _update_history) { if(history.boring) continue; return history.version; } // We shouldn't get here. throw Error("Couldn't find anything interesting"); } constructor(container) { this.container = container; this.refresh(); this.container.querySelector(".close-button").addEventListener("click", (e) => { this.hide(); }); // Close if the container is clicked, but not if something inside the container is clicked. this.container.addEventListener("click", (e) => { if(e.target != this.container) return; this.hide(); }); // Hide on any state change. window.addEventListener("popstate", (e) => { this.hide(); }); this.show(); } refresh() { let items_box = this.container.querySelector(".items"); // Not really needed, since our contents never change helpers.remove_elements(items_box); let item_template = document.body.querySelector(".template-version-history-item"); for(let update of _update_history) { let entry = helpers.create_from_template(item_template); entry.querySelector(".rev").innerText = "r" + update.version; entry.querySelector(".text").innerHTML = update.text; items_box.appendChild(entry); } } show() { this.container.hidden = false; } hide() { this.container.hidden = true; } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/whats_new.js `; ppixiv.resources["src/send_image.js"] = `"use strict"; // This handles sending images from one tab to another. ppixiv.SendImage = class { // This is a singleton, so we never close this channel. static send_image_channel = new BroadcastChannel("ppixiv:send-image"); // A UUID we use to identify ourself to other tabs: static tab_id = this.create_tab_id(); static tab_id_tiebreaker = Date.now() static create_tab_id(recreate=false) { // If we have a saved tab ID, use it. if(!recreate && sessionStorage.ppixivTabId) return sessionStorage.ppixivTabId; // Make a new ID, and save it to the session. This helps us keep the same ID // when we're reloaded. sessionStorage.ppixivTabId = helpers.create_uuid(); return sessionStorage.ppixivTabId; } static known_tabs = {}; static initialized = false; static init() { if(this.initialized) return; this.initialized = true; this.broadcast_tab_info = this.broadcast_tab_info.bind(this); window.addEventListener("unload", this.window_onunload.bind(this)); // Let other tabs know when the info we send in tab info changes. For resize, delay this // a bit so we don't spam broadcasts while the user is resizing the window. window.addEventListener("resize", (e) => { if(this.broadcast_info_after_resize_timer != -1) clearTimeout(this.broadcast_info_after_resize_timer); this.broadcast_info_after_resize_timer = setTimeout(this.broadcast_tab_info, 250); }); window.addEventListener("visibilitychange", this.broadcast_tab_info); document.addEventListener("windowtitlechanged", this.broadcast_tab_info); // Send on window focus change, so we update things like screenX/screenY that we can't // monitor. window.addEventListener("focus", this.broadcast_tab_info); window.addEventListener("blur", this.broadcast_tab_info); window.addEventListener("popstate", this.broadcast_tab_info); SendImage.send_image_channel.addEventListener("message", this.received_message.bind(this)); this.broadcast_tab_info(); this.query_tabs(); this.install_quick_view(); } // If we're sending an image and the page is unloaded, try to cancel it. This is // only registered when we're sending an image. static window_onunload(e) { // Tell other tabs that this tab has closed. SendImage.send_message({ message: "tab-closed" }); } static query_tabs() { SendImage.send_message({ message: "list-tabs" }); } // Send an image to another tab. action is either "quick-view", to show the image temporarily, // or "display", to navigate to it. static async send_image(illust_id, page, tab_id, action) { // Send everything we know about the image, so the receiver doesn't have to // do a lookup. let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); let illust_data = image_data.singleton().get_image_info_sync(illust_id); let user_id = illust_data?.userId; let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null; this.send_message({ message: "send-image", from: SendImage.tab_id, to: tab_id, illust_id: illust_id, page: page, action: action, // "quick-view" or "display" thumbnail_info: thumbnail_info, illust_data: illust_data, user_info: user_info, }, true); } static received_message(e) { let data = e.data; if(data.message == "tab-info") { // Info about a new tab, or a change in visibility. // // This may contain thumbnail and illust info. We don't register it here. It // can be used explicitly when we're displaying a tab thumbnail, but each tab // might have newer or older image info, and propagating them back and forth // could be confusing. if(data.from == SendImage.tab_id) { // The other tab has the same ID we do. The only way this normally happens // is if a tab is duplicated, which will duplicate its sessionStorage with it. // If this happens, use tab_id_tiebreaker to decide who wins. The tab with // the higher value will recreate its tab ID. This is set to the time when // we're loaded, so this will usually cause new tabs to be the one to create // a new ID. if(SendImage.tab_id_tiebreaker >= data.tab_id_tiebreaker) { console.log("Creating a new tab ID due to ID conflict"); SendImage.tab_id = SendImage.create_tab_id(true /* recreate */ ); } else console.log("Tab ID conflict (other tab will create a new ID)"); // Broadcast info. If we recreated our ID then we want to broadcast it on the // new ID. If we didn't, we still want to broadcast it to replace the info // the other tab just sent on our ID. this.broadcast_tab_info(); } this.known_tabs[data.from] = data; } else if(data.message == "tab-closed") { delete this.known_tabs[data.from]; } else if(data.message == "list-tabs") { // A new tab is populating its tab list. this.broadcast_tab_info(); } else if(data.message == "send-image") { // If to is null, a tab is sending a quick view preview. Show this preview if this // tab is a quick view target and the document is visible. Otherwise, to is a tab // ID, so only preview if it's us. if(data.to == null) { if(settings.get("no_receive_quick_view") || document.hidden) { console.log("Not receiving quick view"); return; } } else if(data.to != SendImage.tab_id) return; // If this message has illust info or thumbnail info, register it. let thumbnail_info = data.thumbnail_info; if(thumbnail_info != null) thumbnail_data.singleton().loaded_thumbnail_info([thumbnail_info], "normal"); let user_info = data.user_info; if(user_info != null) image_data.singleton().add_user_data(user_info); let illust_data = data.illust_data; if(illust_data != null) image_data.singleton().add_illust_data(illust_data); // To finalize, just remove preview and quick-view from the URL to turn the current // preview into a real navigation. This is slightly different from sending "display" // with the illust ID, since it handles navigation during quick view. if(data.action == "finalize") { let args = ppixiv.helpers.args.location; args.hash.delete("virtual"); args.hash.delete("quick-view"); ppixiv.helpers.set_page_url(args, false, "navigation"); return; } if(data.action == "cancel") { this.hide_preview_image(); return; } // Otherwise, we're displaying an image. quick-view displays in quick-view+virtual // mode, display just navigates to the image normally. console.assert(data.action == "quick-view" || data.action == "display"); // Show the image. main_controller.singleton.show_illust(data.illust_id, { page: data.page, quick_view: data.action == "quick-view", source: "quick-view", // When we first show a preview, add it to history. If we show another image // or finalize the previewed image while we're showing a preview, replace the // preview history entry. add_to_history: !ppixiv.history.virtual, }); } else if(data.message == "preview-mouse-movement") { // Ignore this message if we're not displaying a quick view image. if(!ppixiv.history.virtual) return; // The mouse moved in the tab that's sending quick view. Broadcast an event // like pointermove. let event = new PointerEvent("quickviewpointermove", { movementX: data.x, movementY: data.y, }); window.dispatchEvent(event); } } static broadcast_tab_info() { let screen = main_controller.singleton.displayed_screen; let illust_id = screen? screen.displayed_illust_id:null; let page = screen? screen.displayed_illust_page:null; let thumbnail_info = illust_id? thumbnail_data.singleton().get_one_thumbnail_info(illust_id):null; let illust_data = illust_id? image_data.singleton().get_image_info_sync(illust_id):null; let user_id = illust_data?.userId; let user_info = user_id? image_data.singleton().get_user_info_sync(user_id):null; let our_tab_info = { message: "tab-info", tab_id_tiebreaker: SendImage.tab_id_tiebreaker, visible: !document.hidden, title: document.title, window_width: window.innerWidth, window_height: window.innerHeight, screen_x: window.screenX, screen_y: window.screenY, illust_id: illust_id, page: page, // Include whatever we know about this image, so if we want to display this in // another tab, we don't have to look it up again. thumbnail_info: thumbnail_info, illust_data: illust_data, user_info: user_info, }; // Add any extra data we've been given. for(let key in this.extra_data) our_tab_info[key] = this.extra_data[key]; this.send_message(our_tab_info); // Add us to our own known_tabs. this.known_tabs[SendImage.tab_id] = our_tab_info; } // Allow adding extra data to tab info. This lets us include things like the image // zoom position without having to propagate it around. static extra_data = {}; static set_extra_data(key, data, send_immediately) { this.extra_data[key] = data; if(send_immediately) this.broadcast_tab_info(); } static send_message(data, send_to_self) { // Include the tab ID in all messages. data.from = this.tab_id; this.send_image_channel.postMessage(data); if(send_to_self) { // Make a copy of data, so we don't modify the caller's copy. data = JSON.parse(JSON.stringify(data)); // Set self to true to let us know that this is our own message. data.self = true; this.send_image_channel.dispatchEvent(new MessageEvent("message", { data: data })); } } // This is called if something else changes the illust while we're in quick view. Send it // as a quick-view instead. static illust_change_during_quick_view(illust_id, page) { // This should only happen while we're in quick view. console.assert(ppixiv.history.virtual); SendImage.send_image(illust_id, page, null, "quick-view"); } // If we're currently showing a preview image sent from another tab, back out to // where we were before. static hide_preview_image() { let was_in_preview = ppixiv.history.virtual; if(!was_in_preview) return; ppixiv.history.back(); } static install_quick_view() { let setup = () => { // Remove old event handlers. if(this.quick_view_active) { this.quick_view_active.abort(); this.quick_view_active = null; } // Stop if quick view isn't enabled. if(!settings.get("quick_view")) return; this.quick_view_active = new AbortController(); window.addEventListener("click", this.quick_view_window_onclick, { signal: this.quick_view_active.signal, capture: true }); new ppixiv.pointer_listener({ element: window, button_mask: 0b11, signal: this.quick_view_active.signal, callback: this.quick_view_pointerevent, }); }; // Set up listeners, and update them when the quick view setting changes. setup(); settings.register_change_callback("quick_view", setup); } static quick_view_started(pointer_id) { // Hide the cursor, and capture the cursor to the document so it stays hidden. document.body.style.cursor = "none"; this.captured_pointer_id = pointer_id; document.body.setPointerCapture(this.captured_pointer_id); // Pause thumbnail animations, so they don't keep playing while viewing an image // in another tab. document.body.classList.add("pause-thumbnail-animation"); // Listen to pointer movement during quick view. window.addEventListener("pointermove", this.quick_view_window_onpointermove); window.addEventListener("contextmenu", this.quick_view_window_oncontextmenu, { capture: true }); } static quick_view_stopped() { if(this.captured_pointer_id != null) { document.body.releasePointerCapture(this.captured_pointer_id); this.captured_pointer_id = null; } document.body.classList.remove("pause-thumbnail-animation"); window.removeEventListener("pointermove", this.quick_view_window_onpointermove); window.removeEventListener("contextmenu", this.quick_view_window_oncontextmenu, { capture: true }); document.body.style.cursor = ""; } static finalize_quick_view() { this.quick_view_stopped(); SendImage.send_message({ message: "send-image", action: "finalize", to: null }, true); } static quick_view_pointerevent = (e) => { if(e.pressed && e.mouseButton == 0) { // See if the click is on an image search result. let { illust_id, page } = main_controller.singleton.get_illust_at_element(e.target); if(illust_id == null) return; e.preventDefault(); e.stopImmediatePropagation(); // This should never happen, but make sure we don't register duplicate pointermove events. if(this.previewing_image) return; // Quick view this image. this.previewing_image = true; SendImage.send_image(illust_id, page, null, "quick-view"); this.quick_view_started(e.pointerId); } // Right-clicking while quick viewing an image locks the image, so it doesn't go away // when the LMB is released. if(e.pressed && e.mouseButton == 1) { if(!this.previewing_image) return; e.preventDefault(); e.stopImmediatePropagation(); this.finalize_quick_view(); } // Releasing LMB while previewing an image stops previewing. if(!e.pressed && e.mouseButton == 0) { if(!this.previewing_image) return; this.previewing_image = false; e.preventDefault(); e.stopImmediatePropagation(); this.quick_view_stopped(); SendImage.send_message({ message: "send-image", action: "cancel", to: null }, true); } } static quick_view_window_onclick = (e) => { if(e.button != 0) return; // Work around one of the oldest design mistakes: cancelling mouseup doesn't prevent // the resulting click. Check if this click was on an element that was handled by // quick view, and cancel it if it was. let { illust_id, page } = main_controller.singleton.get_illust_at_element(e.target); if(illust_id == null) return; e.preventDefault(); e.stopImmediatePropagation(); } // Work around another wonderful bug: while pointer lock is active, we don't get pointerdown // events for *other* mouse buttons. That doesn't make much sense. Work around it by // assuming RMB will fire contextmenu. static quick_view_window_oncontextmenu = (e) => { console.log("context", e.button); e.preventDefault(); e.stopImmediatePropagation(); this.finalize_quick_view(); } // This is only registered while we're quick viewing, to send mouse movements to // anything displaying the image. static quick_view_window_onpointermove = (e) => { SendImage.send_message({ message: "preview-mouse-movement", x: e.movementX, y: e.movementY, }, true); } }; // A context menu widget showing known tabs on the desktop to send images to. ppixiv.send_image_widget = class extends ppixiv.illust_widget { constructor(container) { let contents = helpers.create_from_template(".template-popup-send-image"); container.appendChild(contents); super(contents); this.dropdown_list = this.container.querySelector(".list"); // Close the dropdown if the popup menu is closed. new view_hidden_listener(this.container, (e) => { this.visible = false; }); // Refresh when the image data changes. image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } get visible() { return this.container.classList.contains("visible"); } set visible(value) { if(this.container.classList.contains("visible") == value) return; helpers.set_class(this.container, "visible", value); if(value) { // Refresh when we're displayed. this.refresh(); } else { // Make sure we don't leave a tab highlighted if we're hidden. this.unhighlight_tab(); } } // Stop highlighting a tab. unhighlight_tab() { if(this.previewing_on_tab == null) return; // Stop previewing the tab. SendImage.send_message({ message: "send-image", action: "cancel", to: this.previewing_on_tab }, true); this.previewing_on_tab = null; } refresh() { // Clean out the old tab list. var old_tab_entries = this.container.querySelectorAll(".tab-entry"); for(let entry of old_tab_entries) entry.remove(); // Make sure the dropdown is hidden if we have no image. if(this._illust_id == null) this.visible = false; if(!this.visible) return; // Start preloading the image and image data, so it gives it a head start to be cached // when the other tab displays it. We don't need to wait for this to display our UI. image_data.singleton().get_image_info(this._illust_id).then((illust_data) => { helpers.preload_images([illust_data.urls.original]); }); let tab_ids = Object.keys(SendImage.known_tabs); // We'll create icons representing the aspect ratio of each tab. This is a quick way // to identify tabs when they have different sizes. Find the max width and height of // any tab, so we can scale relative to it. let max_width = 1, max_height = 1; let desktop_min_x = 999999, desktop_max_x = -999999; let desktop_min_y = 999999, desktop_max_y = -999999; let found_any = false; for(let tab_id of tab_ids) { let info = SendImage.known_tabs[tab_id]; if(!info.visible) continue; desktop_min_x = Math.min(desktop_min_x, info.screen_x); desktop_min_y = Math.min(desktop_min_y, info.screen_y); desktop_max_x = Math.max(desktop_max_x, info.screen_x + info.window_width); desktop_max_y = Math.max(desktop_max_y, info.screen_y + info.window_height); max_width = Math.max(max_width, info.window_width); max_height = Math.max(max_height, info.window_height); if(tab_id != SendImage.tab_id) found_any = true; } // If there are no tabs other than ourself, show the intro. this.container.querySelector(".no-other-tabs").hidden = found_any; if(!found_any) { this.dropdown_list.style.width = this.dropdown_list.style.height = ""; return; } let desktop_width = desktop_max_x - desktop_min_x; let desktop_height = desktop_max_y - desktop_min_y; // Scale the maximum dimension of the largest tab to a fixed size, and the other // tabs relative to it, so we show the relative shape and dimensions of each tab. let max_dimension = 400; let scale_by = max_dimension / Math.max(desktop_width, desktop_height); // Scale the overall size to fit. desktop_width *= scale_by; desktop_height *= scale_by; let offset_x_by = -desktop_min_x * scale_by; let offset_y_by = -desktop_min_y * scale_by; // Set the size of the containing box. this.dropdown_list.style.width = \`\${desktop_width}px\`; this.dropdown_list.style.height = \`\${desktop_height}px\`; // If the popup is off the screen, shift it in. We don't do this with the popup // menu to keep buttons in the same relative positions all the time, but this popup // tends to overflow. { this.container.style.margin = "0"; // reset let [actual_pos_x, actual_pos_y] = helpers.get_relative_pos(this.dropdown_list, document.body); let wanted_pos_x = helpers.clamp(actual_pos_x, 20, window.innerWidth-desktop_width-20); let wanted_pos_y = helpers.clamp(actual_pos_y, 20, window.innerHeight-desktop_height-20); let shift_window_x = wanted_pos_x - actual_pos_x; let shift_window_y = wanted_pos_y - actual_pos_y; this.container.style.marginLeft = \`\${shift_window_x}px\`; this.container.style.marginTop = \`\${shift_window_y}px\`; } // Add an entry for each tab we know about. for(let tab_id of tab_ids) { let info = SendImage.known_tabs[tab_id]; // For now, only show visible tabs. if(!info.visible) continue; let entry = this.create_tab_entry(tab_id); this.dropdown_list.appendChild(entry); // Position this entry. let left = info.screen_x * scale_by + offset_x_by; let top = info.screen_y * scale_by + offset_y_by; let width = info.window_width * scale_by; let height = info.window_height * scale_by; entry.style.left = \`\${left}px\`; entry.style.top = \`\${top}px\`; entry.style.width = \`\${width}px\`; entry.style.height =\`\${height}px\`; entry.style.display = "block"; } } create_tab_entry(tab_id) { let info = SendImage.known_tabs[tab_id]; let entry = helpers.create_from_template(".template-send-image-tab"); entry.dataset.tab_id = tab_id; if(info.visible) entry.classList.add("tab-visible"); // If this tab is our own window: if(tab_id == SendImage.tab_id) entry.classList.add("self"); // Set the image. let img = entry.querySelector(".shown-image"); img.hidden = true; let image_url = null; if(info.illust_data) { image_url = info.illust_data.urls.small; if(info.page > 0) image_url = info.illust_data.mangaPages[info.page].urls.small; } else if(info.thumbnail_info) { image_url = info.thumbnail_info.url; } if(image_url && info.illust_screen_pos) { img.hidden = false; img.src = image_url; // Position the image in the same way it is in the tab. The container is the same // dimensions as the window, so we can just copy the final percentages. img.style.top = \`\${info.illust_screen_pos.top*100}%\`; img.style.left = \`\${info.illust_screen_pos.left*100}%\`; img.style.width = \`\${info.illust_screen_pos.width*100}%\`; img.style.height = \`\${info.illust_screen_pos.height*100}%\`; } else { // Show the mock search image if we have no image URL. entry.querySelector(".search-tab").hidden = false; } // We don't need mouse event listeners for ourself. if(tab_id == SendImage.tab_id) return entry; entry.addEventListener("click", (e) => { if(tab_id == SendImage.tab_id) return; // On click, send the image for display, and close the dropdown. SendImage.send_image(this._illust_id, this._page, tab_id, "display"); this.visible = false; this.previewing_on_tab = null; }); entry.addEventListener("mouseenter", (e) => { let entry = e.target.closest(".tab-entry"); if(!entry) return; SendImage.send_image(this._illust_id, this._page, tab_id, "quick-view"); this.previewing_on_tab = tab_id; }); entry.addEventListener("mouseleave", (e) => { let entry = e.target.closest(".tab-entry"); if(!entry) return; this.unhighlight_tab(); }); return entry; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/send_image.js `; ppixiv.resources["src/main.js"] = `"use strict"; // This handles high-level navigation and controlling the different screens. ppixiv.main_controller = class { // This is called by bootstrap at startup. Just create ourself. static launch() { new this; } static get singleton() { if(main_controller._singleton == null) throw "main_controller isn't created"; return main_controller._singleton; } constructor() { if(main_controller._singleton != null) throw "main_controller is already created"; main_controller._singleton = this; this.initial_setup(); } async initial_setup() { // If we're not active, just see if we need to add our button, and stop without messing // around with the page more than we need to. if(!page_manager.singleton().active) { console.log("ppixiv is currently disabled"); await helpers.wait_for_content_loaded(); this.setup_disabled_ui(); return; } console.log("ppixiv setup"); // Install polyfills. Make sure we only do this if we're active, so we don't // inject polyfills into Pixiv when we're not active. install_polyfills(); // Run cleanup_environment. This will try to prevent the underlying page scripts from // making network requests or creating elements, and apply other irreversible cleanups // that we don't want to do before we know we're going to proceed. helpers.cleanup_environment(); this.temporarily_hide_document(); // Wait for DOMContentLoaded to continue. await helpers.wait_for_content_loaded(); // Continue with full initialization. await this.setup(); } // This is where the actual UI starts. async setup() { console.log("ppixiv controller setup"); this.onkeydown = this.onkeydown.bind(this); this.redirect_event_to_screen = this.redirect_event_to_screen.bind(this); this.window_onclick_capture = this.window_onclick_capture.bind(this); this.window_onpopstate = this.window_onpopstate.bind(this); // Create the page manager. page_manager.singleton(); // Run any one-time settings migrations. settings.migrate(); // Migrate the translation database. We don't need to wait for this. update_translation_storage.run(); // Pixiv scripts that use meta-global-data remove the element from the page after // it's parsed for some reason. Try to get global info from document, and if it's // not there, re-fetch the page to get it. if(!this.load_global_info_from_document(document)) { if(!await this.load_global_data_async()) return; } // Set the .premium class on body if this is a premium account, to display features // that only work with premium. helpers.set_class(document.body, "premium", window.global_data.premium); // These are used to hide buttons that the user has disabled. helpers.set_class(document.body, "hide-r18", !window.global_data.include_r18); helpers.set_class(document.body, "hide-r18g", !window.global_data.include_r18g); // See if the page has preload data. This sometimes contains illust and user info // that the page will display, which lets us avoid making a separate API call for it. let preload = document.querySelector("#meta-preload-data"); if(preload != null) { preload = JSON.parse(preload.getAttribute("content")); for(var preload_user_id in preload.user) image_data.singleton().add_user_data(preload.user[preload_user_id]); for(var preload_illust_id in preload.illust) image_data.singleton().add_illust_data(preload.illust[preload_illust_id]); } window.addEventListener("click", this.window_onclick_capture); window.addEventListener("popstate", this.window_onpopstate); window.addEventListener("keyup", this.redirect_event_to_screen, true); window.addEventListener("keydown", this.redirect_event_to_screen, true); window.addEventListener("keypress", this.redirect_event_to_screen, true); window.addEventListener("keydown", this.onkeydown); this.current_screen_name = null; this.current_history_index = helpers.current_history_state_index(); // If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv // URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll // still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation // directly and the page will reload on every click. Do this before we create any of our // UI, so our links inherit the hash. if(helpers.parse_hash(ppixiv.location) == null) { // Don't create a new history state. let newURL = new URL(ppixiv.location); newURL.hash = "#ppixiv"; history.replaceState(null, "", newURL.toString()); } // Don't restore the scroll position. // // If we browser back to a search page and we were scrolled ten pages down, scroll // restoration will try to scroll down to it incrementally, causing us to load all // data in the search from the top all the way down to where we were. This can cause // us to spam the server with dozens of requests. This happens on F5 refresh, which // isn't useful (if you're refreshing a search page, you want to see new results anyway), // and recommendations pages are different every time anyway. // // This won't affect browser back from an image to the enclosing search. history.scrollRestoration = "manual"; // Remove everything from the page and move it into a dummy document. var html = document.createElement("document"); helpers.move_children(document.head, html); helpers.move_children(document.body, html); // Copy the location to the document copy, so the data source can tell where // it came from. html.location = ppixiv.location; // Now that we've cleared the document, we can unhide it. document.documentElement.hidden = false; // Add binary resources as CSS styles. helpers.add_style("noise-background", \`body .noise-background { background-image: url("\${resources['resources/noise.png']}"); };\`); helpers.add_style("light-noise-background", \`body.light .noise-background { background-image: url("\${resources['resources/noise-light.png']}"); };\`); // Add the main CSS style. helpers.add_style("main", resources['resources/main.css']); // Load image resources into blobs. await this.load_resource_blobs(); // Create the page from our HTML resource. document.body.insertAdjacentHTML("beforeend", resources['resources/main.html']); helpers.replace_inlines(document.body); // Create the shared title and page icon. document.head.appendChild(document.createElement("title")); var document_icon = document.head.appendChild(document.createElement("link")); document_icon.setAttribute("rel", "icon"); helpers.add_clicks_to_search_history(document.body); this.container = document.body; // Create the popup menu handler. this.context_menu = new main_context_menu(document.body); // Create the main progress bar. this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar")); // Create the screens. this.screen_search = new screen_search(this.container.querySelector(".screen-search-container")); this.screen_illust = new screen_illust(this.container.querySelector(".screen-illust-container")); this.screen_manga = new screen_manga(this.container.querySelector(".screen-manga-container")); SendImage.init(); this.screens = { search: this.screen_search, illust: this.screen_illust, manga: this.screen_manga, }; // Create the data source for this page. this.set_current_data_source("initialization"); }; window_onpopstate(e) { // A special case for the bookmarks data source. It changes its page in the URL to mark // how far the user has scrolled. We don't want this to trigger a data source change. if(this.temporarily_ignore_onpopstate) { console.log("Not navigating for internal page change"); return; } // Set the current data source and state. this.set_current_data_source(e.navigationCause || "history"); } async refresh_current_data_source() { if(this.data_source == null) return; // Create a new data source for the same URL, replacing the previous one. // This returns the data source, but just call set_current_data_source so // we load the new one. console.log("Refreshing data source for", ppixiv.location.toString()); page_manager.singleton().create_data_source_for_url(ppixiv.location, true); await this.set_current_data_source("refresh"); } // Create a data source for the current URL and activate it. // // This is called on startup, and in onpopstate where we might be changing data sources. async set_current_data_source(cause) { // Remember what we were displaying before we start changing things. var old_screen = this.screens[this.current_screen_name]; var old_illust_id = old_screen? old_screen.displayed_illust_id:null; var old_illust_page = old_screen? old_screen.displayed_illust_page:null; // Get the current data source. If we've already created it, this will just return // the same object and not create a new one. let data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location); // If the data source supports_start_page, and a link was clicked on a page that isn't currently // loaded, create a new data source. If we're on page 5 of bookmarks and the user clicks a link // for page 1 (the main bookmarks navigation button) or page 10, the current data source can't // display that since we'd need to load every page in-between to keep pages contiguous, so we // just create a new data source. // // This doesn't work great for jumping to arbitrary pages (we don't handle scrolling to that page // very well), but it at least makes rewinding to the first page work. if(data_source == this.data_source && data_source.supports_start_page) { let args = helpers.args.location; let wanted_page = this.data_source.get_start_page(args); let lowest_page = data_source.id_list.get_lowest_loaded_page(); let highest_page = data_source.id_list.get_highest_loaded_page(); if(wanted_page < lowest_page || wanted_page > highest_page) { // This works the same as refresh_current_data_source above. console.log("Resetting data source to an unavailable page:", lowest_page, wanted_page, highest_page); data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true); } } // If the data source is changing, set it up. if(this.data_source != data_source) { if(this.data_source != null) { // Shut down the old data source. this.data_source.shutdown(); // If the old data source was transient, discard it. if(this.data_source.transient) page_manager.singleton().discard_data_source(this.data_source); } // If we were showing a message for the old data source, it might be persistent, // so clear it. message_widget.singleton.hide(); this.data_source = data_source; this.show_data_source_specific_elements(); this.context_menu.set_data_source(data_source); if(this.data_source != null) this.data_source.startup(); } // Figure out which screen to display. var new_screen_name; let args = helpers.args.location; if(!args.hash.has("view")) new_screen_name = data_source.default_screen; else new_screen_name = args.hash.get("view"); var illust_id = data_source.get_current_illust_id(); var manga_page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0; // If we're on search, we don't care what image is current. Clear illust_id so we // tell context_menu that we're not viewing anything, so it disables bookmarking. if(new_screen_name == "search") illust_id = null; console.log("Loading data source. Screen:", new_screen_name, "Cause:", cause, "URL:", ppixiv.location.href); // Mark the current screen. Other code can watch for this to tell which view is // active. document.body.dataset.currentView = new_screen_name; let new_screen = this.screens[new_screen_name]; this.context_menu.illust_id = illust_id; this.current_screen_name = new_screen_name; // If we're changing between screens, update the active screen. let screen_changing = new_screen != old_screen; // Make sure we deactivate the old screen before activating the new one. if(old_screen != null && old_screen != new_screen) await old_screen.set_active(false, { }); if(new_screen != null) { // Restore state from history if this is an initial load (which may be // restoring a tab), for browser forward/back, or if we're exiting from // quick view (which is like browser back). This causes the pan/zoom state // to be restored. let restore_history = cause == "initialization" || cause == "history" || cause == "leaving-virtual"; await new_screen.set_active(true, { data_source: data_source, illust_id: illust_id, page: manga_page, navigation_cause: cause, restore_history: restore_history, }); } // Dismiss any message when toggling between screens. if(screen_changing) message_widget.singleton.hide(); // If we're enabling the thumbnail, pulse the image that was just being viewed (or // loading to be viewed), to make it easier to find your place. if(new_screen_name == "search" && old_illust_id != null) this.screen_search.pulse_thumbnail(old_illust_id); // Are we navigating forwards or back? var new_history_index = helpers.current_history_state_index(); var navigating_forwards = cause == "history" && new_history_index > this.current_history_index; this.current_history_index = new_history_index; // Handle scrolling for the new state. // // We could do this better with history.state (storing each state's scroll position would // allow it to restore across browser sessions, and if the same data source is multiple // places in history). Unfortunately there's no way to update history.state without // calling history.replaceState, which is slow and causes jitter. history.state being // read-only is a design bug in the history API. if(cause == "navigation") { // If this is an initial navigation, eg. from a user clicking a link to a search, always // scroll to the top. If this data source exists previously in history, we don't want to // restore the scroll position from back then. // console.log("Scroll to top for new search"); new_screen.scroll_to_top(); } else if(cause == "leaving-virtual") { // We're backing out of a virtual URL used for quick view. Don't change the scroll position. new_screen.restore_scroll_position(); } else if(navigating_forwards) { // On browser history forwards, try to restore the scroll position. // console.log("Restore scroll position for forwards navigation"); new_screen.restore_scroll_position(); } else if(screen_changing && old_illust_id != null) { // If we're navigating backwards or toggling, and we're switching from the image UI to thumbnails, // try to scroll the search screen to the image that was displayed. Otherwise, tell // it to restore any scroll position saved in the data source. // console.log("Scroll to", old_illust_id, old_illust_page); new_screen.scroll_to_illust_id(old_illust_id, old_illust_page); } else { new_screen.restore_scroll_position(); } } show_data_source_specific_elements() { // Show UI elements with this data source in their data-datasource attribute. var data_source_name = this.data_source.name; for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]")) { var data_sources = node.dataset.datasource.split(" "); var show_element = data_sources.indexOf(data_source_name) != -1; node.hidden = !show_element; } } // Show an illustration by ID. // // This actually just sets the history URL. We'll do the rest of the work in popstate. show_illust(illust_id, {page, add_to_history=false, screen="illust", quick_view=false, source=""}={}) { console.assert(illust_id != null, "Invalid illust_id", illust_id); let args = helpers.args.location; // If something else is navigating us in the middle of quick-view, such as changing // the page with the mousewheel, let SendImage handle it. It'll treat it as a quick // view and we'll end up back here with quick_view true. Don't do this if this is // already coming from quick view. if(args.hash.has("quick-view") && !quick_view && source != "quick-view") { console.log("Illust change during quick view"); SendImage.illust_change_during_quick_view(illust_id, page); return; } // Update the URL to display this illust_id. This stays on the same data source, // so displaying an illust won't cause a search to be made in the background or // have other side-effects. this._set_active_screen_in_url(args, screen); this.data_source.set_current_illust_id(illust_id, args); // Remove any leftover page from the current illust. We'll load the default. if(page == null) args.hash.delete("page"); else args.hash.set("page", page + 1); if(quick_view) { args.hash.set("virtual", "1"); args.hash.set("quick-view", "1"); } else { args.hash.delete("virtual"); args.hash.delete("quick-view"); } helpers.set_page_url(args, add_to_history, "navigation"); } // Return the displayed screen instance. get displayed_screen() { for(let screen_name in this.screens) { var screen = this.screens[screen_name]; if(screen.active) return screen; } return null; } _set_active_screen_in_url(args, screen) { args.hash.set("view", screen); // If we're going to the search or manga page, remove the page. // If we're going to the manga page, remove just the page. if(screen == "search" || screen == "manga") args.hash.delete("page"); if(screen == "search") args.hash.delete("illust_id"); } // Navigate out. // // This navigates from the illust page to the manga page (for multi-page posts) or search, and // from the manga page to search. // // This is similar to browser back, but allows moving up to the search even for new tabs. It // would be better for this to integrate with browser history (just browser back if browser back // is where we're going), but for some reason you can't view history state entries even if they're // on the same page, so there's no way to tell where History.back() would take us. get navigate_out_label() { let target = this.displayed_screen?.navigate_out_target; switch(target) { case "manga": return "page list"; case "search": return "search"; default: return null; } } navigate_out() { let new_page = this.displayed_screen?.navigate_out_target; if(new_page == null) return; // If the user clicks "return to search" while on data_sources.current_illust, go somewhere // else instead, since that viewer never has any search results. if(new_page == "search" && this.data_source instanceof data_sources.current_illust) { let args = new helpers.args("/bookmark_new_illust.php#ppixiv", ppixiv.location); helpers.set_page_url(args, true /* add_to_history */, "out"); return; } // Update the URL to mark whether thumbs are displayed. let args = helpers.args.location; this._set_active_screen_in_url(args, new_page); helpers.set_page_url(args, true /* add_to_history */, "out"); } // This captures clicks at the window level, allowing us to override them. // // When the user left clicks on a link that also goes into one of our screens, // rather than loading a new page, we just set up a new data source, so we // don't have to do a full navigation. // // This only affects left clicks (middle clicks into a new tab still behave // normally). window_onclick_capture(e) { // Only intercept regular left clicks. if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey) return; if(!(e.target instanceof Element)) return; // Look up from the target for a link. var a = e.target.closest("A"); if(a == null) return; // If this isn't a #ppixiv URL, let it run normally. var url = new URL(a.href, document.href); var is_ppixiv_url = helpers.parse_hash(url) != null; if(!is_ppixiv_url) return; // Stop all handling for this link. e.preventDefault(); e.stopImmediatePropagation(); // Search links to images always go to /artworks/#, but if they're clicked in-page we // want to stay on the same search and just show the image, so handle them directly. var url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); if(url.pathname.startsWith("/artworks/")) { let parts = url.pathname.split("/"); let illust_id = parts[2]; let args = new helpers.args(a.href); var page = args.hash.has("page")? parseInt(args.hash.get("page"))-1: null; let screen = args.hash.has("view")? args.hash.get("view"):"illust"; this.show_illust(illust_id, { screen: screen, page: page, add_to_history: true }); return; } // Navigate to the URL in-page. helpers.set_page_url(url, true /* add to history */, "navigation"); } async load_global_data_async() { // Doing this sync works better, because it console.log("Reloading page to get init data"); // Some Pixiv pages try to force cache expiry. We really don't want that to happen // here, since we just want to grab the page we're on quickly. Setting cache: force_cache // tells Chrome to give us the cached page even if it's expired. let result = await helpers.load_data_in_iframe(document.location.toString(), { cache: "force-cache", }); console.log("Finished loading init data"); if(this.load_global_info_from_document(result)) return true; // The user is probably not logged in. If this happens on this code path, we // can't restore the page. console.log("Couldn't find context data. Are we logged in?"); this.show_logout_message(true); return false; } // Load Pixiv's global info from doc. This can be the document, or a copy of the // document that we fetched separately. Return true on success. load_global_info_from_document(doc) { // Stop if we already have this. if(window.global_data) return true; // This format is used on at least /new_illust.php. let global_data = doc.querySelector("#meta-global-data"); if(global_data != null) global_data = JSON.parse(global_data.getAttribute("content")); // This is the global "pixiv" object, which is used on older pages. let pixiv = helpers.get_pixiv_data(doc); // Hack: don't use this object if we're on /history.php. It has both of these, and // this object doesn't actually have all info, but its presence will prevent us from // falling back and loading meta-global-data if needed. if(document.location.pathname == "/history.php") pixiv = null; // Discard any of these that have no login info. if(global_data && global_data.userData == null) global_data = null; if(pixiv && (pixiv.user == null || pixiv.user.id == null)) pixiv = null; if(global_data == null && pixiv == null) return false; if(global_data != null) { this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium, global_data.mute, global_data.userData.adult); } else { this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium, pixiv.user.mutes, pixiv.user.explicit); } return true; } init_global_data(csrf_token, user_id, premium, mutes, content_mode) { var muted_tags = []; var muted_user_ids = []; for(var mute of mutes) { if(mute.type == 0) muted_tags.push(mute.value); else if(mute.type == 1) muted_user_ids.push(mute.value); } muting.singleton.set_muted_tags(muted_tags); muting.singleton.set_muted_user_ids(muted_user_ids); window.global_data = { // Store the token for XHR requests. csrf_token: csrf_token, user_id: user_id, include_r18: content_mode >= 1, include_r18g: content_mode >= 2, premium: premium, }; }; // Redirect keyboard events that didn't go into the active screen. redirect_event_to_screen(e) { let screen = this.displayed_screen; if(screen == null) return; // If a popup is open, leave inputs alone. if(document.body.dataset.popupOpen) return; // If the keyboard input didn't go to an element inside the screen, redirect // it to the screen's container. var target = e.target; // If the event is going to an element inside the screen already, just let it continue. if(helpers.is_above(screen.container, e.target)) return; // Clone the event and redispatch it to the screen's container. var e2 = new e.constructor(e.type, e); if(!screen.container.dispatchEvent(e2)) { e.preventDefault(); e.stopImmediatePropagation(); return; } } onkeydown(e) { // Ignore keypresses if we haven't set up the screen yet. let screen = this.displayed_screen; if(screen == null) return; // If a popup is open, leave inputs alone and don't process hotkeys. if(document.body.dataset.popupOpen) return; if(e.keyCode == 27) // escape { e.preventDefault(); e.stopPropagation(); this.navigate_out(); return; } // Let the screen handle the input. screen.handle_onkeydown(e); } // Return the illust_id and page or user_id of the image under element. This can // be an image in the search screen, or a page in the manga screen. // // If element is an illustration and also has the user ID attached, both the user ID // and illust ID will be returned. get_illust_at_element(element) { let result = { }; if(element == null) return result; // Illustration search results have both the illust ID and the user ID on it. let illust_element = element.closest("[data-illust-id]"); if(illust_element) { result.illust_id = parseInt(illust_element.dataset.illustId); // If no page is present, set page to null rather than page 0. This distinguishes image // search results which don't refer to a specific page from the manga page display. Don't // use -1 for this, since that's used in some places to mean the last page. result.page = illust_element.dataset.pageIdx == null? null:parseInt(illust_element.dataset.pageIdx); } let user_element = element.closest("[data-user-id]"); if(user_element) result.user_id = parseInt(user_element.dataset.userId); return result; } // Load PNG resources into blobs, so we don't copy the whole PNG into every // place it's used. async load_resource_blobs() { for(let [name, dataURL] of Object.entries(ppixiv.resources)) { if(!name.endsWith(".png")) continue; let result = await fetch(dataURL); let blob = await result.blob(); let blobURL = URL.createObjectURL(blob); ppixiv.resources[name] = blobURL; } } show_logout_message(force) { // Unless forced, don't show the message if we've already shown it recently. // A session might last for weeks, so we don't want to force it to only be shown // once, but we don't want to show it repeatedly. let last_shown = window.sessionStorage.showed_logout_message || 0; let time_since_shown = Date.now() - last_shown; let hours_since_shown = time_since_shown / (60*60*1000); if(!force && hours_since_shown < 6) return; window.sessionStorage.showed_logout_message = Date.now(); alert("Please log in to use ppixiv."); } temporarily_hide_document() { if(document.documentElement != null) { document.documentElement.hidden = true; return; } // At this point, none of the document has loaded, and document.body and // document.documentElement don't exist yet, so we can't hide it. However, // we want to hide the document as soon as it's added, so we don't flash // the original page before we have a chance to replace it. Use a mutationObserver // to detect the document being created. var observer = new MutationObserver((mutation_list) => { if(document.documentElement == null) return; observer.disconnect(); document.documentElement.hidden = true; }); observer.observe(document, { attributes: false, childList: true, subtree: true }); }; // When we're disabled, but available on the current page, add the button to enable us. async setup_disabled_ui(logged_out=false) { // Wait for DOMContentLoaded for body. await helpers.wait_for_content_loaded(); // On most pages, we show our button in the top corner to enable us on that page. Clicking // it on a search page will switch to us on the same search. var disabled_ui = helpers.create_node(resources['resources/disabled.html']); helpers.replace_inlines(disabled_ui); // If we're on a page that we don't support, like the top page, rewrite the link to switch to // a page we do support. if(!page_manager.singleton().available_for_url(ppixiv.location)) disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv"; document.body.appendChild(disabled_ui); if(page_manager.singleton().available_for_url(ppixiv.location)) { // Remember that we're disabled in this tab. This way, clicking the "return // to Pixiv" button will remember that we're disabled. We do this on page load // rather than when the button is clicked so this works when middle-clicking // the button to open a regular Pixiv page in a tab. // // Only do this if we're available and disabled, which means the user disabled us. // If we wouldn't be available on this page at all, don't store it. page_manager.singleton().store_ppixiv_disabled(true); } // If we're showing this and we know we're logged out, show a message on click. // This doesn't work if we would be inactive anyway, since we don't know whether // we're logged in, so the user may need to click the button twice before actually // seeing this message. if(logged_out) { disabled_ui.querySelector("a").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.show_logout_message(true); }); } }; }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r108/src/main.js `; // Note that this file doesn't use strict, because JS language developers remove // useful features without a second thought. "with" may not be used often, but // it's an important part of the language. (() => { // If we're in a release build, we're inside // (function () { // with(this) // { // ... // } // }.exec({}); // // The empty {} object is our environment. It can be assigned to as "this" at the // top level of scripts, and it's included in scope using with(this) so it's searched // as a global scope. // // If we're in a debug build, this script runs standalone, and we set up the environment // here. // Our source files are stored as text, so we can attach sourceURL to them to give them // useful filenames. "this" is set to the ppixiv context, and we load them out here so // we don't have many locals being exposed as globals during the eval. We also need to // do this out here in order ot use with. let _load_source_file = function(__pixiv, __source) { const ppixiv = __pixiv; with(ppixiv) { return eval(__source); } }; new class { constructor(env) { // If this is an iframe, don't do anything. if(window.top != window.self) return; // Don't activate for things like sketch.pixiv.net. if(window.location.hostname != "www.pixiv.net") return; console.log("ppixiv bootstrap"); // If env is the window, this script was run directly, which means this is a // development build and we need to do some extra setup. If this is a release build, // the environment will be set up already. if(env === window) this.devel_setup(); else this.env = env; this.launch(); } devel_setup() { // In a development build, our source and binary assets are in @resources, and we need // to pull them out into an environment manually. let env = {}; env.resources = {}; env.resources["output/setup.js"] = JSON.parse(GM_getResourceText("output/setup.js")); let setup = env.resources["output/setup.js"]; let source_list = setup.source_files; // Add the file containing binary resources to the list. source_list.unshift("output/resources.js"); for(let path of source_list) { // Load the source file. let source = GM_getResourceText(path); if(source == null) { // launch() will show an error for this, so don't do it here too. continue; } // Add sourceURL to each file, so they show meaningful filenames in logs. // Since we're loading the files as-is and line numbers don't change, we // don't need a source map. // // This uses a path that pretends to be on the same URl as the site, which // seems to be needed to make VS Code map the paths correctly. source += "\n"; source += `//# sourceURL=${document.location.origin}/ppixiv/${path}\n`; env.resources[path] = source; } this.env = env; } launch() { let setup = this.env.resources["output/setup.js"]; let source_list = setup.source_files; unsafeWindow.ppixiv = this.env; // Load each source file. for(let path of source_list) { let source = this.env.resources[path]; if(!source) { console.error("Source file missing:", path); continue; } _load_source_file(this.env, source); } // Create the main controller. this.env.main_controller.launch(); } }(this); })(); } }).call({});