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

Showing the popup menu when Ctrl is pressed is now optional. \`, }, { version: 112, text: \` Added Send to Tab to the context menu, which allows quickly sending an image to another tab.

Added a More Options dropdown to the popup menu. This includes some things that were previously only available from the hover UI. Send to Tab is also in here.

Disabled the "Similar Illustrations" lightbulb button on thumbnails. It can now be accessed from the popup menu, along with a bunch of other ways to get image recommendations. \` }, { version: 110, text: \` Added Quick View. This views images immediately when the mouse is pressed, and images can be panned with the same press.

This can be enabled in preferences, and may become the default in a future release. \` }, { version: 109, boring: true, text: \`Added a visual marker on thumbnails to show the last image you viewed.\` }, { 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 extends ppixiv.dialog_widget { // 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({...options}) { super({...options, visible: true, template: \`

Updates
\`}); 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.visible = false; }); // Hide on any state change. window.addEventListener("popstate", (e) => { this.visible = false; }); } refresh() { let items_box = this.container.querySelector(".items"); // Not really needed, since our contents never change helpers.remove_elements(items_box); for(let update of _update_history) { let entry = this.create_template({name: "item", html: \`
\`}); entry.querySelector(".rev").innerText = "r" + update.version; entry.querySelector(".text").innerHTML = update.text; items_box.appendChild(entry); } } visibility_changed() { super.visibility_changed(); if(!this.visible) { // Remove the widget when it's hidden. this.container.remove(); } } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r118/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); this.pending_movement = [0, 0]; this.listeners = {}; 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); // If we gain focus while quick view is active, finalize the image. Virtual // history isn't meant to be left enabled, since it doesn't interact with browser // history. window.addEventListener("focus", (e) => { let args = ppixiv.helpers.args.location; if(args.hash.has("temp-view")) { console.log("Finalizing quick view image because we gained focus"); args.hash.delete("virtual"); args.hash.delete("temp-view"); ppixiv.helpers.set_page_url(args, false, "navigation"); } }); SendImage.send_image_channel.addEventListener("message", this.received_message.bind(this)); this.broadcast_tab_info(); this.query_tabs(); } static add_message_listener(message, func) { if(!this.listeners[message]) this.listeners[message] = []; this.listeners[message].push(func); } // 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 "temp-view", to show the image temporarily, // or "display", to navigate to it. static async send_image(illust_id, page, tab_ids, 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_ids, illust_id: illust_id, page: page, action: action, // "temp-view" or "display" thumbnail_info: thumbnail_info, illust_data: illust_data, user_info: user_info, }, false); } static received_message(e) { let data = e.data; // If this message has a target and it's not us, ignore it. if(data.to && data.to.indexOf(SendImage.tab_id) == -1) return; // Call any listeners for this message. if(this.listeners[data.message]) { for(let func of this.listeners[data.message]) func(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 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], "internal"); 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("temp-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 == "temp-view" || data.action == "display", data.actionj); // Show the image. main_controller.singleton.show_illust(data.illust_id, { page: data.page, temp_view: data.action == "temp-view", source: "temp-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, }; this.send_message(our_tab_info); // Add us to our own known_tabs. this.known_tabs[SendImage.tab_id] = our_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 })); } } // 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 send_mouse_movement_to_linked_tabs(x, y) { let tab_ids = settings.get("linked_tabs", []); if(tab_ids.length == 0) return; this.pending_movement[0] += x; this.pending_movement[1] += y; // Limit the rate we send these, since mice with high report rates can send updates // fast enough to saturate BroadcastChannel and cause messages to back up. Add up // movement if we're sending too quickly and batch it into the next message. if(this.last_movement_message_time != null && Date.now() - this.last_movement_message_time < 10) return; this.last_movement_message_time = Date.now(); SendImage.send_message({ message: "preview-mouse-movement", x: this.pending_movement[0], y: this.pending_movement[1], to: tab_ids, }, false); this.pending_movement = [0, 0]; } }; ppixiv.link_tabs_popup = class extends ppixiv.dialog_widget { constructor({...options}) { super({...options, template: \` \`}); this.container.querySelector(".close-button").addEventListener("click", (e) => { this.visible = false; }); // 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.visible = false; }); new menu_option_toggle({ container: this.container.querySelector(".toggle-enabled"), label: "Enabled", setting: "linked_tabs_enabled", }); // Refresh the "unlink all tabs" button when the linked tab list changes. settings.register_change_callback("linked_tabs", this.refresh_unlink_all); this.refresh_unlink_all(); this.container.querySelector(".unlink-all").addEventListener("click", (e) => { settings.set("linked_tabs", []); this.send_link_tab_message(); }); // The other tab will send these messages when the link and unlink buttons // are clicked. SendImage.add_message_listener("link-this-tab", (message) => { let tab_ids = settings.get("linked_tabs", []); if(tab_ids.indexOf(message.from) == -1) tab_ids.push(message.from); settings.set("linked_tabs", tab_ids); this.send_link_tab_message(); }); SendImage.add_message_listener("unlink-this-tab", (message) => { let tab_ids = settings.get("linked_tabs", []); let idx = tab_ids.indexOf(message.from); if(idx != -1) tab_ids.splice(idx, 1); settings.set("linked_tabs", tab_ids); this.send_link_tab_message(); }); this.visible = false; } refresh_unlink_all = () => { let any_tabs_linked = settings.get("linked_tabs", []).length > 0; this.container.querySelector(".unlink-all").hidden = !any_tabs_linked; } send_link_tab_message = () => { // We should always be visible when this is called. console.assert(this.visible); SendImage.send_message({ message: "show-link-tab", linked_tabs: settings.get("linked_tabs", []), }); } visibility_changed() { super.visibility_changed(); if(!this.visible) { SendImage.send_message({ message: "hide-link-tab" }); return; } helpers.interval(this.send_link_tab_message, 1000, this.visibility_abort.signal); } } ppixiv.link_this_tab_popup = class extends ppixiv.dialog_widget { constructor({...options}) { super({...options, template: \` \`}); this.hide_timer = new helpers.timer(() => { this.visible = false; }); // Show ourself when we see a show-link-tab message and hide if we see a // hide-link-tab-message. SendImage.add_message_listener("show-link-tab", (message) => { this.other_tab_id = message.from; this.hide_timer.set(2000); let linked = message.linked_tabs.indexOf(SendImage.tab_id) != -1; this.container.querySelector(".link-this-tab").hidden = linked; this.container.querySelector(".unlink-this-tab").hidden = !linked; this.visible = true; }); SendImage.add_message_listener("hide-link-tab", (message) => { this.hide_timer.clear(); this.visible = false; }); // When "link this tab" is clicked, send a link-this-tab message. this.container.querySelector(".link-this-tab").addEventListener("click", (e) => { SendImage.send_message({ message: "link-this-tab", to: [this.other_tab_id] }); // If we're linked to another tab, clear our linked tab list, to try to make // sure we don't have weird chains of tabs linking each other. settings.set("linked_tabs", []); }); this.container.querySelector(".unlink-this-tab").addEventListener("click", (e) => { SendImage.send_message({ message: "unlink-this-tab", to: [this.other_tab_id] }); }); this.visible = false; } visibility_changed() { super.visibility_changed(); this.hide_timer.clear(); // Hide if we don't see a show-link-tab message for a few seconds, as a // safety in case the other tab dies. if(this.visible) this.hide_timer.set(2000); } } ppixiv.send_image_popup = class extends ppixiv.dialog_widget { constructor({...options}) { super({...options, template: \`
Click a tab to send the image there
\`}); // 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.visible = false; }); SendImage.add_message_listener("take-image", (message) => { let tab_id = message.from; SendImage.send_image(this.illust_id, this.page, [tab_id], "display"); this.visible = false; }); this.visible = false; } show_for_illust(illust_id, page) { this.illust_id = illust_id; this.page = page; this.visible = true; } visibility_changed() { super.visibility_changed(); if(!this.visible) { SendImage.send_message({ message: "hide-send-image" }); return; } helpers.interval(() => { // We should always be visible when this is called. console.assert(this.visible); SendImage.send_message({ message: "show-send-image" }); }, 1000, this.visibility_abort.signal); } } ppixiv.send_here_popup = class extends ppixiv.dialog_widget { constructor({...options}) { super({...options, template: \`
\`}); this.hide_timer = new helpers.timer(() => { this.visible = false; }); // Show ourself when we see a show-link-tab message and hide if we see a // hide-link-tab-message. SendImage.add_message_listener("show-send-image", (message) => { this.other_tab_id = message.from; this.hide_timer.set(2000); this.visible = true; }); SendImage.add_message_listener("hide-send-image", (message) => { this.hide_timer.clear(); this.visible = false; }); this.visible = false; } take_image = (e) => { // Send take-image. The sending tab will respond with a send-image message. SendImage.send_message({ message: "take-image", to: [this.other_tab_id] }); } visibility_changed() { super.visibility_changed(); this.hide_timer.clear(); // Hide if we don't see a show-send-image message for a few seconds, as a // safety in case the other tab dies. if(this.visible) { window.addEventListener("click", this.take_image, { signal: this.visibility_abort.signal }); this.hide_timer.set(2000); } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r118/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(); // Set up the pointer_listener singleton. pointer_listener.install_global_handler(); // 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.scss']); // 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; SendImage.init(); // Create the popup menu handler. this.context_menu = new main_context_menu({container: document.body}); this.link_tabs_popup = new link_tabs_popup({container: document.body}); this.link_this_tab_popup = new link_this_tab_popup({container: document.body}); this.send_here_popup = new send_here_popup({container: document.body}); this.send_image_popup = new send_image_popup({container: 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({ container: this.container.querySelector(".screen-search-container") }); this.screen_illust = new screen_illust({ container: this.container.querySelector(".screen-illust-container") }); this.screen_manga = new screen_manga({ container: this.container.querySelector(".screen-manga-container") }); 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); // Don't create a new data source if no pages are loaded, which can happen if // we're loaded viewing an illust. We can start from any page. let lowest_page = data_source.id_list.get_lowest_loaded_page(); let highest_page = data_source.id_list.get_highest_loaded_page(); if(data_source.id_list.any_pages_loaded && (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.set_illust(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", temp_view=false, source=""}={}) { console.assert(illust_id != null, "Invalid illust_id", illust_id); let args = helpers.args.location; // 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(temp_view) { args.hash.set("virtual", "1"); args.hash.set("temp-view", "1"); } else { args.hash.delete("virtual"); args.hash.delete("temp-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"); // If we're going somewhere other than illust, remove zoom state, so // it's not still around the next time we view an image. if(screen != "illust") delete args.state.zoom; } // 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.xRestrict); } 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 binary resources into blobs, so we don't copy images into every // place they're used. async load_resource_blobs() { for(let [name, dataURL] of Object.entries(ppixiv.resources)) { if(!dataURL.startsWith || !dataURL.startsWith("data:")) 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/r118/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({});