// ==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 // @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 126 // @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/folder.svg"] = ` `; 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-booth.svg"] = ` image/svg+xml `; ppixiv.resources["resources/icon-circlems.svg"] = ` `; ppixiv.resources["resources/icon-fanbox.svg"] = ` image/svg+xml `; 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/index.html"] = ` `; 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/logo-twitch.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 { --main-background-color: #000; --background-noise: var(--dark-noise); --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 66, resources/main.scss */ body[data-theme="dark"][data-current-view="illust"] { --background-noise: ""; } /* line 72, resources/main.scss */ body[data-theme="light"] { --main-background-color: #fff; --background-noise: var(--light-noise); --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 107, resources/main.scss */ ul { padding: 0; margin: 0; } /* line 111, 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 117, resources/main.scss */ .screen-illust-container { width: 100%; height: 100%; } /* line 122, resources/main.scss */ .view-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none; cursor: pointer; } /* line 132, resources/main.scss */ .viewer-images { width: 100%; height: 100%; } /* line 139, resources/main.scss */ .viewer-images > .image-box.cropping { overflow: hidden; } /* line 145, resources/main.scss */ .viewer-images > .image-box img { will-change: transform; } /* line 150, resources/main.scss */ .viewer-ugoira, .viewer-video { width: 100%; height: 100%; } /* line 153, resources/main.scss */ .viewer-ugoira > .video-container, .viewer-video > .video-container { width: 100%; height: 100%; } /* line 159, resources/main.scss */ [hidden] { display: none !important; } /* line 163, 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 169, resources/main.scss */ .grecaptcha-badge { display: none !important; } /* line 173, resources/main.scss */ .main-container { position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; overflow: hidden; background-color: var(--main-background-color); background-image: var(--background-noise); } /* line 183, 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 193, resources/main.scss */ .progress-bar.hide { animation: flash-progress-bar 500ms linear 1 forwards; } /* line 197, resources/main.scss */ .loading-progress-bar .progress-bar { z-index: 100; } /* line 202, resources/main.scss */ .video-ui { position: absolute; bottom: 0px; left: 0px; width: 100%; user-select: none; transition: transform .25s, opacity .25s; opacity: 0; } /* line 213, resources/main.scss */ .video-ui .seek-bar { width: 100%; box-sizing: content-box; height: 12px; cursor: pointer; position: relative; } /* line 222, resources/main.scss */ .video-ui .seek-bar[data-position="top"] { padding-top: 25px; } /* line 229, resources/main.scss */ .video-ui .seek-bar[data-position="top"] > .seek-parts { transition: transform .25s; transform: scale(100%, 0%); transform-origin: bottom; } /* line 236, resources/main.scss */ .video-ui .seek-bar[data-position="bottom"] { height: 4px; } /* line 243, resources/main.scss */ .video-ui .seek-bar[data-position="bottom"] > .seek-parts > [data-seek-part="empty"] { background-color: rgba(0, 0, 0, 0.5); } /* line 249, resources/main.scss */ .video-ui .seek-bar.dragging > .seek-parts { transform: scale(100%, 100%) !important; } /* line 253, resources/main.scss */ .video-ui .seek-bar > .seek-parts { width: 100%; height: 100%; } /* line 257, resources/main.scss */ .video-ui .seek-bar > .seek-parts > [data-seek-part] { height: 100%; position: absolute; left: 0; top: 0; } /* line 265, resources/main.scss */ .video-ui .seek-bar > .seek-parts > [data-seek-part="fill"] { background-color: #F00; } /* line 266, resources/main.scss */ .video-ui .seek-bar > .seek-parts > [data-seek-part="loaded"] { background-color: #A00; } /* line 267, resources/main.scss */ .video-ui .seek-bar > .seek-parts > [data-seek-part="empty"] { background-color: rgba(0, 0, 0, 0.25); width: 100%; } /* line 276, resources/main.scss */ .mouse-hidden-box.cursor-active .video-ui, .video-ui.dragging, .video-ui:hover { opacity: 1; } /* line 282, resources/main.scss */ .mouse-hidden-box.cursor-active .video-ui .seek-bar[data-position="top"] > .seek-parts, .video-ui.dragging .seek-bar[data-position="top"] > .seek-parts, .video-ui:hover .seek-bar[data-position="top"] > .seek-parts { transform: scale(100%, 50%); } /* line 288, resources/main.scss */ .video-ui > .video-ui-strip { width: 100%; height: 100%; height: 3em; padding: 0 1em; display: flex; flex-direction: row; color: #ffffff; align-items: center; gap: 10px; background-color: rgba(0, 0, 0, 0.5); padding-top: 4px; } /* line 304, resources/main.scss */ .video-ui > .video-ui-strip .button { cursor: pointer; } /* line 308, resources/main.scss */ .video-ui > .video-ui-strip > .time { font-family: Roboto,Arial,Helvetica,sans-serif; font-size: 1.2em; } /* line 313, resources/main.scss */ .video-ui > .video-ui-strip .volume-slider { width: 100px; height: 100%; display: flex; flex-direction: row; align-items: center; margin-right: -10px; } /* line 321, resources/main.scss */ .video-ui > .video-ui-strip .volume-slider > .volume-line { height: 4px; width: 100px; flex: 1; } /* line 330, 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 337, resources/main.scss */ .small-font { font-size: 0.8em; } /* line 341, resources/main.scss */ .hover-message, .search-results > .no-results { width: 100%; position: absolute; bottom: 0px; display: flex; justify-content: center; } /* line 349, 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 362, resources/main.scss */ .hover-message { transition: opacity .25s; opacity: 0; pointer-events: none; z-index: 100000; /* over everything */ } /* line 368, resources/main.scss */ .hover-message.show { opacity: 1; } /* The version in the search container is always centered. */ /* line 374, resources/main.scss */ .search-results > .no-results { bottom: 50%; } /* line 379, 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 393, 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 415, 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 424, 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 436, resources/main.scss */ .hover-sphere circle { pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } /* line 440, resources/main.scss */ .hover-sphere > svg { width: 100%; height: 100%; transform: translate(-50%, -50%); } /* line 447, resources/main.scss */ .screen-manga-container .ui-container { width: 600px; max-width: 90%; pointer-events: auto; } /* line 453, 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 462, resources/main.scss */ .ui-box .author { vertical-align: top; } /* line 467, resources/main.scss */ .ui-box:not(.visible-widget) { display: inherit !important; } /* line 472, 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 481, resources/main.scss */ body:not(.hide-ui) .screen-illust-container .ui-box.visible-widget { opacity: 1; transform: translate(0, 0); pointer-events: auto; } /* line 490, resources/main.scss */ body.force-ui .screen-illust-container .ui-box { opacity: 1; transform: translate(0, 0); pointer-events: inherit; } /* line 501, resources/main.scss */ .screen-manga-container .ui-box > .description, .screen-manga-container .ui-box > .tag-list { display: none; } /* line 508, resources/main.scss */ .ui-box .button > svg { display: block; } /* line 512, 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 522, resources/main.scss */ .button-row { display: flex; flex-direction: row; align-items: center; height: 32px; margin-top: 5px; margin-bottom: 4px; } /* line 530, 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 537, resources/main.scss */ .title-with-button-row { display: flex; flex-direction: row; align-items: start; } /* An icon in a button strip. */ /* line 544, 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 550, resources/main.scss */ .icon-button svg { display: block; } /* line 555, resources/main.scss */ .disable-ui-button:hover > .icon-button { color: #0096FA; } /* line 558, resources/main.scss */ .whats-new-button.updates > svg { color: #cc0; } /* line 561, resources/main.scss */ body[data-theme="light"] .whats-new-button.updates > svg { color: #0aa; /* yellow doesn't work in a light theme */ } /* line 565, resources/main.scss */ .navigate-out-button { cursor: pointer; } /* line 569, resources/main.scss */ .menu-slider input { vertical-align: middle; width: 100%; padding: 0; margin: 0; cursor: pointer; } /* line 577, resources/main.scss */ .popup.avatar-popup:hover:after { left: auto; bottom: auto; top: 60px; right: -10px; } /* line 584, resources/main.scss */ .follow-container { /* For the avatar in the popup menu, use the same size as the other popup menu buttons. */ } /* line 585, resources/main.scss */ .follow-container .avatar { transition: filter .25s; display: block; position: relative; box-shadow: 0 0 10px 4px #000; /* .avatar contains an image, and a canvas overlaid on top for hover effects. */ } /* line 592, resources/main.scss */ .follow-container .avatar > canvas { border-radius: 5px; object-fit: cover; width: 100%; height: 100%; position: absolute; top: 0; left: 0; } /* line 602, resources/main.scss */ .follow-container .avatar > canvas.highlight { opacity: 0; transition: opacity .25s; } /* line 607, resources/main.scss */ .follow-container .avatar:hover > canvas.highlight { opacity: 1; } /* line 612, resources/main.scss */ .follow-container:not(.big) .avatar { width: 50px; height: 50px; } /* line 617, resources/main.scss */ .follow-container.big .avatar { width: 170px; height: 170px; } /* line 623, 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 631, 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 640, resources/main.scss */ .follow-icon .lock { stroke: #888; } /* line 644, resources/main.scss */ .follow-icon:not(.private) .lock { display: none !important; } /* line 649, 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 650, 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 658, resources/main.scss */ .follow-container .follow-icon.bottom-left { left: 0; } /* line 662, resources/main.scss */ .follow-container .follow-icon.bottom-right { right: 0; } /* line 667, resources/main.scss */ .follow-container .follow-icon:not(:hover) .outline1 { stroke: none !important; } /* line 672, resources/main.scss */ .follow-container:not(.followed) .follow-icon.following-icon { display: none; } /* line 677, resources/main.scss */ .follow-container.followed .follow-icon.follow-button { display: none; } /* line 682, resources/main.scss */ .follow-container:not(:hover) .follow-icon.follow-button { display: none; } /* line 688, resources/main.scss */ .follow-container[data-mode="dropdown"] .follow-icon.follow-button { display: none; } /* line 693, resources/main.scss */ .follow-container.self .follow-icon, .follow-container.self .follow-popup { display: none; } /* line 704, resources/main.scss */ .follow-container:not(.big) .follow-button { top: calc(100% - 5px); } /* line 708, 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 719, resources/main.scss */ .follow-container:not(:hover) .follow-icon > svg { opacity: 0.5; } /* line 725, resources/main.scss */ .popup-context-menu .follow-container .follow-icon > svg { opacity: 1; } /* line 729, resources/main.scss */ .follow-container .follow-icon > svg .middle { transition: transform .1s ease-in-out; transform: translate(0px, -2px); } /* line 734, resources/main.scss */ .follow-container .follow-icon.unfollow-button > svg .middle { transform: translate(-2px, -5px); } /* line 738, resources/main.scss */ .follow-container .follow-icon.unfollow-button:hover > svg .middle { transform: translate(2px, 5px); } /* line 742, resources/main.scss */ .follow-container .follow-popup { margin-top: 10px; right: 0px; } /* line 746, resources/main.scss */ .follow-container .follow-popup .folder { display: block; width: 100%; } /* line 751, resources/main.scss */ .follow-container.followed .follow-container .follow-popup .not-following { display: none; } /* line 752, resources/main.scss */ .follow-container:not(.followed) .follow-container .follow-popup .following { display: none; } /* line 754, resources/main.scss */ .follow-container .follow-popup input { padding: .25em; } /* line 759, resources/main.scss */ .follow-container .hover-area { top: -12px; } /* line 763, resources/main.scss */ .follow-container .avatar-link { display: block; } /* Hide the follow dropdown when following, since there's nothing in it. */ /* line 769, resources/main.scss */ .follow-container.followed.popup-visible .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 773, 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 781, resources/main.scss */ .title-block.popup:hover:after { top: 40px; bottom: auto; } /* When .dot is set, show images with nearest neighbor filtering. */ /* line 788, resources/main.scss */ body.dot img.filtering, body.dot canvas.filtering { image-rendering: crisp-edges; image-rendering: pixelated; } /* line 793, resources/main.scss */ .bulb-button:hover > .icon-button { color: #FF0 !important; /* override grey-icon hover color */ } /* line 797, resources/main.scss */ body[data-theme="light"] .bulb-button:hover > .icon-button { stroke: #000; } /* line 801, resources/main.scss */ .bulb-button > .icon-button { margin-top: -3px; } /* line 806, resources/main.scss */ .extra-profile-link-button .default-icon svg { transform: translate(0, 2px); } /* line 811, 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 821, 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 828, resources/main.scss */ body[data-theme="light"] .description { border: none; } /* Override obnoxious colors in descriptions. Why would you allow this? */ /* line 832, resources/main.scss */ .description * { color: var(--ui-fg-color); } /* line 836, resources/main.scss */ .popup { position: relative; } /* line 840, 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 855, resources/main.scss */ .popup-bottom:hover:after { top: auto; bottom: -2em; } /* line 860, resources/main.scss */ body:not(.premium) .premium-only { display: none; } /* line 861, resources/main.scss */ body:not(.native) .native-only { display: none; } /* line 862, resources/main.scss */ body:not(.pixiv) .pixiv-only { display: none; } /* line 863, resources/main.scss */ body.hide-r18 .r18 { display: none; } /* line 864, resources/main.scss */ body.hide-r18g .r18g { display: none; } /* line 866, 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 877, resources/main.scss */ .popup-menu-box.hover-menu-box { visibility: hidden; } /* line 880, 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 885, resources/main.scss */ .hover-area { position: absolute; top: -50%; left: -33%; width: 150%; height: 200%; z-index: -1; } /* line 894, resources/main.scss */ .popup-menu-box .button { padding: .25em; cursor: pointer; width: 100%; } /* line 901, resources/main.scss */ .popup-menu-box .button:hover { background-color: var(--dropdown-menu-hover-color); } /* line 905, 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 923, resources/main.scss */ body.ui-on-hover .top-ui-box { position: fixed; top: auto; bottom: 100%; left: 0; 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 940, 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 948, resources/main.scss */ body.ui-on-hover .top-ui-box.hover, body.ui-on-hover .top-ui-box.force-open { transform: translateY(100%); } /* line 954, 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 963, resources/main.scss */ body.ui-on-hover .top-ui-box + .top-ui-box-padding { height: 30px; } /* line 970, resources/main.scss */ .search-screen { position: absolute; width: 100%; height: 100%; top: 0; left: 0; color: #fff; display: flex; flex-direction: row; } /* line 980, resources/main.scss */ .search-screen .search-results { position: relative; width: 100%; height: 100%; overflow-x: hidden; overflow-y: scroll; } /* line 990, resources/main.scss */ .search-screen .search-results:focus { outline: none; } /* line 996, resources/main.scss */ .search-results { /* .thumbnails is the actual thumbnail list. */ } /* line 997, resources/main.scss */ .search-results .thumbnail-ui-box-container { width: 50%; /* Make sure this doesn't get too narrow, or it'll overlap too much of the thumbnail area. */ min-width: 800px; } /* line 1002, resources/main.scss */ .search-results .thumbnail-ui-box { width: 100%; 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 1012, resources/main.scss */ .search-results .thumbnail-ui-box .disable-ui-button { margin-right: 2px; } /* line 1015, resources/main.scss */ .search-results .thumbnail-ui-box .disable-ui-button > svg { width: 22px; } /* line 1020, resources/main.scss */ .search-results .thumbnail-ui-box .displaying { padding-bottom: 4px; } /* line 1023, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word { padding: 0px 5px; } /* line 1027, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word:first-child { padding-left: 0px; /* remove left padding from the first item */ } /* line 1031, resources/main.scss */ .search-results .thumbnail-ui-box .displaying .word.or { font-size: 12px; padding: 0; color: #bbb; } /* line 1037, resources/main.scss */ .search-results .thumbnail-ui-box .bookmarks-link > svg, .search-results .thumbnail-ui-box .following-link > svg { width: 32px; height: 32px; } /* line 1043, resources/main.scss */ .search-results .thumbnail-ui-box .contact-link > svg { width: 31px; height: 31px; margin: 0 3px; } /* line 1049, resources/main.scss */ .search-results .thumbnail-ui-box .webpage-link > svg { margin: 0 2px; width: 26px; height: 26px; } /* line 1055, resources/main.scss */ .search-results .thumbnail-ui-box .circlems-icon > svg { margin: 2px 0 0 0; } /* line 1061, resources/main.scss */ .search-results .thumbnail-ui-box .fanbox-icon > svg [inkscape\\:label="edge"], .search-results .thumbnail-ui-box .fanbox-icon > svg [inkscape\\:label="ear-left"], .search-results .thumbnail-ui-box .fanbox-icon > svg [inkscape\\:label="ear-right"], .search-results .thumbnail-ui-box .fanbox-icon > svg [inkscape\\:label="hand-right"] { fill: currentColor !important; } /* line 1072, resources/main.scss */ .search-results .thumbnails { user-select: none; padding: 0; text-align: center; } /* line 1078, resources/main.scss */ .search-results .thumbnails { display: flex; flex-wrap: wrap; justify-content: center; margin: 0 auto; /* center */ } /* line 1088, 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 1102, resources/main.scss */ .search-results .last-viewed-image-marker { position: absolute; left: 0; top: 0; pointer-events: none; height: auto; } /* line 1112, resources/main.scss */ .search-results .thumbnail-box:not(.flash) .last-viewed-image-marker { display: none; } /* line 1117, resources/main.scss */ .thumbnail-load-previous { width: 100%; } /* line 1120, 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 1131, 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 1144, resources/main.scss */ .thumbnail-load-previous > .load-previous-buttons > .load-previous-button:hover { background-color: var(--box-link-hover-color); } /* line 1151, 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 1153, resources/main.scss */ .thumbnail-box[data-pending] { visibility: hidden; } /* line 1156, resources/main.scss */ .thumbnail-box .thumbnail-inner { position: relative; } /* line 1168, resources/main.scss */ .thumbnail-box:not([data-nearby]) .thumbnail-inner { content-visibility: hidden; } /* line 1172, 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 1187, resources/main.scss */ .screen-search-container .thumbnail-box .similar-illusts-button { display: block; width: 32px; height: 32px; margin-top: -2px; } /* line 1194, resources/main.scss */ .screen-search-container .thumbnail-box:not(:hover) .similar-illusts-button { visibility: hidden; } /* line 1198, 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 1207, resources/main.scss */ .screen-search-container .thumbnail-box .similar-illusts-button:hover { opacity: 1; stroke: #000; } /* line 1212, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row { position: absolute; display: flex; align-items: center; justify-content: center; gap: 4px; pointer-events: none; width: 100%; height: 32px; bottom: 3px; padding: 0 4px; } /* line 1236, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .bottom-left-icon, .screen-search-container .thumbnail-box .bottom-row .bottom-right-icon { height: 32px; width: 100px; flex-shrink: 100000; display: flex; align-items: center; } /* line 1244, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .bottom-right-icon { justify-content: end; } /* line 1248, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .thumbnail-label { display: flex; align-items: center; gap: .5em; flex-shrink: 1; white-space: nowrap; color: var(--frame-fg-color); background-color: rgba(0, 0, 0, 0.6); padding: 4px 8px; overflow: hidden; border-radius: 6px; } /* line 1261, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .thumbnail-label .thumbnail-ellipsis-box { text-overflow: ellipsis; overflow: hidden; } /* line 1265, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .thumbnail-label .thumbnail-ellipsis-box > .label { /* Specify a line-height explicitly, so vertical centering is reasonably consistent for * both EN and JP text. */ line-height: 19px; } /* line 1272, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .thumbnail-label .ugoira-icon { color: #fff; transition: opacity .5s; } /* line 1278, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .heart { width: 32px; height: 32px; } /* line 1281, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .heart > svg { transition: opacity .5s; } /* line 1286, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .page-count-box { display: inline-block; padding: 4px 4px; background-color: rgba(0, 0, 0, 0.6); border-radius: 6px; transition: opacity .5s; white-space: nowrap; pointer-events: auto; } /* line 1296, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .page-count-box .page-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; } /* line 1303, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .page-count-box:hover .regular { display: none; } /* line 1306, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .page-count-box:not(:hover) .hover { display: none; } /* line 1310, resources/main.scss */ .screen-search-container .thumbnail-box .bottom-row .page-count-box .page-count { vertical-align: middle; margin-left: -4px; } /* line 1318, resources/main.scss */ .screen-search-container .thumbnail-inner:hover .heart > svg { opacity: 0.5; } /* line 1321, resources/main.scss */ .screen-search-container .thumbnail-inner:hover .ugoira-icon { opacity: 0.5; } /* line 1328, 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 1339, resources/main.scss */ .thumbnail-box[data-pending] .thumb { display: none; } /* line 1344, resources/main.scss */ .thumbnail-box .thumb { transition: transform .5s; transform: scale(1, 1); } /* line 1354, 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 1362, 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 1373, resources/main.scss */ .thumbnail-box .thumbnail-inner:not(:hover) .thumb, body.pause-thumbnail-animation .thumbnail-box .thumb { animation-play-state: paused; } /* line 1378, 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 1389, 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 1411, 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 1412, 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 1427, resources/main.scss */ .thumbnail-box.muted .thumb { filter: blur(10px); transform: scale(1.25, 1.25); } /* line 1432, resources/main.scss */ body:not(.disable-thumbnail-zooming) .thumbnail-box.muted .thumb:hover { transform: scale(1, 1); } /* line 1437, resources/main.scss */ .thumbnail-box:not(.muted) .muted-text { display: none; } /* line 1443, resources/main.scss */ .screen-search-container .following-tag { text-decoration: none; } /* line 1452, resources/main.scss */ .box-link-row { display: flex; flex-direction: row; align-items: center; gap: 0.5em; } /* line 1460, resources/main.scss */ .box-link-row > .box-link { padding-left: 0.5em; padding-right: 0.5em; } /* line 1467, resources/main.scss */ .box-button-row { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; } /* line 1473, resources/main.scss */ .box-button-row > .box-link { margin: 0.25em .25em; padding: 0 .5em; } /* line 1482, resources/main.scss */ .box-button-row > .box-link > * { padding: .25em 0; } /* line 1496, resources/main.scss */ .vertical-list > .box-link { padding: 0 .5em; } /* line 1498, resources/main.scss */ .vertical-list > .box-link { display: flex; flex-direction: row; align-items: center; margin-top: 0; margin-bottom: 0; } /* line 1509, resources/main.scss */ .box-link { display: inline-flex; cursor: pointer; text-decoration: none; margin: 0; padding: 0 .75em; 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 1524, resources/main.scss */ .box-link.disabled { color: var(--box-link-disabled-color); cursor: auto; pointer-events: none; } /* line 1532, resources/main.scss */ .box-link:hover:not(.disabled) { background-color: var(--box-link-hover-color); } /* line 1536, resources/main.scss */ .box-link.selected { background-color: var(--box-link-selected-color); } /* line 1540, 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 1549, resources/main.scss */ .box-link > .icon { display: inline-block; width: 1em; position: relative; top: 0.125em; margin-right: 0.25em; } /* line 1555, resources/main.scss */ .box-link > .icon svg { width: auto; height: 1em; } /* line 1562, resources/main.scss */ .box-link.active { background-color: var(--box-link-selected-color); } /* line 1568, resources/main.scss */ a.box-link, span.box-link { padding-top: 0.5em; padding-bottom: 0.5em; } /* line 1574, 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 1582, resources/main.scss */ .tag-search-box { display: inline-block; position: relative; } /* line 1587, resources/main.scss */ .input-field-container { background-color: white; padding: 6px 10px; display: inline-flex; gap: 0.25em; width: 300px; } /* line 1600, resources/main.scss */ .input-field-container > input { background: none; border: none; font-size: 1.2em; vertical-align: middle; flex: 1; min-width: 0; } /* line 1612, resources/main.scss */ .input-field-container > .right-side-button { display: inline-block; vertical-align: middle; cursor: pointer; user-select: none; color: black; } /* line 1619, resources/main.scss */ .input-field-container > .right-side-button > svg { vertical-align: middle; } /* line 1626, 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 1634, resources/main.scss */ .navigation-search-box .search-submit-button { vertical-align: middle; margin-left: -30px; /* overlap the search box */ } /* line 1638, resources/main.scss */ .navigation-search-box input.search-tags { width: 100%; padding-right: 30px; /* extra space for the submit button */ } /* line 1644, resources/main.scss */ .thumbnail-ui-box .avatar-container { float: right; position: relative; margin-left: 25px; } /* line 1650, resources/main.scss */ .image-for-suggestions { float: right; margin-left: 25px; } /* line 1654, resources/main.scss */ .image-for-suggestions > img { display: block; height: 150px; width: 150px; object-fit: cover; border-radius: 5px; /* matches the avatar display */ } /* line 1663, 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 1669, resources/main.scss */ .grey-icon:hover, :hover > .grey-icon.parent-highlight { color: var(--button-highlight-color); } /* line 1676, resources/main.scss */ .popup-visible > .grey-icon { color: var(--button-highlight-color); } /* line 1682, 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 1693, 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 1706, resources/main.scss */ .member-tags-box .post-tag-list, .search-tags-box .related-tag-list, .bookmark-tags-box .bookmark-tag-list, .local-bookmark-tags-box .local-bookmark-tag-list { max-height: 50vh; min-width: 20em; overflow-x: hidden; overflow-y: auto; white-space: nowrap; } /* line 1718, resources/main.scss */ .member-tags-box .post-tag-list .following-tag:hover:after, .search-tags-box .related-tag-list .tag:hover:after, .bookmark-tags-box .bookmark-tag-list .tag:hover:after, .local-bookmark-tags-box .local-bookmark-tag-list .tag:hover:after { left: auto; right: 0px; } /* These affect both the search edit and search history boxes. */ /* line 1727, resources/main.scss */ .input-dropdown { width: 500px; /* overridden by script */ max-width: 800px; margin: 1px; z-index: 1; user-select: none; /* 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 1742, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list { display: flex; flex-direction: column; white-space: normal; } /* line 1748, 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 1757, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search { color: #000; flex: 1; padding-left: 7px; height: 100%; } /* line 1764, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search .word { display: inline-flex; align-items: center; height: 100%; padding: 0px 5px; } /* line 1770, resources/main.scss */ .input-dropdown .input-dropdown-list > .entry .search .word.or { font-size: 12px; padding: 0; color: #333; } /* line 1779, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list { /* Hide the button to remove history entries from non-history entries. */ } /* line 1780, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry .suggestion-icon { margin: 2px -2px 0 2px; } /* line 1783, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.autocomplete) .suggestion-icon { display: none; } /* line 1787, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry.selected { background-color: #ffa; } /* line 1791, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover { background-color: #ddd; } /* line 1795, 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 1811, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry { display: none; } /* line 1815, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list > .entry:hover .remove-history-entry { visibility: visible; } /* line 1818, resources/main.scss */ .search-history > .input-dropdown > .input-dropdown-list .remove-history-entry:hover { color: #000; background-color: #c0c0c0; } /* line 1825, 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 1829, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list { white-space: normal; max-width: 100%; } /* line 1833, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry { display: inline-flex; } /* line 1836, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight { background-color: #eeee00; } /* line 1837, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag:hover { background-color: #0099FF; } /* line 1838, resources/main.scss */ .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight:hover { background-color: #00CCFF; } /* line 1845, 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 1855, resources/main.scss */ body.hide-ui .manga-thumbnail-container { display: none; } /* line 1863, 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 1874, 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 1887, resources/main.scss */ .manga-thumbnail-container.visible > .strip { opacity: 1; transform: translate(0, 0); } /* line 1893, 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 1903, 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 1918, resources/main.scss */ .manga-thumbnail-arrow { height: 100%; width: 30px; margin: 0 6px; } /* line 1924, resources/main.scss */ .manga-thumbnail-arrow > svg { fill: #888; } /* line 1928, resources/main.scss */ .manga-thumbnail-arrow:hover > svg { fill: #ff0; } /* line 1933, resources/main.scss */ body[data-theme="light"] .manga-thumbnail-arrow { stroke: #aa0; } /* line 1938, resources/main.scss */ .manga-thumbnail-arrow > svg { display: block; height: 100%; width: 100%; padding: 4px; } /* line 1947, 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 1958, resources/main.scss */ .widget:not(.visible-widget) { display: none; } /* The right click context menu for the image view: */ /* line 1964, 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 1981, resources/main.scss */ .popup-context-menu:not(.visible-widget) { display: inherit; opacity: 0; pointer-events: none; transform: scale(0.85); } /* line 1989, resources/main.scss */ .popup-context-menu.visible-widget { opacity: 1; } /* line 1994, resources/main.scss */ .popup-context-menu > * { transform-origin: unset; } /* line 1999, resources/main.scss */ .popup-context-menu .popup:hover:after { display: none; } /* line 2003, resources/main.scss */ .popup-context-menu .tooltip-display { display: flex; align-items: stretch; padding: 10px 0 0 8px; pointer-events: none; } /* line 2010, 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 2017, 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 2021, resources/main.scss */ .popup-context-menu .button-strip > .button-block { display: inline-block; background-color: var(--frame-bg-color); padding: 12px; } /* line 2028, resources/main.scss */ .popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; } /* line 2032, resources/main.scss */ .popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; } /* line 2035, resources/main.scss */ .popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; } /* line 2036, resources/main.scss */ .popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; } /* line 2038, 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 2051, resources/main.scss */ .popup-context-menu .button-strip .button:not(.enabled) { cursor: inherit; color: var(--toggle-button-fg-disabled-color); } /* line 2057, resources/main.scss */ .popup-context-menu .button-strip .button > * { min-width: 32px; } /* line 2060, resources/main.scss */ .popup-context-menu .button-strip .button > svg { width: 32px; height: 32px; } /* line 2065, resources/main.scss */ .popup-context-menu .button-strip .button.enabled:hover { color: var(--toggle-button-fg-color); } /* line 2069, 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 2076, resources/main.scss */ .popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; } /* line 2077, resources/main.scss */ .popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; } /* line 2080, 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 2088, resources/main.scss */ body[data-theme="light"] .popup-context-menu .button-strip .button .tag-dropdown-arrow { border-top-color: #ccc; } /* line 2095, resources/main.scss */ .popup-context-menu .button-strip > .button-block.shift-left { margin-left: -56px; } /* line 2100, 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 2109, 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 2118, resources/main.scss */ .popup-context-menu .popup-bookmark-tag-dropdown { right: -100%; } /* line 2127, resources/main.scss */ .popup-more-options-container .button-send-image svg .arrow { transition: transform ease-in-out .15s; } /* line 2131, resources/main.scss */ .popup-more-options-container .button-send-image:not(.disabled):hover svg .arrow { transform: translate(2px, -2px); } /* line 2137, 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 2151, resources/main.scss */ .popup-context-menu .popup-bookmark-tag-dropdown, .popup-context-menu .popup-more-options-dropdown { top: calc(100% - 4px); } /* line 2155, 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 2163, 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 2173, 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 2179, 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 2186, 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 2189, 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 2192, resources/main.scss */ body[data-theme="light"] .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active, body[data-theme="light"] .popup-more-options-dropdown .popup-bookmark-tag-entry.active { background-color: #00c; color: #fff; } /* line 2196, 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 2199, resources/main.scss */ body[data-theme="light"] .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.active:hover, body[data-theme="light"] .popup-more-options-dropdown .popup-bookmark-tag-entry.active:hover { background-color: #00a; } /* line 2202, 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 2205, resources/main.scss */ body[data-theme="light"] .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:not(.active):hover, body[data-theme="light"] .popup-more-options-dropdown .popup-bookmark-tag-entry:not(.active):hover { background-color: #ccc; } /* line 2211, 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 2232, resources/main.scss */ .popup-context-menu .button.button-bookmark.public.has-like-count > svg { margin-top: -10px; } /* line 2236, 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 2242, 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 2248, resources/main.scss */ svg.heart-image .delete { display: none; } /* These are !important to override the default white coloring in the context * menu. */ /* line 2254, resources/main.scss */ .button-bookmark { color: #400 !important; } /* line 2256, resources/main.scss */ .button-bookmark.enabled { color: #800 !important; stroke: none; } /* line 2261, 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 2270, resources/main.scss */ .screen-search-container .thumbnails .button-bookmark svg > .heart { stroke: #000; stroke-width: .5px; } /* line 2275, 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 2280, resources/main.scss */ .button.button-like > svg { color: var(--like-button-color); } /* line 2284, resources/main.scss */ .button.button-like.liked > svg { color: var(--like-button-liked-color); } /* line 2287, resources/main.scss */ .button.button-like.enabled:hover > svg { color: var(--like-button-hover-color); } /* line 2293, resources/main.scss */ .button.button-browser-back .arrow { transition: transform ease-in-out .15s; transform: translate(-2px, 0px); } /* line 2297, resources/main.scss */ .button.button-browser-back:hover .arrow { transform: translate(1px, 0px); } /* line 2302, resources/main.scss */ .button.button-like > svg > * { transition: transform ease-in-out .15s; transform: translate(0, 0px); } /* line 2306, resources/main.scss */ .button.button-like > svg > .mouth { transform: scale(1, 0.75); } /* line 2310, resources/main.scss */ .button.button-like.liked > svg > * { transform: translate(0, -3px); } /* line 2313, resources/main.scss */ .button.button-like.liked > svg > .mouth { transform: scale(1, 1.1) translate(0, -3px); } /* line 2316, resources/main.scss */ .button.button-like.enabled:hover > svg > * { transform: translate(0, -2px); } /* line 2319, resources/main.scss */ .button.button-like.enabled:hover > svg > .mouth { transform: scale(1, 0.9) translate(0, -3px); } /* line 2322, resources/main.scss */ .button-bookmark.public svg.heart-image .lock { display: none; } /* line 2325, resources/main.scss */ .button-bookmark svg.heart-image .lock { stroke: #888; } /* line 2329, 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 2341, 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 2349, resources/main.scss */ .dialog .content > .scroll { width: 100%; height: 100%; overflow-y: auto; padding: 1em; } /* line 2357, resources/main.scss */ .dialog .header { font-size: 40px; margin-bottom: 20px; } /* line 2363, resources/main.scss */ .whats-new-box .content { width: 80%; height: 80%; } /* line 2367, 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 2373, resources/main.scss */ .whats-new-box .content .text { margin: 1em 0; padding: 0 20px; /* inset horizontally a bit */ } /* line 2379, 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 2388, resources/main.scss */ .close-button:hover { color: var(--button-highlight-color); } /* line 2391, resources/main.scss */ .close-button > svg { display: block; } /* line 2397, 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 2404, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="left"] { margin-left: 20px; left: 0; } /* line 2409, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="right"] { margin-right: 20px; right: 0; } /* line 2414, resources/main.scss */ .screen-illust-container .page-change-indicator[data-side="right"] svg { transform-origin: center center; transform: scale(-1, 1); } /* line 2420, resources/main.scss */ .screen-illust-container .page-change-indicator[data-icon="last-page"] svg .bar { display: none; } /* line 2423, resources/main.scss */ .screen-illust-container .page-change-indicator svg { opacity: 0; } /* line 2426, resources/main.scss */ .screen-illust-container .page-change-indicator.flash svg { animation: flash-page-change-opacity 400ms ease-out 1 forwards; } /* line 2430, 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 2450, resources/main.scss */ .link-tab-popup .explanation { max-width: 25em; width: 100%; text-align: center; margin: 0 auto; } /* line 2457, resources/main.scss */ .link-tab-popup .button { display: inline-block; cursor: pointer; background-color: #000; padding: .5em 1em; margin: .5em; border-radius: 5px; } /* line 2466, resources/main.scss */ .link-tab-popup .content { width: 400px; padding: 1em; } /* line 2471, resources/main.scss */ .link-tab-popup .buttons { display: flex; } /* line 2475, resources/main.scss */ .link-tab-popup .tutorial-monitor { width: 290px; height: 125px; margin-bottom: -20px; } /* line 2483, 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 2500, resources/main.scss */ .send-image-popup .content { padding: 1em; } /* line 2507, 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 2514, resources/main.scss */ .link-this-tab-popup .button, .send-image-here-popup .button { cursor: pointer; padding: 1em; } /* line 2520, resources/main.scss */ .tag-entry-popup { z-index: 1000; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); } /* line 2530, resources/main.scss */ .tag-entry-popup > .strip { display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: center; } /* line 2536, resources/main.scss */ .tag-entry-popup > .strip > .box { background-color: #222; padding: 10px; color: #eee; position: relative; } /* line 2542, resources/main.scss */ body[data-theme="light"] .tag-entry-popup > .strip > .box { background-color: #ddd; color: #222; } /* line 2549, resources/main.scss */ .tag-entry-popup .close-button { position: absolute; top: 0px; right: 0px; padding: 8px; cursor: pointer; } /* line 2557, resources/main.scss */ .tag-entry-popup .input-box { position: relative; display: flex; align-items: center; margin-top: .5em; } /* line 2563, resources/main.scss */ .tag-entry-popup .input-box > input { flex: 1; padding: 4px; } /* line 2567, resources/main.scss */ .tag-entry-popup .input-box > .submit-button { cursor: pointer; display: inline-block; width: 20px; text-align: center; margin-left: 6px; border: 1px solid white; } /* line 2574, resources/main.scss */ body[data-theme="light"] .tag-entry-popup .input-box > .submit-button { border-color: #444; } /* line 2577, resources/main.scss */ .tag-entry-popup .input-box > .submit-button:hover { background-color: #444; } /* line 2579, resources/main.scss */ body[data-theme="light"] .tag-entry-popup .input-box > .submit-button:hover { background-color: #aaa; } /* line 2584, 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 2591, resources/main.scss */ .years-ago > a { padding: 4px 10px; background-color: #444; } /* line 2594, resources/main.scss */ body[data-theme="light"] .years-ago > a { background-color: #ccc; } /* line 2600, resources/main.scss */ .tree { user-select: none; overflow-x: hidden; overflow-y: auto; flex: 1; } /* line 2607, resources/main.scss */ .tree .tree-item { position: relative; contain-intrinsic-height: 32px; } /* line 2616, resources/main.scss */ .tree .tree-item.allow-content-visibility { content-visibility: auto; } /* line 2621, resources/main.scss */ .tree .tree-item:not(.root) > .items { margin-left: 1em; } /* line 2625, resources/main.scss */ .tree .tree-item.selected > .self > .label { background-color: #003088; } /* line 2630, resources/main.scss */ .tree .tree-item > .self { display: flex; flex-direction: row; align-items: center; height: 2em; } /* line 2636, resources/main.scss */ .tree .tree-item > .self:focus { outline: none; } /* line 2647, resources/main.scss */ .tree .tree-item > .self > .label { padding: 0.5em; white-space: nowrap; } /* line 2654, resources/main.scss */ .tree .tree-item > .self.root { display: none; } /* line 2659, resources/main.scss */ .tree .tree-item > .self > .expander { display: flex; justify-content: center; align-items: center; font-size: 50%; width: 3em; height: 100%; } /* line 2668, resources/main.scss */ .tree .tree-item > .self > .expander > .expander-button { display: none; width: 3em; text-align: center; vertical-align: middle; } /* line 2677, resources/main.scss */ .tree .tree-item > .self > .expander[data-mode="loading"] > .loading { display: block; } /* line 2682, resources/main.scss */ .tree .tree-item > .self > .expander[data-mode="none"] > .none { display: block; } /* line 2688, resources/main.scss */ .tree .tree-item > .self > .expander[data-pending="true"] > .expand { opacity: 0.5; } /* line 2693, resources/main.scss */ .tree .tree-item > .self > .expander[data-mode="expandable"] > .expand, .tree .tree-item > .self > .expander[data-mode="expanded"] > .expand { display: block; } /* line 2699, resources/main.scss */ .tree .tree-item > .self > .expander .expand { transform: rotate(0deg); transition: transform .25s; } /* line 2705, resources/main.scss */ .tree .tree-item > .self > .expander[data-mode="expanded"] > .expand { transform: rotate(90deg); } /* line 2714, resources/main.scss */ .local-navigation-box { height: 100%; width: 25%; flex-shrink: 0; background-color: #111; border-right: solid 1px #444; padding-top: 0.5em; padding-left: 0.5em; opacity: 1; transition: opacity .35s, transform .35s; display: flex; flex-direction: column; } /* line 2729, resources/main.scss */ .local-navigation-box[hidden] { display: block !important; opacity: 0; pointer-events: none; position: fixed; transform: translate(-50%, 0); } /* line 2738, resources/main.scss */ .tree-popup { background-color: #222; color: #fff; position: fixed; pointer-events: none; outline-style: dotted; outline-width: 1px; outline-color: #aaa; } /* line 2748, resources/main.scss */ .tree-popup > .label { white-space: nowrap; } /* line 2756, resources/main.scss */ .thumb-popup { position: fixed; pointer-events: none; margin-left: 10px; width: 25%; height: 40%; max-height: 400px; max-width: 400px; } /* line 2765, resources/main.scss */ .thumb-popup > img { object-fit: contain; width: 100%; height: 100%; } /* line 2776, resources/main.scss */ .settings-dialog .content { width: 80%; min-height: 30em; min-width: 800px; padding: 1em; } /* line 2782, resources/main.scss */ .settings-dialog .content .box { display: flex; flex-direction: row; } /* line 2787, resources/main.scss */ .settings-dialog .content .box .sections { white-space: nowrap; display: flex; flex-direction: column; } /* line 2792, resources/main.scss */ .settings-dialog .content .box .sections > div { padding: 0.5em; cursor: pointer; } /* line 2796, resources/main.scss */ .settings-dialog .content .box .sections > div:not(.selected) { opacity: 0.65; } /* line 2800, resources/main.scss */ .settings-dialog .content .box .sections > div:not(.active) { background: none; } /* line 2804, resources/main.scss */ .settings-dialog .content .box .sections > div:hover { background-color: var(--box-link-hover-color); } /* line 2810, resources/main.scss */ .settings-dialog .content .box .items { flex: 1; } /* line 2814, resources/main.scss */ .settings-dialog .content .box .settings-page { display: flex; flex-direction: column; } /* line 2818, resources/main.scss */ .settings-dialog .content .box .settings-page .settings-row { padding: 0.5em 1em; gap: 1em; } /* line 2823, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link { height: auto; background-color: inherit; } /* line 2829, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link > .buttons > .box-link { padding: 0.35em .75em; } /* line 2833, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link.button { background-color: var(--box-link-bg-color); } /* line 2837, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link.clickable:hover:not(.disabled) { background-color: var(--box-link-hover-color); } /* line 2842, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link:not(.clickable):hover { cursor: inherit; background: none; } /* line 2847, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link .label-box { margin-left: .25em; flex: 1; display: flex; flex-direction: column; gap: .25em; } /* line 2853, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link .label-box .label { padding-top: .25em; margin: 0; } /* line 2858, resources/main.scss */ .settings-dialog .content .box .settings-page .box-link .label-box .explanation { font-size: 16px; opacity: 0.75; } /* line 2868, resources/main.scss */ .settings-dialog .close-button { position: absolute; top: 5px; right: 5px; color: var(--button-color); background-color: var(--ui-bg-color); padding: 4px; border-radius: 5px; cursor: pointer; } /* line 2877, resources/main.scss */ .settings-dialog .close-button:hover { color: var(--button-highlight-color); } /* line 2880, resources/main.scss */ .settings-dialog .close-button > svg { display: block; } /* line 2886, resources/main.scss */ .muted-tags-popup { padding: 0.5em 1em; display: flex; flex-direction: column; gap: 0.5em; max-height: 440px; overflow-y: auto; } /* line 2894, resources/main.scss */ .muted-tags-popup .mute-warning { border: solid 2px black; border-radius: 15px; background-color: #000; padding: 1em; } /* line 2902, resources/main.scss */ .close-local-search { padding: 0.5em; } /* line 2907, resources/main.scss */ .image-editor { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none; } /* line 2917, resources/main.scss */ .image-editor .save-edits.dirty { color: #0f0; } /* line 2921, resources/main.scss */ .image-editor .save-edits .spinner { position: absolute; top: 0; left: 0; background-color: var(--box-link-bg-color); width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; } /* line 2931, resources/main.scss */ .image-editor .save-edits .spinner > span { display: block; font-size: 40px; } /* line 2938, resources/main.scss */ .image-editor .image-editor-buttons { position: absolute; display: flex; flex-direction: column; font-size: 150%; width: 100%; justify-content: center; align-items: center; } /* line 2940, resources/main.scss */ .mouse-hidden-box:not(.show-cursor) .image-editor .image-editor-buttons.top { display: none; } /* line 2944, resources/main.scss */ .image-editor .image-editor-buttons.top { top: 0; } /* line 2947, resources/main.scss */ .image-editor .image-editor-buttons.bottom { bottom: 0; } /* line 2957, resources/main.scss */ .image-editor .image-editor-buttons .image-editor-button-row { pointer-events: auto; } /* line 2962, resources/main.scss */ .image-editor .spin { animation: spin 1000ms linear infinite forwards; } @keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } } /* line 2972, resources/main.scss */ .crop-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } /* line 2979, resources/main.scss */ .crop-editor-overlay .crop-box { position: relative; cursor: move !important; } /* line 2982, resources/main.scss */ .crop-editor-overlay .crop-box .crop-dim { position: absolute; background-color: #000; opacity: 0.5; } /* line 2990, resources/main.scss */ .crop-editor-overlay .handle[data-crop="topleft"] { cursor: nw-resize !important; } /* line 2991, resources/main.scss */ .crop-editor-overlay .handle[data-crop="top"] { cursor: n-resize !important; } /* line 2992, resources/main.scss */ .crop-editor-overlay .handle[data-crop="topright"] { cursor: ne-resize !important; } /* line 2993, resources/main.scss */ .crop-editor-overlay .handle[data-crop="left"] { cursor: w-resize !important; } /* line 2994, resources/main.scss */ .crop-editor-overlay .handle[data-crop="right"] { cursor: e-resize !important; } /* line 2995, resources/main.scss */ .crop-editor-overlay .handle[data-crop="bottomleft"] { cursor: sw-resize !important; } /* line 2996, resources/main.scss */ .crop-editor-overlay .handle[data-crop="bottom"] { cursor: s-resize !important; } /* line 2997, resources/main.scss */ .crop-editor-overlay .handle[data-crop="bottomright"] { cursor: se-resize !important; } /* line 3000, resources/main.scss */ .crop-editor-overlay .edge-handle { position: absolute; } /* line 3005, resources/main.scss */ .inpaint-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } /* line 3012, resources/main.scss */ .inpaint-editor-overlay.creating-lines { cursor: crosshair !important; } /* line 3016, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment { pointer-events: auto; } /* line 3028, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment .inpaint-line { fill: none; stroke: #f00; stroke-linecap: round; stroke-linejoin: round; stroke-opacity: 0.75; mix-blend-mode: difference; } /* line 3037, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment:hover { pointer-events: all; } /* line 3040, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { stroke: #000; } /* line 3045, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment .inpaint-handle { opacity: 0; } /* line 3049, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment.selected .inpaint-handle, .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { opacity: 1; } /* line 3055, resources/main.scss */ .inpaint-editor-overlay .inpaint-segment .inpaint-handle { fill: none; opacity: 0.25; stroke: #000; pointer-events: all; } /*# sourceMappingURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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/picture-in-picture.svg"] = ` `; 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-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/vview-icon.png"] = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGsmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIxLTEwLTE2VDA2OjQwOjI2LTA1OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMS0xMi0xMVQyMzo1MDowOS0wNjowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMS0xMi0xMVQyMzo1MDowOS0wNjowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo4YjNhNjQ1Zi0xNjZjLWUyNDItODA0ZC0yMTUyNGYwY2IyMjYiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo5YzAyOTMyZi05ZDY2LWEzNGItOTRiMy05YjRlMmVmMTdlYzkiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpjMmNkZjUwMC0wZTAzLWI0NDQtYjEwNS0yZTMzNGQ1YTlmY2QiPiA8cGhvdG9zaG9wOlRleHRMYXllcnM+IDxyZGY6QmFnPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9IlYiIHBob3Rvc2hvcDpMYXllclRleHQ9IlYiLz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSJWIiBwaG90b3Nob3A6TGF5ZXJUZXh0PSJWIi8+IDwvcmRmOkJhZz4gPC9waG90b3Nob3A6VGV4dExheWVycz4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjMmNkZjUwMC0wZTAzLWI0NDQtYjEwNS0yZTMzNGQ1YTlmY2QiIHN0RXZ0OndoZW49IjIwMjEtMTAtMTZUMDY6NDA6MjYtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6OGIzYTY0NWYtMTY2Yy1lMjQyLTgwNGQtMjE1MjRmMGNiMjI2IiBzdEV2dDp3aGVuPSIyMDIxLTEyLTExVDIzOjUwOjA5LTA2OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+fbDRPQAADClJREFUeJztnWtwVdUVx//7XN40vBIg4WXA8goQQMUyvBpBHgIjVltHpLVoQcWOY8c+hj6m0w+dsbWjM8iUWEKrjcIEq6gFKhG1LYRg5ZkACXkQIAQSCAGEAAmQc/rhttdwc5Pctfd9nL3P+n1kErLvOb+7z9pnrb22wJobYBhZrHgPgNEbFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRokO8B8B8RWqCmJ4iRvQSO6vtvGrn2q14DygMWCBX8NMJ1pIR1vhE8f9/sBqakHnYfjG/KZ7DCgPBTTbjy4DuInOG9WBq6Fji0ypnzpZbthPjQRHgGCiejO4tcmb7WrMHwKxB4r25vlgOiQoLFE8yZ/imp4i2f+ahodYv73bvbXLvyIwnK8P3zQHt2OPnt/f6MsL7ydjDAsWHpaOsZaMJF/9nE136IGOB4sOP0mlX/oEh4rmxbrxZbhyT8ayc2HzFHi5LhrvxZrlxTGaT0BHPjJG57FOShc99gRALFGteSLdSEyRF6Oy+QIgFiimWwGNfl7zm+2vdmNxggWLK8+OsMX0kp5/1ZXZkBxMRWKCY8rhsILypwn61gAXyNg+mWvf2k5l+PjxuP5Lr0qyqZtn4CUlieoro3RmV9dh5xjl22cVpxhZMay9rEZKN5fZj211qD3QRaGqyWJhqLRttJXW57d8L65zMI/brR9w4twcxsLt4Oo083+eU24tdbA+0EGjlROulyaHXr+mJInOGLz1RPLfD1VcZwHdHiJ6daL+y5aTjcnvgfoHen+d7aGg7X9wVY6wTV5yXD7h6HnpkGG36aWzCj11fTQaXB9G5Czu0a4+f30/2ufNNv59J/cQkYvj86z1NpZc0iPDce9GzZ/nmDCZc9BWuzDX6WXgHbWyFdW6fUAO49KI/k2Z9bwRtbFOTxcqJLv04Dw+jTT9vlephD1wr0A8otTIBfjPJN6KX6/KNGQPEWMrb54rLTqYO60o/bhSoiw/UiMFPZx+eHOW6TzRrEG1I68ucqzejNJbI47rLDSBNNlsEYOVES+59XfSgPr8+OK7N9AN3ClRVr7T6ePROF32oqckirTdBoPwaZ3+tBouvAC661gHOXUeJwgr2+XHWzIFumYTmDKY+v3SafuBOgQBsqlD6FkrX3EScRUMJKl+7hRwWKCJsPal0HZenWfOGxH8SGtVLkGqfc8rtC43RG05UcKlAu2qcn3+u9CKf+u4uGiy4gyZxXrVO0Y+f+F/l1vjdAVtlPfLDsVaY2/aix8PE/Jde6y8/7hUIwLe2NeWUy1/T+E5CPTphSjJt/XVRt+cXXC4QgMXbm/52TNKhn0yw7ukbt0loAVHfd2Q/Znxxu0AAXsy3pb+a1IdIBJlGmX4AbD2pXwAELQSqqnd+9YVkQP1CunVnj/hMQqRlYEGdU/4lCxQ11hy25R5k3TrgiZFx+IzjE8Uwirh/P6GlPdBFIAB/LpYMEZ4YKRI6RnYs7UOqZAKw84yWARA0Eij3lPOHgzJXOTVBxL7WLMxCSj/nG7C9imeg6LOu2G6UioUWxzaz0cmiLeA/qtR1+oFeApVecl7aL2PQhCTxrFRDDDnmDaH9LR1fQAfQSSAArx2y5Yo9vh/DUJpakKTpAt6PZgJdbMQfD8tM+JP7i2/Hqk5oPmUBv6/WOX2VBYohrxTYR6WqheYSV0ZyDO8pSP03Np/QOACCjgLdtLGuSOaiLxtttdtTVx2qpv8+o/H0Ax0FAvBKgb1Pqu5zfvTTq6QA6HwD/sUCxQW5nVNPp1kDu0d3EppLqWHNPaX38wv6CrSqUGYS6tMZS0dFUaCZA0WvzoSf13oB70dXgSA7CUV1zwY1xso9xQLFD7lJKD1RyG17DYeFrR+b0pLCOue4Vg2yQqKxQJCdhJ6Kzu7Vnp1Aql/brG0Gvjl6C7Sq0D54nnwbpiSLGVEol55N3AKWV6N9BA3dBQKwQWoj1TzizQ4Hagnix/oHQDBAoNWH7DJ6Ld/yNCulW4RHQqoB2lXjuPkcwvDRXqCGJmSXkCehpC4R7uMxrIcYTdkD/66eJfQt0V4gAJlH7DP0fOSjES0Sun+Q5xbwfkwQqK4BWcXk+zE+USyN3CREymCUfekUX2SB3MRfS+x6elMm0pmBbTOL0g9kuynTD4wR6PhlZ/UhclQxNVlEZOfhPX3FAEqKLa+GBXIf70ttLF9EqX5vjZnEAOhj/XOoAcwRaM85R6JY8fHhQv0Ut0WUDMbeWqeuQfUvugdzBAKwjb69YVgPsVwtEvIJ2h6MLZqXIAZhlEBbTjr59PBiCbEhdRCziM+vnfqXcDTHKIEArKNvYJ3cX1AlaM60FNo11L0EMQjTBHrjqH2Anl69n9jKuTkLKHswjMlgBDBNIAASPameHGX16yrztxK74C7KiwBjMhgBDBRobZF9irj5sH9XyL2Vvm8g7bf03QPfGgYKdKkR2SX01JhUqSuphOPYZefIBRZIB96tID8p7u4rJNqZkTqam1EAFISZAh08L5PZoFaEJXfDOEobaAP2YLTETIEAfHaafLdWjLWG9yQIkTGAGgCZFkHDYIE+OG7vOUdzqIsPi4cTBCKVcOyvdWqvk4ajB8YKBKn1/HcoofR9lABoi849XNrAZIGyiuyTV2i3bWyfcKvMkruBdI5TXrWBzy+YLdCVm3i7lPy9D7PKjPoGSCIm0wKTBQKwmX7qT5hVZqQlW36N02SmP6YL9J+zzi56fj6cKjNS/lWu2E0LDBcIwFp6N6p2q8wGfU2MpBwPberzC14QKLvELqij3b92q8wyKDujq+o1OwaVhPkCAXiHvp5vu8qM9AboE+MSqM3xhEDZpeSjuNquMiPNQCbtwWiJJwSqqnf+RI+EWqsy698VpADIpF1gLfGEQAA+o+ehWqsym05JgR2+4FRKdUbXBa8ItL3K2X02MlVmpDdA2ypNtgfeEQjA2/R2ZiGrzEhn+ZodAMFTAmUetkuJLe5bVpkldMSEJIJAn5pYwtEcDwnkADnl5Pkg6IFF6sO6t9aRaPmgFx4SCMDGcpuakwqqMiPtAss1PQCC1wQqukjePx9UZTab0sfOjDaabeMtgQDsoG8MDYTSXTvQGvnqfpBKOHhOoPcq7L3EzNSYPmJ5mgXgG/1oAdD1W7Sx6YjnBAKQQ+8MvHSkBWIKzAsBELwpUFYxudTV35v8AcphqF4IgOBNgS7fkCl1nTfYIvUB+qe5NUDN8aJAADbRSwSfovSh+uKcI3dCuXZ4VKD9tc7rR2gO9ae07/jIGwEQPCsQotzoKd8bARC8LNDGcsmDV8Nht+k51ADeFQjARnqpazgU1DlXTE+BBfC0QFnF5FZU4eCd6QceF0iuFVW7GNnGpTU8LRCAD6PQtZla+qg1XhdozzlHYudhG1RfQ4X+R+mGj9cFglR+vg28s4D3wwJhfZkdwZ2jn3vp+QUWyM/GyLVvlmhzrjUsEACsK7JP0w/NDInEMeRawwIBwIVGvHk0Aje+qt6oo5zCgQX6HxKtpVvitecXWKAAB887r9FbSwf/J3URGYtOsEBfod6HxWsBEFig5mw+YSsuwvcSO1MbAAt0G28clX+KVdYb3ogjJCzQbawtki8SiuwbbV1ggYJ5i97Ew4+nkvABWKBgVhWSm3ICOHrJ+YvC409fWKAQbKDvPFx9yL7pRX9YoFC8fIC2/XlDmb2G2LPBGFig0GSFXST0j0pnySfe2AMWChYoNGuL7OyS9h1aV2wv2OqBFgqt0yHeA4gYloDjIIILoWd3NCV1FfNbORZ+91lnbZH9picD5+YIrLkR7zGo4r/DUVpD/+Iua1qKNT1FNDSh+qqzr9bZWe3k1TjUdoumYoJATBzhGIhRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCVYIEYJFohRggVilGCBGCX+C3RktH6WMZYaAAAAAElFTkSuQmCC`; 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/crc32.js", "src/helpers.js", "src/settings.js", "src/fix_chrome_clicks.js", "src/widgets.js", "src/local_api.js", "src/local_widgets.js", "src/muting.js", "src/editing.js", "src/editing_crop.js", "src/editing_inpaint.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/viewer_video.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(media_id, options) { let illust_id = helpers.media_id_to_illust_id_and_page(media_id)[0]; let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_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(media_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(media_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(media_id, request.tags); console.log("Updated bookmark data:", media_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_media_info_sync(media_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(media_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(media_id, options) { if(helpers.is_media_id_local(media_id)) return await local_api.bookmark_add(media_id, options); if(options == null) options = {}; // If bookmark_privately_by_default is enabled and private wasn't specified // explicitly, set it to true. if(options.private == null && settings.get("bookmark_privately_by_default")) options.private = true; let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_id); console.log("Add bookmark for", media_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(media_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(media_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(media_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(media_id, bookmark_params); } static async bookmark_remove(media_id) { if(helpers.is_media_id_local(media_id)) return await local_api.bookmark_remove(media_id); let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_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(media_id, { bookmarkData: null }); // If we have full image data loaded, update the like count locally. let illust_data = image_data.singleton().get_media_info_sync(media_id); if(illust_data) { illust_data.bookmarkCount--; image_data.singleton().call_illust_modified_callbacks(media_id); } image_data.singleton().update_cached_bookmark_image_tags(media_id, null); message_widget.singleton.show("Bookmark removed"); image_data.singleton().call_illust_modified_callbacks(media_id); } // Change an existing bookmark to public or private. static async bookmark_set_private(media_id, private_bookmark) { if(helpers.is_media_id_local(media_id)) return; let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_id); if(!illust_info.bookmarkData) { console.log(\`Illust \${media_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(media_id, { bookmarkData: { id: bookmark_id, private: private_bookmark, }, }); message_widget.singleton.show(private_bookmark? "Bookmarked privately":"Bookmarked"); image_data.singleton().call_illust_modified_callbacks(media_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(media_id) { console.log("Show tag prompt"); // Hide the popup when we show the prompt. this.hide_temporarily = true; let prompt = new text_prompt({ title: "New tag:" }); let tags = await prompt.result; if(tags == null) return; // cancelled // Split the new tags. tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); // 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(media_id); // 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. if(helpers.is_media_id_local(media_id)) await local_api.bookmark_add(media_id, { tags: tags }); else await actions.bookmark_add(media_id, { tags: active_tags, }); } // If quiet is true, don't print any messages. static async like_image(media_id, quiet) { if(helpers.is_media_id_local(media_id)) return; let illust_id = helpers.media_id_to_illust_id_and_page(media_id)[0]; console.log("Clicked like on", media_id); if(image_data.singleton().get_liked_recently(media_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(media_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_media_info_sync(media_id); if(!was_already_liked && illust_data) illust_data.likeCount++; // Let widgets know that the image was liked recently, and that the like count // may have changed. image_data.singleton().call_illust_modified_callbacks(media_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) { if(user_id == -1) return; 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) { if(user_id == -1) return; 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(media_id, progress_bar_controller, download_type) { let illust_data = await image_data.singleton().get_media_info(media_id, { load_user_info: true }); let user_info = await image_data.singleton().get_user_info(illust_data.userId); console.log("Download", media_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(let 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. let manga_page = helpers.parse_media_id(media_id).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() { if(ppixiv.native) return await local_api.load_recent_bookmark_tags(); let url = "/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; } // Mute a user or tag using the Pixiv mute list. type must be "tag" or "user". static async add_pixiv_mute(value, {type}) { console.log(\`Adding \${value} to the Pixiv \${type} mute list\`); if(!muting.singleton.can_add_pixiv_mutes) { message_widget.singleton.show("The Pixiv mute list is full."); return; } // Stop if the value is already in the list. let mute_list = type == "tag"? "pixiv_muted_tags":"pixiv_muted_user_ids"; let mutes = muting.singleton[mute_list]; if(mutes.indexOf(value) != -1) return; // Get the label. If this is a tag, the label is the same as the tag, otherwise // get the user's username. We only need this for the message we'll display at the // end. let label = value; if(type == "user") label = (await image_data.singleton().get_user_info(value)).name; // Note that this doesn't return an error if the mute list is full. It returns success // and silently does nothing. let result = await helpers.rpc_post_request("/ajax/mute/items/add", { context: "illust", type: type, value: value, }); if(result.error) { message_widget.singleton.show(result.message); return; } // The API call doesn't return the updated list, so we have to update it manually. mutes.push(value); // Pixiv sorts the muted tag list, so mute it here to match. if(type == "tag") mutes.sort(); muting.singleton[mute_list] = mutes; message_widget.singleton.show(\`Muted the \${type} \${label}\`); } // Remove item from the Pixiv mute list. type must be "tag" or "user". static async remove_pixiv_mute(value, {type}) { console.log(\`Removing \${value} from the Pixiv muted \${type} list\`); // Get the label. If this is a tag, the label is the same as the tag, otherwise // get the user's username. We only need this for the message we'll display at the // end. let label = value; if(type == "user") label = (await image_data.singleton().get_user_info(value)).name; let result = await helpers.rpc_post_request("/ajax/mute/items/delete", { context: "illust", type: type, value: value, }); if(result.error) { message_widget.singleton.show(result.message); return; } // The API call doesn't return the updated list, so we have to update it manually. let mute_list = type == "tag"? "pixiv_muted_tags":"pixiv_muted_user_ids"; let mutes = muting.singleton[mute_list]; let idx = mutes.indexOf(value); if(idx != -1) mutes.splice(idx, 1); muting.singleton[mute_list] = mutes; message_widget.singleton.show(\`Unmuted the \${type} \${label}\`); } // value is a tag name or user ID. label is the tag or username. type must be // "tag" or "user". static async add_extra_mute(value, label, {type}) { console.log(\`Adding \${value} (\${label}) to the extra muted \${type} list\`); // Stop if the item is already in the list. let mutes = muting.singleton.extra_mutes; for(let {value: muted_value, type: muted_type} of mutes) if(value == muted_value && type == muted_type) { console.log("Item is already muted"); return; } mutes.push({ type: type, value: value, label: label, }); mutes.sort((lhs, rhs) => { return lhs.label.localeCompare(rhs.label); }); muting.singleton.extra_mutes = mutes; message_widget.singleton.show(\`Muted the \${type} \${label}\`); } static async remove_extra_mute(value, {type}) { console.log(\`Removing \${value} from the extra muted \${type} list\`); let mutes = muting.singleton.extra_mutes; for(let idx = 0; idx < mutes.length; ++idx) { let mute = mutes[idx]; if(mute.type == type && mute.value == value) { message_widget.singleton.show(\`Unmuted the \${mute.type} \${mute.label}\`); mutes.splice(idx, 1); break; } } muting.singleton.extra_mutes = mutes; } // If the user has premium, add to Pixiv mutes. Otherwise, add to extra mutes. static async add_mute(value, label, {type}) { if(window.global_data.premium) { await actions.add_pixiv_mute(value, {type: type}); } else { if(type == "user" && label == null) { // We need to know the user's username to add to our local mute list. let user_data = await image_data.singleton().get_user_info(value); label = user_data.name; } await actions.add_extra_mute(value, label, {type: type}); } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/actions.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/r126/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=", xmlns: "http://www.w3.org/2000/svg", 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_icon(name, size) { let span = document.createElement("span"); span.classList = "material-icons"; span.innerText = name; if(size != null) span.style.fontSize = size; return span; }, 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() { if(ppixiv.native) { // We're running in a local environment and not on Pixiv, so we don't need to do // this stuff. Just add stubs for the functions we'd set up here. helpers.fetch = unsafeWindow.fetch; window.HTMLDocument.prototype.realCreateElement = window.HTMLDocument.prototype.createElement; window.cloneInto = (data, window) => { return data; } return; } // 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, value) { if(value) dataset[name] = value; 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) { // If seconds is negative, return a time in the future. let future = seconds < 0; if(future) seconds = -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"; let result = value.toFixed(places) + " " + label; result += future? " from now":" ago"; return result; }; 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); }, format_seconds(total_seconds) { total_seconds = Math.floor(total_seconds); let result = ""; let seconds = total_seconds % 60; total_seconds = Math.floor(total_seconds / 60); let minutes = total_seconds % 60; total_seconds = Math.floor(total_seconds / 60); let hours = total_seconds % 24; result = \`\${minutes}:\${seconds.toString().padStart(2, '0')}\`; if(hours > 0) { // Pad minutes to two digits if we have hours. result = result.padStart(5, '0'); result = hours + ":" + result; } return result; }, // Return i rounded up to interval. round_up_to: function(i, interval) { return Math.floor((i+interval-1)/interval) * interval; }, get_extension: function(fn) { var parts = fn.split("."); return parts[parts.length-1]; }, save_scroll_position(scroller, save_relative_to) { return { original_scroll_top: scroller.scrollTop, original_offset_top: save_relative_to.offsetTop, }; }, restore_scroll_position(scroller, restore_relative_to, saved_position) { let scroll_top = saved_position.original_scroll_top; if(restore_relative_to) { let offset = restore_relative_to.offsetTop - saved_position.original_offset_top; scroll_top += offset; } scroller.scrollTop = scroll_top; }, 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: async 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; } try { return await fetch(options.url, data); } catch(e) { // Don't log an error if we were intentionally aborted. if(data.signal && data.signal.aborted) return null; console.error("Error loading %s", options.url, e); if(options.data) console.error("Data:", options.data); return null; } }, // 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); if(result == null) return null; // 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 page 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, }); }, toggle_fullscreen() { if(!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); }, 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) { if(ppixiv.native) return; 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); }); }, // Return true if url is one of ours. is_ppixiv_url: function(url) { // If we're native, all URLs on this origin are ours. if(ppixiv.native) return new URL(url).origin == document.location.origin; else return url.hash.startsWith("#ppixiv"); }, get_hash_args: function(url) { if(!helpers.is_ppixiv_url(url)) return { path: "", query: new unsafeWindow.URLSearchParams() }; // The hash looks like: // // #ppixiv/a/b/c?foo&bar // // /a/b/c is the hash path. foo&bar are the hash args. // 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. // The pathname portion of this (with /ppixiv removed) is the hash path, // and the query portion is the hash args. // // If the hash is #ppixiv/abcd, the hash path is "/abcd". // Remove #ppixiv: let hash_path = url.hash; if(hash_path.startsWith("#ppixiv")) hash_path = hash_path.substr(7); else if(hash_path.startsWith("#")) hash_path = hash_path.substr(1); // See if we have hash args. let idx = hash_path.indexOf('?'); let query = null; if(idx != -1) { query = hash_path.substr(idx+1); hash_path = hash_path.substr(0, idx); } // We encode spaces as + in the URL, but decodeURIComponent doesn't, so decode // that first. Actual '+' is always escaped as %2B. hash_path = hash_path.replace(/\\+/g, " "); hash_path = decodeURIComponent(hash_path); // Use unsafeWindow.URLSearchParams to work around https://bugzilla.mozilla.org/show_bug.cgi?id=1414602. if(query == null) return { path: hash_path, query: new unsafeWindow.URLSearchParams() }; else return { path: hash_path, query: new unsafeWindow.URLSearchParams(query) }; }, // 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; }, args: class { constructor(url) { url = new URL(url, ppixiv.location); this.path = url.pathname; this.query = url.searchParams; let { path: hash_path, query: hash_query } = helpers.get_hash_args(url); this.hash = hash_query; this.hash_path = hash_path; // 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, if this.hash_path is "a/b/c" and this.hash is { a: "1", b: "2" }, // set the hash to #ppixiv/a/b/c?a=1&b=2. url.hash = ppixiv.native? "#":"#ppixiv"; if(this.hash_path != "") { if(!this.hash_path.startsWith("/")) url.hash += "/"; url.hash += helpers.encodeURLHash(this.hash_path); } let hash_string = helpers.encodeHashParams(this.hash); if(hash_string != "") url.hash += "?" + hash_string; return url; } toString() { return this.url.toString(); } // Helpers to get and set arguments which can be in either the query, // the hash or the path. Examples: // // get("page") - get the query parameter "page" // get("#page") - get the hash parameter "page" // get("/1") - get the first path parameter // set("page", 10) - set the query parameter "page" to "10" // set("#page", 10) - set the hash parameter "page" to "10" // set("/1", 10) - set the first path parameter to "10" // set("page", null) - remove the query parameter "page" get(key) { let hash = key.startsWith("#"); let path = key.startsWith("/"); if(hash || path) key = key.substr(1); if(path) return this.get_pathname_segment(parseInt(key)); let params = hash? this.hash:this.query; return params.get(key); } set(key, value) { let hash = key.startsWith("#"); let path = key.startsWith("/"); if(hash || path) key = key.substr(1); if(path) { this.set_pathname_segment(parseInt(key), value); return; } let params = hash? this.hash:this.query; if(value != null) params.set(key, value); else params.delete(key); } // Return the pathname segment with the given index. If the path is "/abc/def", "abc" is // segment 0. If idx is past the end, return "". get_pathname_segment(idx) { // The first pathname segment is always empty, since the path always starts with a slash. idx++; let parts = this.path.split("/"); let result = parts[idx]; return result || ""; } // Set the pathname segment with the given index. If the path is "/abc/def", setting // segment 0 to "ghi" results in "/ghi/def". // // If idx is at the end, a new segment will be added. If it's more than one beyond the // end a warning will be printed, since this usually shouldn't result in pathnames with // empty segments. If value is null, remove the segment instead. set_pathname_segment(idx, value) { idx++; let parts = this.path.split("/"); if(value != null) { if(idx < parts.length) parts[idx] = value; else if(idx == parts.length) parts.push(value); else console.warn(\`Can't set pathname segment \${idx} to \${value} past the end: \${this.toString()}\`); } else { if(idx == parts.length-1) parts.pop(); else if(idx < parts.length-1) console.warn(\`Can't remove pathname segment \${idx} in the middle: \${this.toString()}\`); } this.path = parts.join("/"); } }, // 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, { send_popstate=true }={}) { if(args instanceof URL) args = new helpers.args(args); var old_url = ppixiv.location.toString(); // Use the history state from args if it exists. let history_data = { ...args.state, }; // 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(history_data) == JSON.stringify(history.state)) return; // history.state.index is incremented whenever we navigate forwards, so we can // tell in onpopstate whether we're navigating forwards or backwards. 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(send_popstate && 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]; }, // Set node's maxHeight so it doesn't cross the bottom of the screen. set_max_height(node, { max_height=null, bottom_padding=0 }={}) { let {top} = node.getBoundingClientRect(document.body); let height = window.innerHeight - top; // Add a bit of padding so it's not flush against the edge. height -= bottom_padding; if(max_height != null) height = Math.min(max_height, height); node.style.maxHeight = \`\${height}px\`; }, 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); }, // Scale x from [l1,h2] to [l2,h2]. scale(x, l1, h1, l2, h2) { return (x - l1) * (h2 - l2) / (h1 - l1) + l2; }, // 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]); }, // Asynchronously wait for an animation frame. async vsync({signal=null}={}) { return new Promise((accept, reject) => { // The timestamp passed to the requestAnimationFrame callback is designed // incorrectly. It gives the time callbacks started being called, which is // meaningless. It should give the time in the future the current frame is // expected to be displayed, which is what you get from things like Android's // choreographer to allow precise frame timing. let id = requestAnimationFrame((time) => { accept(time); }); let abort = () => { cancelAnimationFrame(id); signal.removeEventListener("abort", abort); reject(); }; if(signal) { signal.addEventListener("abort", abort, { once: true }); } }); }, // 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. // // Return [css, columns], where css is the style to use and columns is the actual // column count. 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, best_columns]; }, // 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) { // Disable panning if we don't have the image size. Local directory thumbnails // don't tell us the dimensions in advance. if(width == null || height == null) { helpers.set_class(thumb, "vertical-panning", false); helpers.set_class(thumb, "horizontal-panning", false); return; } 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({vview=false}={}) { if(ppixiv.native || vview) helpers.set_page_icon(resources['resources/vview-icon.png']); else helpers.set_page_icon(resources['resources/regular-pixiv-icon.png']); }, set_title_and_icon(illust_data) { helpers.set_title(illust_data) helpers.set_icon() }, // 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(); }, // Split a "type:id" into its two parts. // // If there's no colon, this is a Pixiv illust ID, so set type to "illust". _split_id(id) { if(id == null) return { } let parts = id.split(":"); let type = parts.length < 2? "illust": parts[0]; let actual_id = parts.length < 2? id: parts.splice(1).join(":"); // join the rest return { type: type, id: actual_id, } }, // Encode a media ID. // // These represent single images, videos, etc. that we can view. Examples: // // illust:1234-0 - The first page of Pixiv illust ID 1234 // illust:1234-12 - Pixiv illust ID 1234, page 12. Pages are zero-based. // user:1000 - Pixiv user 1000. // folder:/images - A directory in the local API. // file:/images/image.jpg - A file in the local API. // // IDs with the local API are already in this format, and Pixiv illust IDs and pages are // converted to it. encode_media_id({type, id, page=null}={}) { if(type == "illust") { if(page == null) page = 0; id += "-" + page; } return type + ":" + id; }, parse_media_id(media_id) { // If this isn't an illust, a media ID is the same as an illust ID. let { type, id } = helpers._split_id(media_id); if(type != "illust") return { type: type, id: id, page: 0 }; // If there's no hyphen in the ID, it's also the same. if(media_id.indexOf("-") == -1) return { type: type, id: id, page: 0 }; // Split out the page. let parts = id.split("-"); let page = parts[1]; page = parseInt(page); id = parts[0]; return { type: type, id: id, page: page }; }, // Given a media ID, return the same media ID for the first page. // // Some things don't interact with pages, such as illust info loads, and // only store data with the ID of the first page. get_media_id_first_page(media_id) { if(media_id == null) return null; let id = helpers.parse_media_id(media_id); id.page = 0; return helpers.encode_media_id(id); }, // Convert a Pixiv illustration ID and page number to a media ID. illust_id_to_media_id(illust_id, page) { if(illust_id == null) return null; let { type, id } = helpers._split_id(illust_id); // Pages are only used for illusts. For other types, the page should always // be null or 0, and we don't include it in the media ID. if(type == "illust") { id += "-"; id += page || 0; } else { console.assert(page == null || page == 0); } return type + ":" + id; }, media_id_to_illust_id_and_page(media_id) { let { type, id, page } = helpers.parse_media_id(media_id); if(type != "illust") return [media_id, 0]; return [id, page]; }, // Return true if media_id is an ID for the local API. is_media_id_local(media_id) { let { type } = helpers.parse_media_id(media_id); return type == "file" || type == "folder"; }, // Return the last count parts of path. get_path_suffix(path, count=2) { let parts = path.split('/'); parts = parts.splice(parts.length-count); // take the last two parts return parts.join("/"); }, encodeURLPart(regex, part) { return part.replace(regex, (c) => { // encodeURIComponent(sic) encodes non-ASCII characters. We don't need to. let ord = c.charCodeAt(0); if(ord >= 128) return c; // Regular URL escaping wants to escape spaces as %20, which is silly since // it's such a common character in filenames. Escape them as + instead, like // things like AWS do. The escaping is different, but it's still a perfectly // valid URL. Note that the API doesn't decode these, we only use it in the UI. if(c == " ") return "+"; let hex = ord.toString(16).padStart('0', 2); return "%" + hex; }); }, // Both "encodeURI" and "encodeURIComponent" are wrong for encoding hashes. // The first doesn't escape ?, and the second escapes lots of things we // don't want to, like forward slash. encodeURLHash(hash) { return helpers.encodeURLPart(/[^A-Za-z0-9-_\\.!~\\*'()/:\\[\\]\\^=&]/g, hash); }, // This one escapes keys in hash parameters. This is the same as encodeURLHash, // except it also encodes = and &. encodeHashParam(param) { return helpers.encodeURLPart(/[^A-Za-z0-9-_\\.!~\\*'()/:\\[\\]\\^]/g, param); }, // Encode a URLSearchParams for hash parameters. // // We can use URLSearchParams.toString(), but that escapes overaggressively and // gives us nasty, hard to read URLs. There's no reason to escape forward slash // in query parameters. encodeHashParams(params) { let values = []; for(let key of params.keys()) { let key_values = params.getAll(key); for(let value of key_values) { key = helpers.encodeHashParam(key); value = helpers.encodeHashParam(value); values.push(key + "=" + value); } } return values.join("&"); }, // Escape a string to use in a CSS selector. // // If we're searching for [data-filename='path'], we need to escape quotes in "path". escape_selector(s) { return s.replace(/['"]/g, (c) => { return "\\\\" + c; }); }, title_case(s) { let parts = []; for(let part of s.split(" ")) parts.push(part.substr(0, 1).toUpperCase() + s.substr(1)); return parts.join(" "); }, // 1 -> 1 // 1:2 -> 0.5 // null -> null // "" -> null parse_ratio(value) { if(value == null || value == "") return null; if(value.indexOf == null) return value; let parts = value.split(":", 2); if(parts.length == 1) { return parseFloat(parts[0]); } else { let num = parseFloat(parts[0]); let den = parseFloat(parts[1]); return num/den; } }, // Parse: // 1 -> [1,1] // 1...2 -> [1,2] // 1... -> [1,null] // ...2 -> [null,2] // 1:2 -> [0.5, 0.5] // 1:2...2 -> [0.5, 2] // null -> null parse_range(range) { if(range == null) return null; let parts = range.split("..."); let min = helpers.parse_ratio(parts[0]); let max = helpers.parse_ratio(parts[1]); return [min, max]; }, // 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(); }, // Return the canonical URL for an illust. For most URLs this is // /artworks/12345. get_url_for_id(media_id) { let args = null; let [illust_id, page] = helpers.media_id_to_illust_id_and_page(media_id); if(helpers.is_media_id_local(media_id)) { // URLs for local files are handled differently. args = helpers.args.location; local_api.get_args_for_id(media_id, args); args.hash.set("view", "illust"); } else { args = new helpers.args("/", ppixiv.location); args.path = \`/artworks/\${illust_id}\`; } if(page != null && page > 1) args.query.set("page", page); return args.url; }, }; // 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(); }); } } // 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); this.element.addEventListener("simulatedpointerdown", 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_until_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_until_release)) { this.block_context_menu_until_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) => { // Prevent oncontextmenu if RMB was pressed and cancelled, or if we're blocking // it after release. if(this.block_context_menu_until_release || 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: new_button_mask, 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.element.dispatchEvent(e); } } } // This is like pointer_listener, but for watching for keys being held down. // This isn't meant to be used for single key events. ppixiv.global_key_listener = class { static singleton = null; constructor() { ppixiv.global_key_listener.singleton = this; this.keys_pressed = new Set(); this.listeners = new Map(); // by key // Listen to keydown on bubble, so we don't see key presses that were stopped // by the original target, but listen to keyup on capture. window.addEventListener("keydown", (e) => { if(this.keys_pressed.has(e.key)) return; this.keys_pressed.add(e.key); this.call_listeners_for_key(e.key, true); }); window.addEventListener("keyup", (e) => { if(!this.keys_pressed.has(e.key)) return; this.keys_pressed.delete(e.key); this.call_listeners_for_key(e.key, false); }, true); window.addEventListener("blur", (e) => { this.release_all_keys(); }); // If the context menu is shown, release all keys, since browsers forget to send // keyup events when the context menu is open. window.addEventListener("contextmenu", async (e) => { // This is a pain. We need to handle this event as late as possible, to let // all other handlers have a chance to preventDefault. If we check it now, // contextmenu handlers (like blocking_context_menu_until_timer) can be registered // after us, and we won't see their preventDefault. // // This really wants an option for event listeners that causes it to be run after // other event handlers, but doesn't allow it to preventDefault, for event handlers // that specifically want to know if an event ended up being prevented. But that // doesn't exist, so instead we just sleep to exit to the event loop, and look at // the event after it's completed. await helpers.sleep(0); if(e.defaultPrevented) return; this.release_all_keys(); }); } release_all_keys() { for(let key of this.keys_pressed) this.call_listeners_for_key(key, false); this.keys_pressed.clear(); } get_listeners_for_key(key, { create=false }={}) { if(!this.listeners.has(key)) { if(!create) return []; this.listeners.set(key, new Set); } return this.listeners.get(key); } register_listener(key, listener) { let listeners_for_key = this.get_listeners_for_key(key, { create: true }); listeners_for_key.add(listener); // If key is already pressed, run the callback. Defer this so we don't call // it while the caller is still registering. setTimeout(() => { // Stop if the listener was unregistered before we got here. if(!this.get_listeners_for_key(key).has(listener)) return; if(this.keys_pressed.has(key)) listener.key_changed(true); }, 0); } unregister_listener(key, listener) { let listeners_for_key = this.get_listeners_for_key(key, { create: false }); if(listeners_for_key) listeners_for_key.delete(listener); } call_listeners_for_key = (key, down) => { let listeners_for_key = this.get_listeners_for_key(key, { create: false }); if(listeners_for_key == null) return; for(let key_listener of listeners_for_key.values()) key_listener.key_changed(down); }; } ppixiv.key_listener = class { constructor(key, callback, {signal=null}={}) { this.key_changed = this.key_changed.bind(this); this.callback = callback; this.pressed = false; ppixiv.global_key_listener.singleton.register_listener(key, this); if(signal) { signal.addEventListener("abort", (e) => { ppixiv.global_key_listener.singleton.unregister_listener(key, this); }); } } key_changed(pressed) { if(this.pressed == pressed) return; this.pressed = pressed; this.callback(pressed); } } // 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"]); } // Store info about an image that we've loaded data for. add_info(image_info) { // Store one record per page. let pages = []; for(let page = 0; page < image_info.pageCount; ++page) { let illust_id = image_info.id; let media_id = helpers.illust_id_to_media_id(image_info.id, 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: media_id, 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(media_id) { return this.db.db_op(async (db) => { let store = this.db.get_store(db); let record = await key_storage.async_store_get(store, media_id); if(record == null) return null; else return record.url; }); } async guess_url(media_id) { // If this is a local URL, we always have the image URL and we don't need to guess. let { type, page } = helpers.parse_media_id(media_id); console.assert(type != "folder"); if(type == "file") { let thumb = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(thumb?.illustType == "video") return null; else return thumb?.mangaPages[page]?.urls?.original; } // If we already have illust info, use it. let illust_info = image_data.singleton().get_media_info_sync(media_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(media_id); 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(media_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(media_id) { // If this was a stored URL, remove it from the database. await this.db.multi_delete([media_id]); } }; // Helpers for working with paths. ppixiv.helpers.path = { // Return true if array begins with prefix. array_starts_with(array, prefix) { if(array.length < prefix.length) return false; for(let i = 0; i < prefix.length; ++i) if(array[i] != prefix[i]) return false; return true; }, is_relative_to(path, root) { let path_parts = path.split("/"); let root_parts = root.split("/"); return ppixiv.helpers.path.array_starts_with(path_parts, root_parts); }, split_path(path) { // If the path ends with a slash, remove it. if(path.endsWith("/")) path = path.substr(0, path.length-1); let parts = path.split("/"); return parts; }, // Return absolute_path relative to relative_to. get_relative_path(relative_to, absolute_path) { console.assert(absolute_path.startsWith("/")); console.assert(relative_to.startsWith("/")); let path_parts = ppixiv.helpers.path.split_path(absolute_path); let root_parts = ppixiv.helpers.path.split_path(relative_to); // If absolute_path isn"t underneath relative_to, leave it alone. if(!ppixiv.helpers.path.array_starts_with(path_parts, root_parts)) return absolute_path; let relative_parts = path_parts.splice(root_parts.length); return relative_parts.join("/"); }, // Append child to path. get_child(path, child) { // If child is absolute, leave it alone. if(child.startsWith("/")) return child; let path_parts = ppixiv.helpers.path.split_path(path); let child_parts = ppixiv.helpers.path.split_path(child); let combined = path_parts.concat(child_parts); return combined.join('/'); }, }; ppixiv.FixedDOMRect = class extends DOMRect { constructor(left, top, right, bottom) { super(left, top, right-left, bottom-top); } get left() { return super.left; } set left(value) { let delta = value - this.x; this.x += delta; this.width -= delta; } get top() { return super.top; } set top(value) { let delta = value - this.y; this.y += delta; this.height -= delta; } get right() { return super.right; } set right(value) { this.width += value - this.right; } get bottom() { return super.bottom; } set bottom(value) { this.height += value - this.bottom; } } // Add: // // await controller.signal.wait() // // to wait for an AbortSignal to be aborted. AbortSignal.prototype.wait = function() { if(this._promise == null) { this._promise = new Promise((accept) => { this._promise_accept = accept; }); this.addEventListener("abort", (e) => { console.log("done"); this._promise_accept(); }, { once: true }); } return this._promise; }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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 = { }; // When a setting changes, an event with the name of the setting is dispatched on // settings.changes. static changes = new EventTarget(); 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); let event = new Event(key); settings.changes.dispatchEvent(event); } static register_change_callback(key, callback, { signal=null }={}) { settings.get_change_callback_list(key).register(callback, signal); } static unregister_change_callback(key, callback) { settings.get_change_callback_list(key).unregister(callback); } // Adjust a zoom setting up or down. static adjust_zoom(setting, down) { let value = settings.get(setting); if(typeof(value) != "number" || isNaN(value)) value = 4; value += down?-1:+1; value = helpers.clamp(value, 0, 5); this._slider_value = value; this.value = this._slider_value; settings.set(setting, value); } } // Register settings. ppixiv.settings.configure("zoom-mode", { sticky: true }); ppixiv.settings.configure("theme", { default: "dark" }); 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 }); ppixiv.settings.configure("volume", { default_value: 1 }); ppixiv.settings.configure("view_mode", { default_value: "illust" }); ppixiv.settings.configure("image_editing", { session: true }); ppixiv.settings.configure("image_editing_mode", { session: true }); ppixiv.settings.configure("inpaint_create_lines", { session: true }); ppixiv.settings.configure("auto_pan_ease", { default_value: 1 }); ppixiv.settings.configure("slideshow_duration", { default_value: 15 }); ppixiv.settings.configure("auto_pan_duration", { default_value: 3 }); ppixiv.settings.configure("extra_mutes", { default_value: [] }); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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. 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.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/r126/src/fix_chrome_clicks.js `; ppixiv.resources["src/widgets.js"] = `"use strict"; // A basic widget base class. ppixiv.widget = class { constructor({ container, template=null, contents=null, parent=null, visible=true, shutdown_signal=null, ...options}={}) { this.options = options; this.templates = {}; // If our parent is passing us a shared shutdown signal, use it. Otherwise, create // our own. this.shutdown_signal = shutdown_signal || new AbortController(); // We must have either a template or contents. if(template) { console.assert(contents == null); this.container = this.create_template({html: template}); if(container != null) container.appendChild(this.container); } else { // contents is a widget that's already created. The container is always // the parent of contents, so container shouldn't be specified in this mode. console.assert(container == null); console.assert(contents != null); this.container = contents; } this.container.classList.add("widget"); this.container.widget = this; this.parent = parent; // If we're visible, we'll unhide below. 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(); } shutdown() { // Signal shutdown_signal to remove event listeners. console.assert(this.shutdown_signal != null); this.shutdown_signal.abort(); this.shutdown_signal = null; this.container.remove(); } // 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().addEventListener("mediamodified", this.refresh.bind(this), { signal: this.shutdown_signal.signal }); } // 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_media_id(media_id) { if(this._media_id == media_id) return; this._media_id = media_id; let [illust_id, page] = helpers.media_id_to_illust_id_and_page(media_id); this._page = page; this.refresh(); } get illust_id() { throw "FIXME"; } // making sure all uses of this are removed get media_id() { return this._media_id; } async refresh() { // Grab the illust info. let media_id = this._media_id; let info = { media_id: this._media_id }; if(this._media_id != null) { // See if we have the data the widget wants already. info.thumbnail_data = thumbnail_data.singleton().get_illust_data_sync(this._media_id); info.illust_data = image_data.singleton().get_media_info_sync(this._media_id); let load_needed = false; switch(this.needed_data) { case "thumbnail": info.thumbnail_data = thumbnail_data.singleton().get_illust_data_sync(this._media_id); if(info.thumbnail_data == null) load_needed = true; break; case "illust_info": info.illust_data = image_data.singleton().get_media_info_sync(this._media_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 "media_id": break; // nothing case "thumbnail": info.thumbnail_data = await thumbnail_data.singleton().get_or_load_illust_data(this._media_id); break; case "illust_info": info.illust_data = await image_data.singleton().get_media_info(this._media_id); break; default: throw new Error("Unknown: " + this.needed_data); } } // Stop if the media ID changed while we were async. if(this._media_id != media_id) return; await this.refresh_internal(info); } async refresh_internal({ media_id, illust_id, illust_data, thumbnail_data }) { throw "Not implemented"; } } // Display messages in the popup widget. This is a singleton. ppixiv.message_widget = class extends widget { static get singleton() { if(message_widget._singleton == null) message_widget._singleton = new message_widget({container: document.body}); return message_widget._singleton; } constructor(options) { super({...options, template: \`
\`, }); this.timer = null; } show(message) { console.assert(message != null); 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_id == -1) { 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) 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({ title, ...options }={}) { super({...options, container: document.body, visible: true, template: \`
X
+
\`}); this.submit = this.submit.bind(this); this.onkeydown = this.onkeydown.bind(this); this.result = new Promise((completed, cancelled) => { this._completed = completed; }); this.input = this.container.querySelector("input"); this.input.value = ""; this.container.querySelector(".title").innerText = title; 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._completed(null); } } // 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 "media_id"; } constructor({...options}) { super({...options, template: \` \`}); this.displaying_media_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._media_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 media_id to save tags when we're closed. Otherwise, media_id will already // be cleared when we close and we won't be able to save. set_media_id(media_id) { // If we're hiding and were previously visible, save changes. if(media_id == null) this.save_current_tags(); super.set_media_id(media_id); } // 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_media_id = null; } } async refresh_internal({ media_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_media_id == media_id? this.selected_tags:[]; this.displaying_media_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(media_id == null) this.visible = false; if(media_id == null || !this.visible) return; // Fit the tag scroll box within however much space we have available. helpers.set_max_height(this.container.querySelector(".tag-list"), { max_height: 400, bottom_padding: 10 }); // 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(media_id); // Remember which illustration's bookmark tags are actually loaded. this.displaying_media_id = media_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); // Add any tags that are on the bookmark but not in recent tags. for(let tag of active_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 media_id = this._media_id; let new_tags = this.selected_tags; if(media_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(media_id != this.displaying_media_id) return; // Get the tags currently on the bookmark to compare. let old_tags = await image_data.singleton().load_bookmark_details(media_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._media_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(); let menu_options = { similar_illustrations: () => { return new menu_option_button({ ...shared_options, label: "Similar illustrations", icon: "resources/related-illusts.svg", requires_image: true, onclick: () => { this.parent.hide(); let [illust_id] = helpers.media_id_to_illust_id_and_page(this.media_id); let args = new helpers.args(\`/bookmark_detail.php?illust_id=\${illust_id}#ppixiv?recommendations=1\`); helpers.set_page_url(args, true /* add_to_history */, "navigation"); } }); }, similar_artists: () => { return 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"); } }); }, similar_bookmarks: () => { return new menu_option_button({ ...shared_options, label: "Similar bookmarks", icon: "resources/related-illusts.svg", requires_image: true, onclick: () => { this.parent.hide(); let [illust_id] = helpers.media_id_to_illust_id_and_page(this.media_id); let args = new helpers.args(\`/bookmark_detail.php?illust_id=\${illust_id}#ppixiv\`); helpers.set_page_url(args, true /* add_to_history */, "navigation"); } }); }, mute_user: () => { return new menu_option_button({ ...shared_options, label: "Mute user", icon: helpers.create_icon("block", "16px"), requires_user: true, hide_if_unavailable: true, available: () => { return !muting.singleton.is_muted_user_id(this.user_id); }, onclick: async () => { this.parent.hide(); await actions.add_mute(this.user_id, null, {type: "user"}); } }); }, unmute_user: () => { return new menu_option_button({ ...shared_options, label: "Unmute user", icon: helpers.create_icon("block", "16px"), requires_user: true, hide_if_unavailable: true, available: () => { return muting.singleton.is_muted_user_id(this.user_id); }, onclick: async () => { this.parent.hide(); muting.singleton.unmute_user_id(this.user_id); } }); }, // XXX: hook into progress bar download_image: () => { return 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.media_id, null, "image"); this.parent.hide(); } }); }, download_manga: () => { return 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.media_id, null, "ZIP"); this.parent.hide(); } }); }, download_video: () => { return 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.media_id, null, "MKV"); this.parent.hide(); } }); }, send_to_tab: () => { return 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.media_id); this.parent.hide(); } }); }, toggle_slideshow: () => { return new menu_option_button({ ...shared_options, label: "Slideshow", icon: helpers.create_icon("wallpaper", "16px"), requires_image: true, onclick: () => { // Add or remove slideshow=1 from the hash. let args = helpers.args.location; let enabled = args.hash.get("slideshow") == "1"; if(enabled) args.hash.delete("slideshow"); else args.hash.set("slideshow", "1"); helpers.set_page_url(args, false, "toggle slideshow"); this.parent.hide(); } }); }, linked_tabs: () => { return new menu_option_toggle({ container: option_box, parent: this, label: "Linked tabs", setting: "linked_tabs_enabled", icon: helpers.create_icon("link", "16px"), buttons: [ new menu_option_button({ container: option_box, parent: this, label: "Edit", classes: ["small-font"], no_icon_padding: true, onclick: (e) => { e.stopPropagation(); new ppixiv.settings_dialog({ container: document.body, show_page: "linked_tabs", }); this.parent.hide(); return true; }, }), ], }); }, edit_inpainting: () => { return new menu_option_toggle({ ...shared_options, label: "Edit image", icon: helpers.create_icon("brush", "16px"), hide_if_unavailable: true, setting: "image_editing", requires: ({media_id}) => { return media_id != null && helpers.is_media_id_local(media_id); }, }); }, exit: () => { return new menu_option_button({ ...shared_options, label: "Return to Pixiv", icon: "resources/pixiv-icon.svg", url: "#no-ppixiv", }); }, }; this.menu_options = []; if(!ppixiv.native) { this.menu_options.push(menu_options.similar_illustrations()); this.menu_options.push(menu_options.similar_artists()); this.menu_options.push(menu_options.similar_bookmarks()); this.menu_options.push(menu_options.mute_user()); this.menu_options.push(menu_options.unmute_user()); this.menu_options.push(menu_options.download_image()); this.menu_options.push(menu_options.download_manga()); this.menu_options.push(menu_options.download_video()); } this.menu_options.push(menu_options.send_to_tab()); this.menu_options.push(menu_options.linked_tabs()); this.menu_options.push(menu_options.toggle_slideshow()); this.menu_options.push(menu_options.edit_inpainting()); if(!ppixiv.native) this.menu_options.push(menu_options.exit()); // 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({ media_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 && media_id == null) enable = false; if(option.options.requires_user && this.user_id == null) enable = false; if(option.options.requires && !option.options.requires({media_id: media_id, user_id: this.user_id})) 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 "media_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(); e.stopPropagation(); // 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({ media_id }) { if(this.require_image) helpers.set_class(this.container, "enabled", media_id != null); } } ppixiv.bookmark_button_widget = class extends ppixiv.illust_widget { get needed_data() { return "thumbnail"; } constructor({bookmark_type, bookmark_tag_widget, ...options}) { super({...options}); this.bookmark_type = bookmark_type; 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({ media_id, thumbnail_data }) { // If this is a local image, we won't have a bookmark count, so set local-image // to remove our padding for it. We can get media_id before thumbnail_data. let is_local = helpers.is_media_id_local(media_id); helpers.set_class(this.container, "has-like-count", !is_local); let { type } = helpers.parse_media_id(media_id); // Hide the private bookmark button for local IDs. if(this.bookmark_type == "private") this.container.closest(".button-container").hidden = is_local; let bookmarked = thumbnail_data?.bookmarkData != null; let private_bookmark = this.bookmark_type == "private"; let our_bookmark_type = thumbnail_data?.bookmarkData?.private == 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. this.container.dataset.popup = thumbnail_data == null? "": !bookmarked && this.bookmark_type == "folder"? "Bookmark folder": !bookmarked && this.bookmark_type == "private"? "Bookmark privately": !bookmarked && this.bookmark_type == "public" && type == "folder"? "Bookmark folder": !bookmarked && this.bookmark_type == "public"? "Bookmark image": our_bookmark_type? "Remove bookmark": "Change bookmark to " + this.bookmark_type; } // 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._media_id); let private_bookmark = this.bookmark_type == "private"; if(illust_data.bookmarkData && illust_data.bookmarkData.private == private_bookmark) { let media_id = this._media_id; await actions.bookmark_remove(this._media_id); // If the current image changed while we were async, stop. if(media_id != this._media_id) 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._media_id, { private: 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 "media_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({ media_id }) { // Hide the like button for local IDs. this.container.closest(".button-container").hidden = helpers.is_media_id_local(media_id); let liked_recently = media_id != null? image_data.singleton().get_liked_recently(media_id):false; helpers.set_class(this.container, "liked", liked_recently); helpers.set_class(this.container, "enabled", !liked_recently); this.container.dataset.popup = this._media_id == null? "": liked_recently? "Already liked image":"Like image"; } clicked_like = (e) => { e.preventDefault(); e.stopPropagation(); if(this._media_id != null) actions.like_image(this._media_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/r126/src/widgets.js `; ppixiv.resources["src/local_api.js"] = `"use strict"; // Helpers for the local API. ppixiv.local_api = class { static get local_url() { // If we're running natively, the API is on the same URL as we are. if(ppixiv.native) return new URL("/", document.location); let url = settings.get("local_api_url"); if(url == null) return null; return new URL(url); } // Return the URL path used by the UI. static get path() { // When running natively, the path is just /. if(ppixiv.native) return "/"; else return "/local/"; } static async local_post_request(pathname, data={}, options={}) { let url = ppixiv.local_api.local_url; if(url == null) throw Error("Local API isn't enabled"); url.pathname = encodeURI(pathname); var result = await helpers.send_pixiv_request({ method: "POST", url: url.toString(), responseType: "json", data: JSON.stringify(data), signal: options.signal, }); // If the result isn't valid JSON, we'll get a null result. if(result == null) result = { error: true, message: "Invalid response" }; return result; } // Return true if the local API is enabled. static is_enabled() { return ppixiv.local_api.local_url != null; } // Load image info from the local API. static async load_media_info(media_id) { let illust_data = await local_api.local_post_request(\`/api/illust/\${media_id}\`); if(illust_data.success) local_api.adjust_illust_info(illust_data.illust); return illust_data; } // Fill in some redundant fields in. The local API doesn't use mangaPages, // but we fill it in from urls so we can treat it the same way. static adjust_illust_info(illust) { let { type } = helpers.parse_media_id(illust.id); if(type == "folder") { illust.mangaPages = []; illust.pageCount = 0; // These metadata fields don't exist for folders. Set them to null so thumbnail_data._check_illust_data doesn't complain. illust.width = illust.height = illust.userName = null; illust.illustType = 1; } else { illust.mangaPages = [{ width: illust.width, height: illust.height, urls: illust.urls, }]; illust.pageCount = 1; } } // This is called early in initialization. If we're running natively and // the URL is empty, navigate to a default directory, so we don't start off // on an empty page every time. static async set_initial_url() { if(!ppixiv.native || document.location.hash != "") return; // Read the folder list. If we have any mounts, navigate to the first one. Otherwise, // show folder:/ as a fallback. let illust_id = "folder:/"; let result = await local_api.list(illust_id); if(result.results.length) illust_id = result.results[0].id; let args = helpers.args.location; local_api.get_args_for_id(illust_id, args); helpers.set_page_url(args, false, "initial"); } // Run a search against the local API. // // The results will be registered as thumbnail info and returned. static async list(path="", {...options}={}) { let result = await local_api.local_post_request(\`/api/list/\${path}\`, { ...options, }); if(!result.success) { console.error("Error reading directory:", result.reason); return result; } for(let illust of result.results) ppixiv.local_api.adjust_illust_info(illust); thumbnail_data.singleton().loaded_thumbnail_info(result.results, "internal"); return result; } static loading_media_ids = {}; static is_media_id_loading(media_id) { return this.loading_media_ids[media_id]; } // This is like thumbnail_data.loaded_thumbnail_info(). static async load_media_ids(media_ids) { // Filter out IDs that are already loading or loaded. let media_ids_to_load = []; for(let media_id of media_ids) { if(thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id)) continue; media_ids_to_load.push(media_id); this.loading_media_ids[media_id] = true; } if(media_ids_to_load.length == 0) return; let result = await local_api.local_post_request(\`/api/illusts\`, { ids: media_ids_to_load, }); for(let media_id of media_ids) { delete this.loading_media_ids[media_id]; } if(!result.success) { console.error("Error reading IDs:", result.reason); return; } for(let illust of result.results) ppixiv.local_api.adjust_illust_info(illust); thumbnail_data.singleton().loaded_thumbnail_info(result.results, "internal"); // Broadcast that we have new thumbnail data available. window.dispatchEvent(new Event("thumbnailsloaded")); } static async bookmark_add(media_id, options) { let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_id); let bookmark_options = { }; if(options.tags != null) bookmark_options.tags = options.tags; // Remember whether this is a new bookmark or an edit. let was_bookmarked = illust_info.bookmarkData != null; let result = await local_api.local_post_request(\`/api/bookmark/add/\${media_id}\`, { ...bookmark_options, }); if(!result.success) return; // Update bookmark tags and thumbnail data. image_data.singleton().update_cached_bookmark_image_tags(media_id, result.bookmark.tags); thumbnail_data.singleton().update_illust_data(media_id, { bookmarkData: result.bookmark }); let { type } = helpers.parse_media_id(media_id); message_widget.singleton.show( was_bookmarked? "Bookmark edited": type == "folder"? "Bookmarked folder":"Bookmarked", ); image_data.singleton().call_illust_modified_callbacks(media_id); } static async bookmark_remove(media_id) { let illust_info = await thumbnail_data.singleton().get_or_load_illust_data(media_id); if(illust_info.bookmarkData == null) { console.log("Not bookmarked"); return; } let result = await local_api.local_post_request(\`/api/bookmark/delete/\${media_id}\`); if(!result.success) return; thumbnail_data.singleton().update_illust_data(media_id, { bookmarkData: null }); message_widget.singleton.show("Bookmark removed"); image_data.singleton().call_illust_modified_callbacks(media_id); } static async load_recent_bookmark_tags() { let result = await local_api.local_post_request(\`/api/bookmark/tags\`); if(!result.success) { console.log("Error fetching bookmark tag counts"); return; } let tags = []; for(let tag of Object.keys(result.tags)) { // Skip "untagged". if(tag == "") continue; tags.push(tag); } tags.sort(); return tags; } // Given a local ID, return the separated directory and filename. id is // the id result of helpers.parse_media_id when type is "file". static split_local_id(id) { let idx = id.lastIndexOf("/"); let directory = id.substr(0, idx); let filename = id.substr(idx+1); return { directory: directory, filename: filename }; } // The local data source URL has three parts: the root, the path, and the file // being viewed (if any). The path can be absolute or relative to root. The // file be absolute or relative to path. // // Root is args.hash_path, path is args.hash.get("path"), and file is args.hash.get("file"). // // When searching, the root is the directory that was searched. If a folder is // clicked inside search results, it goes in the path, leaving the root alone so we're // still in the search. If a file is clicked inside search results, it goes in // file. These are all usually paths relative to the previous part, but they're allowed // to be absolute. // // Changes to root and path result in a new data source. // // Examples: // // #ppixiv/images/pictures?path=vacation/day1 // // The user searched inside /images/pictures, and is currently viewing the folder // /images/pictures/vacation/day1. // // #ppixiv/images/pictures?file=vacation/image.jpg // // The user searched inside /images/pictures, and is currently viewing the image // vacation/image.jpg. There's no path, which means the image was listed directly in the // search results. We're showing that a search is active, but the current view is // a folder inside the search, not the search itself. This case is important: since // the path hasn't changed, the data source is still the search, so you can mousewheel // within the search. // // #ppixiv/images/pictures?path=vacation/day1&file=image.jpg // // The user searched inside /images/pictures, navigated to the folder vacation/day1 in // the results, then viewed image.jpg from there. The data source is the folder. // // When no search is active, we never use path. We just put the folder inside the // root. // // It's tricky to figure out where to edit the URL, but combining them is simple: // hash_path + path + file. static get_args_for_id(media_id, args) { if(args.path != local_api.path) { // Navigating to a local URL from somewhere else. The search options // are unrelated, so just reset the URL. // XXX: untested args.path = local_api.path; args.query = new URLSearchParams(); args.hash = new URLSearchParams(); args.hash_path = ""; } // The path previously on args: let args_root = args.hash_path || ""; let args_path = args.hash.get("path") || ""; // let args_file = args.hash.get("file") || ""; // The new path to set: let { type, id: path } = helpers.parse_media_id(media_id); if(type == "file") { // Put the relative path to new_path from root/path in "file". let folder = helpers.path.get_child(args_root, args_path); let filename = helpers.path.get_relative_path(folder, path); args.hash.set("file", filename); return args; } // This is a folder. Remove any file in the URL. args.hash.delete("file"); // Remove the page when linking to a folder. Don't do this for files, since the // page should be left in place when viewing an image. args.query.delete("p"); // If we're going to a folder and the current page is shuffled, don't shuffle the // folder we're going to. If the user shuffled folder:/books and then clicked a // random book, he probably doesn't want the pages in the book shuffled too. Don't // do this if we're going to a file, since it doesn't matter and we don't want to // cause the data source to change. if(args.hash.get("order") == "shuffle") args.hash.delete("order"); // If a search isn't active, just put the folder in the root and remove any path. let search_active = local_api.get_search_options_for_args(args).search_options != null; if(!search_active) { args.hash_path = path; args.hash.delete("path"); return args; } // When in a search, leave hash_path alone, and put the relative path to the folder // in path. let relative_path = helpers.path.get_relative_path(args.hash_path, path); if(relative_path != "") args.hash.set("path", relative_path); else args.hash.delete("path"); return args; } // The search root is the top of the current search, which is where the // tree view starts. This is just the hash path. // XXX: move this into the tree static get_search_root_from_args(args, search_options) { // If there's no search active, the root is always the top. if(search_options == null) return "folder:/"; return "folder:" + args.hash_path; } // Get the local file or folder ID from a URL. // // Normally, a URL is a file if a "file" hash arg is present, otherwise it's // a folder. If get_folder is true, return the folder, ignoring any file argument. static get_local_id_from_args(args, { get_folder=false }={}) { // Combine the hash path and the filename to get the local ID. let root = args.hash_path; let path = args.hash.get("path"); if(path != null) { // The path can be relative or absolute. root = helpers.path.get_child(root, path) } let file = args.hash.get("file"); if(file == null || get_folder) return "folder:" + root; // The file can also be relative or absolute. if(!file.startsWith("/")) file = helpers.path.get_child(root, file) return "file:" + file; } // Return the API search options and title for the given URL. static get_search_options_for_args(args) { let search_options = { }; let title = null; let search_root = helpers.get_path_suffix(args.hash_path, 2); if(args.hash.has("search")) { search_options.search = args.hash.get("search"); title = "Search: " + search_options.search; } if(args.hash.has("bookmark-tag")) { search_options.bookmarked = true; search_options.bookmark_tags = args.hash.get("bookmark-tag"); if(search_options.bookmark_tags != "") title = \`Bookmarks tagged \${search_options.bookmark_tags}\`; else title = \`Untagged bookmarks\`; } else if(args.hash.has("bookmarks")) { search_options.bookmarked = true; title = "Bookmarks"; } if(args.hash.has("type")) { search_options.media_type = args.hash.get("type"); if(!title) title = helpers.title_case(search_options.media_type); } if(args.hash.has("aspect-ratio")) { let range = args.hash.get("aspect-ratio"); search_options.aspect_ratio = helpers.parse_range(range); } if(args.hash.has("pixels")) { let range = args.hash.get("pixels"); search_options.total_pixels = helpers.parse_range(range); } if(title) title += \` inside \${search_root}\`; // Clear search_options if it has no keys, to indicate that we're not in a search. if(Object.keys(search_options).length == 0) { search_options = null; // When there's no search, just show the current path as the title. let folder_id = local_api.get_local_id_from_args(args, { get_folder: true }); let { id } = helpers.parse_media_id(folder_id); title = helpers.get_path_suffix(id); } return { search_options: search_options, title: title }; } // Given a folder ID, return its parent. If folder_id is the root, return null. static get_parent_folder(media_id) { if(media_id == "folder:/") return null; // media_id can be a file or a folder. We always return a folder. let { id } = helpers.parse_media_id(media_id); let parts = id.split("/"); if(parts.length == 2) return "folder:/"; // return folder:/, not folder: parts.splice(parts.length-1, 1); return "folder:" + parts.join("/"); } // Navigate to the top of local search. This is the "Local Search" button in the // search menu. // // We don't want to just navigate to folder:/, since most people will only have one // library mounted, so the first thing they'll always see is a page with their one // folder on it that they have to click into. Instead, load the library list, and // open the top of the first one. static async show_local_search(e) { e.preventDefault(); let result = await local_api.list("folder:/"); if(!result.success) { console.error("Error reading libraries:", result.reason); return; } let libraries = result.results; if(libraries.length == 0) { alert("No libraries are available"); return; } let folder_id = libraries[0].id; let args = new helpers.args("/", ppixiv.location); local_api.get_args_for_id(folder_id, args); helpers.set_page_url(args.url, true /* add to history */, "navigation"); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/local_api.js `; ppixiv.resources["src/local_widgets.js"] = `"use strict"; // Widgets only used for local file navigation. ppixiv.tree_widget = class extends ppixiv.widget { constructor({ add_root=true, ...options}) { super({...options, template: \`
\`}); this.illust_modified = this.illust_modified.bind(this); this.label_popup = this.create_template({html: \`
\`}); this.thumb_popup = this.create_template({html: \`
\`}); this.items = this.container.querySelector(".items"); // Listen to illust changes so we can refresh nodes. image_data.singleton().illust_modified_callbacks.register(this.illust_modified); // Create the root item. This is tree_widget_item or a subclass. if(add_root) { let root = new ppixiv.tree_widget_item({ parent: this, label: "root", root: true, }); this.set_root(root); } } illust_modified(media_id) { if(this.root == null) return; for(let node of Object.values(this.root.nodes)) { if(node.illust_changed) node.illust_changed(media_id); } } // Given an element, return the tree_widget_item label it's inside, if any. get_widget_from_element(element) { let label = element.closest(".tree-item > .self > .label"); if(label == null) return null; let item = label.closest(".tree-item"); return item.widget; } set_root(root) { if(this.root == root) return; // If we have another root, remove it from this.items. if(this.root) { this.root.container.remove(); this.root = null; } this.root = root; // Add the new root to this.items. if(root.container.parentNode != this.items) { console.assert(root.parentNode == null); this.items.appendChild(root.container); } // Root nodes are always expanded. root.expanded = true; } set_selected_item(item) { if(this.selected_item == item) return; this.selected_item = item; for(let node of this.container.querySelectorAll(".tree-item.selected")) node.classList.remove("selected"); if(item != null) { item.container.classList.add("selected"); // If the item isn't visible, center it. // // Bizarrely, while there's a full options dict for scrollIntoView and you // can control horizontal and vertical scrolling separately, there's no "none" // option so you can scroll vertically and not horizontally. let scroll_container = this.container; let label = item.container.querySelector(".label"); let old_scroll_left = scroll_container.scrollLeft; label.scrollIntoView({ block: "nearest" }); scroll_container.scrollLeft = old_scroll_left; } } // Update the hover popup. This allows seeing the full label without needing // a horizontal scroller, and lets us display a quick thumbnail. set_hover(item) { let img = this.thumb_popup.querySelector("img"); if(item == null) { // Remove the hover, and clear the image so it doesn't flicker the next time // we display it. img.src = helpers.blank_image; this.label_popup.remove(); this.thumb_popup.remove(); return; } let label = item.container.querySelector(".label"); let {top, left, bottom, height} = label.getBoundingClientRect(); // Set up thumb_popup. if(item.path) { let {right} = this.container.getBoundingClientRect(); this.thumb_popup.style.left = \`\${right}px\`; // If the label is above halfway down the screen, position the preview image // below it. Otherwise, position it below. This keeps the image from overlapping // the label. We don't know the dimensions of the image here. let label_center = top + height/2; let below_middle = label_center > window.innerHeight/2; if(below_middle) { // Align the bottom of the image to the top of the label. this.thumb_popup.style.top = \`\${top - 20}px\`; img.style.objectPosition = "left bottom"; this.thumb_popup.style.transform = "translate(0, -100%)"; } else { // Align the top of the image to the bottom of the label. this.thumb_popup.style.top = \`\${bottom+20}px\`; img.style.objectPosition = "left top"; this.thumb_popup.style.transform = ""; } // Don't show a thumb for roots. Searches don't have thumbnails, and it's not useful // for most others. img.hidden = item.is_root; img.crossOriginMode = "use-credentials"; if(!item.is_root) { // Use /tree-thumb for these thumbnails. They're the same as the regular thumbs, // but it won't give us a folder image if there's no thumb. let url = local_api.local_url; url.pathname = "tree-thumb/" + item.path; img.src = url; img.addEventListener("img", (e) => { console.log("error"); img.hidden = true; }); } document.body.appendChild(this.thumb_popup); } // Set up label_popup. { this.label_popup.style.left = \`\${left}px\`; this.label_popup.style.top = \`\${top}px\`; // Match the padding of the label. this.label_popup.style.padding = getComputedStyle(label).padding; this.label_popup.querySelector(".label").innerText = item.label; document.body.appendChild(this.label_popup); } } } ppixiv.tree_widget_item = class extends ppixiv.widget { // If root is true, this is the root item being created by a tree_widget. Our // parent is the tree_widget and our container is tree_widget.items. // // If root is false (all items created by the user) and parent is a tree_widget, our // real parent is the tree_widget's root item. Otherwise, parent is always another // tree_widget_item. constructor({ parent, label, root=false, // If true, this item might have children. The first time the user expands // it, onexpand() will be called to populate it. pending=false, expandable=false, // If true and this is a root node, hide the label. hide_if_root=true, ...options }={}) { // If this isn't a root node and parent is a tree_widget, use the tree_widget's // root node as our parent instead of the tree widget itself. if(!root && parent instanceof ppixiv.tree_widget) parent = parent.root; super({...options, // The container is our parent node's item list. container: parent.items, parent: parent, template: \`
▶ ⌛
\`}); this.ondblclick = this.ondblclick.bind(this); // If this is the root node, hide .self, and add .root so our children // aren't indented. if(root && hide_if_root) { this.container.querySelector(".self").hidden = true; this.container.classList.add("root"); } // If our parent is the root node, we're a top-level node. helpers.set_class(this.container, "top", !root && parent.root); helpers.set_class(this.container, "child", !root && !parent.root); this.items = this.container.querySelector(".items"); this.expander = this.container.querySelector(".expander"); this.expand_mode = "expandable"; this.is_root = root; this._expandable = expandable; this._expanded = false; this._pending = pending; this._label = label; // Our root node: this.root_node = root? this:this.parent.root_node; // If we're the root node, the tree is our parent. Otherwise, copy the tree from // our parent. this.tree = root? this.parent:this.parent.tree; this.expander.addEventListener("click", (e) => { this.expanded = !this.expanded; }); let label_element = this.container.querySelector(".label"); label_element.addEventListener("dblclick", this.ondblclick); label_element.addEventListener("mousedown", (e) => { if(e.button != 0) return; e.preventDefault(); e.stopImmediatePropagation(); this.select(); this.onclick(); }, { capture: true }); label_element.addEventListener("mouseover", (e) => { this.tree.set_hover(this); }, { capture: false, }); label_element.addEventListener("mouseout", (e) => { this.tree.set_hover(null); }, { capture: false, }); this.refresh_expand_mode(); if(this.parent instanceof ppixiv.tree_widget_item) { this.parent.refresh_expand_mode(); } // Refresh the label. this.refresh(); } get label() { return this._label; } refresh() { let label = this.container.querySelector(".label"); label.innerText = this.label; } // This is called if pending is set to true the first time the node is expanded. // Return true on success, or false to re-collapse the node on error. async onexpand() { return true; } // This is called when the item is clicked. onclick() { } set expanded(value) { if(this._expanded == value) return; // Don't unexpand the root. if(!value && this.is_root) return; this._expanded = value; // If we're pending, call onexpand the first time we're expanded so we can // be populated. We'll stay pending and showing the hourglass until onexpand // completes. if(this._expanded) this.load_contents(); this.refresh_expand_mode(); } async load_contents() { // Stop if we're already loaded. if(!this._pending) return; if(this.load_promise != null) { try { await this.load_promise; } catch(e) { // The initial call to load_contents will print the error. } return; } // Start a load if one isn't already running. // Start the load. this.load_promise = this.onexpand(); this.load_promise.finally(() => { this.pending = false; this.load_promise = null; }); try { if(await this.load_promise) return; } catch(e) { console.log("Error expanding", this, e); } // If onexpand() threw an exception or returned false, there was an error loading the // node. Unexpand it rather than leaving it marked complete, so it can be retried. this._pending = true; this._expanded = false; this.refresh_expand_mode(); } set expandable(value) { if(this._expandable == value) return; this._expandable = value; this.refresh_expand_mode(); } set pending(value) { if(this._pending == value) return; this._pending = value; this.refresh_expand_mode(); } get expanded() { return this._expanded;} get expandable() { return this._expandable; } get pending() { return this._pending; } // Return an array of this node's child tree_widget_items. get child_nodes() { let result = []; for(let child = this.items.firstElementChild; child != null; child = child.nextElementSibling) if(child.widget) result.push(child.widget); return result; } get displayed_expand_mode() { // If we're not pending and we have no children, show "none". if(!this._pending && this.items.firstElementChild == null) return "none"; // If we're expanded and pending, show "loading". We're waiting for onexpand // to finish loading and unset pending. if(this.expanded) return this._pending? "loading":"expanded"; return "expandable"; } refresh_expand_mode() { this.expander.dataset.mode = this.displayed_expand_mode; this.expander.dataset.pending = this._pending; this.items.hidden = !this._expanded || this._pending; helpers.set_class(this.container, "allow-content-visibility", this.displayed_expand_mode != "expanded"); } select() { this.tree.set_selected_item(this); } focus() { this.container.querySelector(".self").focus(); } remove() { if(this.parent == null) return; this.parent.items.remove(this.container); // Refresh the parent in case we're the last child. this.parent.refresh_expand_mode(); this.parent = null; } async ondblclick(e) { e.preventDefault(); e.stopImmediatePropagation(); console.log("ondblclick"); this.expanded = !this.expanded; // Double-clicking the tree expands the node. It also causes it to be viewed due // to the initial single-click. However, if you double-click a directory that's // full of images, the natural thing for it to do is to view the first image. If // we don't do that, every time you view a directory you have to click it in the // tree, then click the first image in the search. // // Try to do this intelligently. If the directory we're loading is almost all images, // navigate to the first image. Otherwise, just let the click leave us viewing the // directory. This way, double-clicking a directory that has a bunch of other directories // in it will just expand the node, but double-clicking a directory which is a collection // of images will view the images. // // If we do this, we'll do both navigations: first to the directory and then to the image. // That's useful, so if we display the image but you really did want the directory view, // you can just back out once. // // Wait for contents to be loaded so we can see if there are any children. console.log("loading on dblclick"); await this.load_contents(); // If there are any children that we just expanded, stop. console.log("loaded, length:", this.child_nodes.length); if(this.child_nodes.length != 0) return; // The dblclick should have set the data source to this entry. Grab the // data source. let data_source = main_controller.singleton.data_source; console.log("data source for double click:", data_source); // Load the first page. This will overlap with the search loading it, and // will wait on the same request. if(!data_source.id_list.is_page_loaded(1)) await data_source.load_page(1); // Navigate to the first image on the first page. let media_ids = data_source.id_list.media_ids_by_page.get(1); console.log("files for double click:", media_ids?.length); if(media_ids != null) main_controller.singleton.show_media(media_ids[0], {add_to_history: true, source: "dblclick"}); } }; class local_navigation_widget_item extends ppixiv.tree_widget_item { constructor({path, search_options=null, ...options}={}) { super({...options, expandable: true, pending: true, }); this.options = options; this.search_options = search_options; this.path = path; // Set the ID on the item to let the popup menu know what it is. Don't do // this for top-level libraries ("folder:/images"), since they can't be // bookmarked. let { id } = helpers.parse_media_id(this.path); let is_library = id.indexOf("/", 1) == -1; if(!is_library) this.container.dataset.mediaId = this.path; if(options.root) { // As we load nodes in this tree, we'll index them by ID here. this.nodes = {}; this.nodes[path] = this; } } // This is called by the tree when an illust changes to let us refresh, so we don't need // to register an illust change callback for every node. // XXX: need a way to refresh these // do this once at the tree level: // image_data.singleton().illust_modified_callbacks.register(this.refresh); illust_changed(media_id) { // Refresh if we're displaying the illust that changed. if(media_id == this.path) this.refresh(); } // In addition to the label, refresh the bookmark icon. refresh() { super.refresh(); // Show or hide the bookmark icon. let info = thumbnail_data.singleton().get_one_thumbnail_info(this.path); let bookmarked = info?.bookmarkData != null; this.container.querySelector(".button-bookmark").hidden = !bookmarked; // This is useful, but the pointless browser URL popup covering the UI is really annoying... /* if(this.path) { let label = this.container.querySelector(".label"); let args = helpers.args.location; local_api.get_args_for_id(this.path, args); // label.href = args.url.toString(); } */ } async onexpand() { return await this.load(); } onclick() { this.tree.show_item(this.path); } load() { if(this.loaded) return Promise.resolve(true); // If we're already loading this item, just let it complete. if(this.load_promise) return this.load_promise; this.load_promise = this.load_inner(); this.load_promise.then((success) => { if(!success) return; // Refresh the selection in case this loaded the search we're currently on. this.tree.refresh_selection(); }); this.load_promise.finally(() => { this.load_promise = null; }); return this.load_promise; } async load_inner(item) { if(this.loaded) return true; this.loaded = true; let result = await local_api.list(this.path, { ...this.search_options, id: this.path, // This tells the server to only include directories. It's much faster, since // it doesn't need to scan images for metadata, and it disables pagination and gives // us all results at once. directories_only: true, }); if(!result.success) { this.loaded = false; return false; } // If this is the top-level item, this is a list of archives. If we have only one // archive, populate the top level with the top leve of the archive instead, so we // don't have an expander with just one item. // Not sure this is worth it. It adds special cases elsewhere, since it makes the // tree structure different (local_navigation_widget.load_path is broken, etc). /* if(this.path == "folder:/" && result.results.length == 1) { // Top-level items are always folders. console.assert(result.results[0].id.startsWith("folder:/")); this.path = result.results[0].id; return await this.load_inner(); } */ for(let dir of result.results) { // Strip "folder:" off of the name, and use the basename of that as the label. let {type } = helpers.parse_media_id(dir.id); if(type != "folder") continue; // Don't propagate search_options to children. let child = new local_navigation_widget_item({ parent: this, label: dir.illustTitle, path: dir.id, }); // Store ourself on the root node's node list. this.root_node.nodes[child.path] = child; // If we're the root, expand our children as they load, so the default tree // isn't just one unexpanded library. if(!this.search_options && this.path == "folder:/") child.expanded = true; } return true; } } // A tree view for navigation with the local image API. // XXX: keyboard navigation? ppixiv.local_navigation_widget = class extends ppixiv.tree_widget { constructor({...options}={}) { super({...options, add_root: false, }); this.load_path = new SentinelGuard(this.load_path, this); // Root local_navigation_widget_items will be stored here when // set_data_source_search_options is called. Until that happens, we have // no root. this.roots = {}; // Set current_search_options to a sentinel so we'll always set it on the // first call to set_data_source_search_options. this.current_search_root = null; this.current_search_options = new Object(); window.addEventListener("popstate", (e) => { this.set_root_from_url(); this.refresh_selection(); }); this.set_root_from_url(); this.refresh_selection(); } // Choose a tree root for the current URL, creating one if needed. set_root_from_url() { // Don't load a root if we're not currently on local search. let args = helpers.args.location; if(args.path != local_api.path) return; let { search_options, title } = local_api.get_search_options_for_args(args); if(search_options == null) title = "/"; let search_root = local_api.get_search_root_from_args(args, search_options); // Note that search_options is null if we're showing the regular tree and no // search is active. this.current_search_root = search_root; this.current_search_options = search_options; // Use a JSON serialization as a key. This always serializes in the same way. // Each combination of search_root and search_options is a separate root. let search_options_json = JSON.stringify({root: search_root, search_options: search_options}); if(this.roots[search_options_json] == null) { // Create this tree. this.roots[search_options_json] = new local_navigation_widget_item({ parent: this, label: title? title:"Root", root: true, // Hide the root node if there's no search, so the file tree roots are at the top. hide_if_root: !this.showing_search, path: search_root, search_options: search_options, }); } this.set_root(this.roots[search_options_json]); } // Return true if we're displaying a search, or false if we're showing the filesystem tree. get showing_search() { return this.current_search_options != null; } set_root(root) { super.set_root(root); // Make sure the new root is loaded. root.load(); } // If a search is active, select its item. async refresh_selection() { if(this.root == null) return; // If we're not on a /local/ search, just deselect. let args = helpers.args.location; if(args.path != local_api.path) { this.set_selected_item(null); return; } // Load the path if possible and select it. let node = await this.load_path(args); if(node) { node.select(); return; } } // Load and expand each component of path. // // This call is guarded, so if we're called again from another navigation, // we won't keep loading and changing the selection. async load_path(signal, args) { // Stop if we don't have a root yet. if(this.root == null) return; // Wait until the root is loaded, if needed. await this.root.load(); signal.check(); let media_id = local_api.get_local_id_from_args(args, { get_folder: true }); let { id } = helpers.parse_media_id(media_id); // Split apart the path. let parts = id.split("/"); // Discard the last component. We only need to load the directory containing the // path, not the directory itself. parts.splice(parts.length-1, 1); // Incrementally load each directory component. // // Note that if we're showing a search, items at the top of the tree will be from // random places further down the filesystem. We can do the same thing here: if // we're trying to load /a/b/c/d/e and the search node points to /a/b/c, we skip // /a and /a/b which aren't in the tree and start loading from there. let current_path = ""; let node = null; for(let part of parts) { // Append this path component to current_path. if(current_path == "") current_path = "folder:/"; else if(current_path != "folder:/") current_path += "/"; current_path += part; // If this directory exists in the tree, it'll be in nodes by now. node = this.root.nodes[current_path]; if(node == null) { // console.log("Path doesn't exist:", current_path); continue; } // Expand the node. This will trigger a load if needed. node.expanded = true; // If the node is loading, wait for the load to finish. if(node.load_promise) await node.load_promise; signal.check(); } return this.root.nodes[media_id]; } // Navigate to media_id, which should be an entry in the current tree. show_item(media_id) { let args = new helpers.args(ppixiv.location); local_api.get_args_for_id(media_id, args); helpers.set_page_url(args, true /* add_to_history */, "navigation"); // Hide the hover thumbnail on click to get it out of the way. this.set_hover(null); } }; // This stores searches like helpers.add_recent_search_tag. It's simpler, since this // is the only place these searches are added. function add_recent_local_search(tag) { var recent_tags = settings.get("local_searches") || []; var idx = recent_tags.indexOf(tag); if(idx != -1) recent_tags.splice(idx, 1); recent_tags.unshift(tag); settings.set("local_searches", recent_tags); window.dispatchEvent(new Event("recent-local-searches-changed")); } function remove_recent_local_search(search) { // Remove tag from the list. There should normally only be one. var recent_tags = settings.get("local_searches") || []; while(1) { var idx = recent_tags.indexOf(search); if(idx == -1) break; recent_tags.splice(idx, 1); } settings.set("local_searches", recent_tags); window.dispatchEvent(new Event("recent-local-searches-changed")); } // local_search_box_widget and local_search_dropdown_widget are dumb copy-pastes // of tag_search_box_widget and tag_search_dropdown_widget. They're simpler and // much less used, and it didn't seem worth creating a shared base class for these. ppixiv.local_search_box_widget = class extends ppixiv.widget { constructor({...options}) { super(options); this.input_onfocus = this.input_onfocus.bind(this); this.submit_search = this.submit_search.bind(this); this.input_element = this.container.querySelector(".input-field-container > input"); this.dropdown_widget = new local_search_dropdown_widget({ container: this.container, input_element: this.container, focus_parent: this.container, }); this.input_element.addEventListener("keydown", (e) => { // Exit the search box if escape is pressed. if(e.key == "Escape") { this.input_element.blur(); this.dropdown_widget.hide(); } }); this.input_element.addEventListener("focus", this.input_onfocus); this.input_element.addEventListener("submit", this.submit_search); this.clear_search_button = this.container.querySelector(".clear-local-search-button"); this.clear_search_button.addEventListener("click", (e) => { this.input_element.value = ""; this.input_element.dispatchEvent(new Event("submit")); }); this.container.querySelector(".submit-local-search-button").addEventListener("click", (e) => { this.input_element.dispatchEvent(new Event("submit")); }); this.input_element.addEventListener("input", (e) => { this.refresh_clear_button_visibility(); }); // Search submission: helpers.input_handler(this.input_element, this.submit_search); // Hide the dropdowns on navigation. new view_hidden_listener(this.input_element, (e) => { this.dropdown_widget.hide(); }); window.addEventListener("popstate", (e) => { this.refresh_from_location(); }); this.refresh_from_location(); this.refresh_clear_button_visibility(); } // SEt the text box from the current URL. refresh_from_location() { let args = helpers.args.location; this.input_element.value = args.hash.get("search") || ""; this.refresh_clear_button_visibility(); } refresh_clear_button_visibility() { this.clear_search_button.hidden = this.input_element.value == ""; } // 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; this.dropdown_widget.show(); } submit_search(e) { let tags = this.input_element.value.trim(); if(tags.length == 0) tags = null; // Add this tag to the recent search list. if(tags) add_recent_local_search(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. We expect to be on the local data source when this is called. let args = new helpers.args(ppixiv.location); console.assert(args.path == local_api.path); if(tags) args.hash.set("search", tags); else args.hash.delete("search"); args.set("p", null); helpers.set_page_url(args, true /* add_to_history */, "navigation"); } } ppixiv.local_search_dropdown_widget = class extends ppixiv.widget { constructor({input_element, focus_parent, ...options}) { super({...options, template: \`
\`}); this.dropdown_onclick = this.dropdown_onclick.bind(this); this.window_onclick = this.window_onclick.bind(this); this.populate_dropdown = this.populate_dropdown.bind(this); this.input_element = input_element; // While we're open, we'll close if the user clicks outside focus_parent. this.focus_parent = focus_parent; // Refresh the dropdown when the search history changes. window.addEventListener("recent-local-searches-changed", this.populate_dropdown); this.container.addEventListener("click", this.dropdown_onclick); // 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(); }); } // Hide if the user clicks outside us. window_onclick(e) { if(helpers.is_above(this.focus_parent, e.target)) return; 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(); let tag = e.target.closest(".entry").dataset.tag; remove_recent_local_search(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(); } show() { if(this.shown) return; this.shown = true; // Fill in the dropdown before displaying it. this.populate_dropdown(); this.container.hidden = false; window.addEventListener("click", this.window_onclick, true); helpers.set_max_height(this.input_dropdown); } hide() { if(!this.shown) return; this.shown = false; this.container.hidden = true; window.addEventListener("click", this.window_onclick, true); // Make sure the input isn't focused. this.input_element.blur(); } create_entry(search) { let entry = this.create_template({name: "tag-dropdown-entry", html: \` X \`}); entry.dataset.tag = search; let span = document.createElement("span"); span.innerText = search; entry.querySelector(".search").appendChild(span); let args = new helpers.args("/", ppixiv.location); args.path = local_api.path; args.hash_path = "/"; args.hash.set("search", search); entry.href = args.url; return entry; } // Populate the tag dropdown. populate_dropdown() { let tag_searches = settings.get("local_searches") || []; tag_searches.sort(); let list = this.container.querySelector(".input-dropdown-list"); helpers.remove_elements(list); for(let tag of tag_searches) { var entry = this.create_entry(tag); entry.classList.add("history"); list.appendChild(entry); } } } // A button to show an image in Explorer. // // This requires view_in_explorer.pyw be set up. ppixiv.view_in_explorer_widget = class extends ppixiv.illust_widget { get needed_data() { return "thumbnail"; } constructor({...options}) { super({...options}); this.enabled = false; // Ignore clicks on the button if it's disabled. this.container.addEventListener("click", (e) => { if(this.enabled) return; e.preventDefault(); e.stopPropagation(); }); } refresh_internal({ media_id, thumbnail_data }) { // Hide the button if we're not on a local image. this.container.closest(".button-container").hidden = !helpers.is_media_id_local(media_id); let path = thumbnail_data?.localPath; this.enabled = thumbnail_data?.localPath != null; helpers.set_class(this.container.querySelector("A.button"), "enabled", this.enabled); if(path == null) return; path = path.replace(/\\\\/g, "/"); // We have to work around some extreme jankiness in the URL API. If we create our // URL directly and then try to fill in the pathname, it won't let us change it. We // have to create a file URL, fill in the pathname, then replace the scheme after // converting to a string. Web! let url = new URL("file:///"); url.pathname = path; url = url.toString(); url = url.replace("file:", "vviewinexplorer:") let a = this.container.querySelector("A.local-link"); a.href = url; // Set the popup for the type of ID. let { type } = helpers.parse_media_id(media_id); let popup = type == "file"? "View file in Explorer":"View folder in Explorer"; a.dataset.popup = popup; } } ppixiv.close_search_widget = class extends ppixiv.widget { constructor({input_element, focus_parent, ...options}) { super({...options, template: \` \`}); window.addEventListener("popstate", (e) => { this.refresh_search_active(); }); this.refresh_search_active(); this.container.addEventListener("click", (e) => { // Get the URL for the current folder and set it to a new URL, so it removes search // parameters. let media_id = local_api.get_local_id_from_args(helpers.args.location, { get_folder: true }); let args = new helpers.args("/", ppixiv.location); local_api.get_args_for_id(media_id, args); helpers.set_page_url(args, true, "navigation"); }); } refresh_search_active() { let search_active = local_api.get_search_options_for_args(helpers.args.location).search_options != null; this.visible = search_active; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/local_widgets.js `; ppixiv.resources["src/muting.js"] = `"use strict"; // This handles querying whether a tag or a user is muted. // // The "mutes-changed" event is fired here when any mute list is modified. ppixiv.muting = class extends EventTarget { static get singleton() { if(muting._singleton == null) muting._singleton = new muting(); return muting._singleton; }; constructor() { super(); // This is used to tell other tabs when mutes change, so adding mutes takes effect without // needing to reload all other tabs. this.sync_mutes_channel = new BroadcastChannel("ppixiv:mutes-changed"); this.sync_mutes_channel.addEventListener("message", this.received_message); } // Set the list of tags and users muted via Pixiv's settings. get pixiv_muted_tags() { return this.muted_tags; } set pixiv_muted_tags(muted_tags) { this.muted_tags = muted_tags; this.fire_mutes_changed(); } get pixiv_muted_user_ids() { return this.muted_user_ids; } set pixiv_muted_user_ids(muted_user_ids) { this.muted_user_ids = muted_user_ids; this.fire_mutes_changed(); } // Extra mutes have a similar format to the /ajax/mute/items API: // // [{ // "type": "tag", // or user // "value": "tag or user ID", // "label": "tag or username" // ]} get extra_mutes() { return ppixiv.settings.get("extra_mutes"); } set extra_mutes(muted_users) { ppixiv.settings.set("extra_mutes", muted_users); this.fire_mutes_changed(); } // Shortcut to get just extra muted tags: get extra_muted_tags() { let tags = []; for(let mute of this.extra_mutes) if(mute.type == "tag") tags.push(mute.value); return tags; } // Fire mutes-changed to let UI know that a mute list has changed. fire_mutes_changed() { // If either of these are null, we're still being initialized. Don't fire events yet. if(this.pixiv_muted_tags == null || this.pixiv_muted_user_ids == null) return; this.dispatchEvent(new Event("mutes-changed")); // Tell other tabs that mutes have changed. this.broadcast_mutes(); } broadcast_mutes() { // Don't do this if we're inside broadcast_mutes because another tab sent this to us. if(this.handling_broadcast_mutes) return; this.sync_mutes_channel.postMessage({ pixiv_muted_tags: this.pixiv_muted_tags, pixiv_muted_user_ids: this.pixiv_muted_user_ids, }); } received_message = (e) => { let data = e.data; if(this.handling_broadcast_mutes) { console.error("recursive"); return; } console.log("Received updated mutes:", data); this.handling_broadcast_mutes = true; try { this.pixiv_muted_tags = data.pixiv_muted_tags; this.pixiv_muted_user_ids = data.pixiv_muted_user_ids; } finally { this.handling_broadcast_mutes = false; } }; is_muted_user_id(user_id) { if(this.muted_user_ids.indexOf(user_id) != -1) return true; for(let {value: muted_user_id} of this.extra_mutes) { if(user_id == muted_user_id) return true; } return false; }; // Unmute user_id. // // This checks both Pixiv's unmute list and our own, so it can always be used if // is_muted_user_id is true. async unmute_user_id(user_id) { actions.remove_extra_mute(user_id, {type: "user"}); if(this.muted_user_ids.indexOf(user_id) != -1) await actions.remove_pixiv_mute(user_id, {type: "user"}); } // Return true if any tag in tag_list is muted. any_tag_muted(tag_list) { let extra_muted_tags = this.extra_muted_tags; for(let tag of tag_list) { if(tag.tag) tag = tag.tag; if(this.muted_tags.indexOf(tag) != -1 || extra_muted_tags.indexOf(tag) != -1) return tag; } return null; } // Return true if the user is able to add to the Pixiv mute list. get can_add_pixiv_mutes() { // Non-premium users can only have one mute, and that's shared across both tags and users. let total_mutes = this.pixiv_muted_tags.length + this.pixiv_muted_user_ids.length; return window.global_data.premium || total_mutes == 0; } } ppixiv.muted_tags_popup = class extends ppixiv.dialog_widget { constructor({ mute_type, // "tags" or "users" ...options}) { super({...options, template: \`
You can mute any number of tags and users.

However, since you don't have Premium, mutes will only be saved in your browser and can't be saved to your Pixiv account. They will be lost if you change browsers or clear site data.
Add
Users can be muted from their user page, or by right-clicking an image and clicking settings.
\`}); this.mute_type = mute_type; this.container.querySelector(".title").innerText = \`Muted \${mute_type == "tag"? "tags":"users"}\`; this.container.querySelector(".add-muted-tag-box").hidden = mute_type != "tag"; this.container.querySelector(".add-muted-user-box").hidden = mute_type != "user"; this.container.querySelector(".add-muted-tag").addEventListener("click", this.click_add_muted_tag); this.container.querySelector(".mute-warning-button").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); let mute_warning = this.container.querySelector(".mute-warning"); mute_warning.hidden = !mute_warning.hidden; }); // Hide the warning for non-premium users if the user does have premium. this.container.querySelector(".non-premium-mute-warning").hidden = window.global_data.premium; this.visible = false; } visibility_changed() { super.visibility_changed(); if(this.visible) { this.container.querySelector(".mute-warning").hidden = true; this.refresh(); } // Clear the username cache when we're hidden, so we'll re-request it the next time // we're viewed. if(!this.visible) this.clear_muted_user_id_cache(); } refresh = async() => { if(!this.visible) return; if(this.mute_type == "tag") await this.refresh_for_tags(); else await this.refresh_for_users(); } create_entry() { return this.create_template({name: "muted-tag-entry", html: \` \`}); } refresh_for_tags = async() => { // Do a batch lookup of muted tag translations. let tags_to_translate = [...muting.singleton.pixiv_muted_tags]; for(let mute of muting.singleton.extra_mutes) { if(mute.type == "tag") tags_to_translate.push(mute.value); } let translated_tags = await tag_translations.get().get_translations(tags_to_translate); let create_muted_tag_entry = (tag, tag_list_container) => { let entry = this.create_entry(); entry.dataset.tag = tag; let label = tag; let tag_translation = translated_tags[tag]; if(tag_translation) label = \`\${tag_translation} (\${tag})\`; entry.querySelector(".tag-name").innerText = label; tag_list_container.appendChild(entry); return entry; }; let muted_tag_list = this.container.querySelector(".mute-list"); helpers.remove_elements(muted_tag_list); for(let {type, value: tag} of muting.singleton.extra_mutes) { if(type != "tag") continue; let entry = create_muted_tag_entry(tag, muted_tag_list); entry.querySelector(".remove-mute").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); actions.remove_extra_mute(tag, {type: "tag"}); this.refresh(); }); } for(let tag of muting.singleton.pixiv_muted_tags) { let entry = create_muted_tag_entry(tag, muted_tag_list); entry.querySelector(".remove-mute").addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); await actions.remove_pixiv_mute(tag, {type: "tag"}); this.refresh(); }); } } refresh_for_users = async() => { let create_muted_user_entry = (user_id, username, tag_list_container) => { let entry = this.create_entry(); entry.dataset.user_id = user_id; entry.querySelector(".tag-name").innerText = username; tag_list_container.appendChild(entry); return entry; }; let muted_user_list = this.container.querySelector(".mute-list"); helpers.remove_elements(muted_user_list); for(let {type, value: user_id, label: username} of muting.singleton.extra_mutes) { if(type != "user") continue; let entry = create_muted_user_entry(user_id, username, muted_user_list); entry.querySelector(".remove-mute").addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); actions.remove_extra_mute(user_id, {type: "user"}); this.refresh(); }); } // We already know the muted user IDs, but we need to load the usernames for display. // If we don't have this yet, start the load and refresh once we have it. let user_id_to_username = this.cached_muted_user_id_to_username; if(user_id_to_username == null) { this.get_muted_user_id_to_username().then(() => { console.log("Refreshing after muted user load"); this.refresh(); }); } else { // Now that we have usernames, Sort Pixiv mutes by username. let mutes = muting.singleton.pixiv_muted_user_ids; mutes.sort((lhs, rhs) => { lhs = user_id_to_username[lhs] || ""; rhs = user_id_to_username[rhs] || ""; return lhs.localeCompare(rhs); }); for(let user_id of mutes) { let entry = create_muted_user_entry(user_id, user_id_to_username[user_id], muted_user_list); entry.querySelector(".remove-mute").addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); await actions.remove_pixiv_mute(user_id, {type: "user"}); this.refresh(); }); } } } clear_muted_user_id_cache() { this.cached_muted_user_id_to_username = null; } // Return a dictionary of muted user IDs to usernames. get_muted_user_id_to_username() { // If this completed previously, just return the cached results. if(this.cached_muted_user_id_to_username) return this.cached_muted_user_id_to_username; // If this is already running, return the existing promise and don't start another. if(this.get_muted_user_id_to_username_promise) return this.get_muted_user_id_to_username_promise; let promise = this.get_muted_user_id_to_username_inner(); this.get_muted_user_id_to_username_promise = promise; this.get_muted_user_id_to_username_promise.finally(() => { // Clear get_muted_user_id_to_username_promise when it finishes. if(this.get_muted_user_id_to_username_promise == promise) this.get_muted_user_id_to_username_promise = null; }); return this.get_muted_user_id_to_username_promise; } async get_muted_user_id_to_username_inner() { // Users muted with Pixiv. We already have the list, but we need to make an API // request to get usernames to actually display. let result = await helpers.rpc_get_request("/ajax/mute/items", { context: "setting" }); if(result.error) { message_widget.singleton.show(result.message); this.cached_muted_user_id_to_username = {}; return this.cached_muted_user_id_to_username; } let user_id_to_username = {}; for(let item of result.body.mute_items) { // We only care about user mutes here. if(item.type == "user") user_id_to_username[item.value] = item.label; } this.cached_muted_user_id_to_username = user_id_to_username; return this.cached_muted_user_id_to_username; } // Add to our muted tag list. click_add_muted_tag = async (e) => { e.preventDefault(); e.stopPropagation(); let prompt = new text_prompt({ title: "Tag to mute:" }); let tag = await prompt.result; if(tag == null || tag == "") return; // cancelled // If the user has premium, use the regular Pixiv mute list. Otherwise, add the tag // to extra mutes. We never add anything to the Pixiv mute list for non-premium users, // since it's limited to only one entry. if(window.global_data.premium) await actions.add_pixiv_mute(tag, {type: "tag"}); else await actions.add_extra_mute(tag, tag, {type: "tag"}); this.refresh(); }; async remove_pixiv_muted_tag(tag) { await actions.remove_pixiv_mute(tag, {type: "tag"}); this.refresh(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/muting.js `; ppixiv.resources["src/editing.js"] = `"use strict"; ppixiv.ImageEditor = class extends ppixiv.illust_widget { constructor({onvisibilitychanged, ...options}) { super({...options, template: \`
\`}); this.crop_editor = new ppixiv.CropEditor({ container: this.container, parent: this, }); this.inpaint_editor = new ppixiv.InpaintEditor({ container: this.container, parent: this, }); this.onvisibilitychanged = onvisibilitychanged; this._dirty = false; this.editing_media_id = null; this.undo_stack = []; this.top_button_row = this.container.querySelector(".image-editor-buttons.top"); this.show_crop = this.container.querySelector(".show-crop"); this.show_crop.addEventListener("click", (e) => { settings.set("image_editing_mode", settings.get("image_editing_mode", null) == "crop"? null:"crop"); }); this.show_inpaint = this.container.querySelector(".show-inpaint"); this.show_inpaint.addEventListener("click", (e) => { settings.set("image_editing_mode", settings.get("image_editing_mode", null) == "inpaint"? null:"inpaint"); }); // Refresh when these settings change. for(let setting of ["image_editing", "image_editing_mode"]) settings.changes.addEventListener(setting, () => { this.refresh(); // Let our parent know that we may have changed editor visibility, since this // affects whether image cropping is active. this.onvisibilitychanged(); }, { signal: this.shutdown_signal.signal }); // Stop propagation of pointerdown at the container, so clicks inside the UI don't // move the image. this.container.addEventListener("pointerdown", (e) => { e.stopPropagation(); }); // Prevent fullscreen doubleclicks on UI buttons. this.container.addEventListener("dblclick", (e) => { e.stopPropagation(); }); this.save_edits = this.container.querySelector(".save-edits"); this.save_edits.addEventListener("click", async (e) => { this.save(); }, { signal: this.shutdown_signal.signal }); // Hotkeys: window.addEventListener("keydown", (e) => { if(e.code == "KeyS" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.save(); } if(e.code == "KeyZ" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.undo(); } if(e.code == "KeyY" && e.ctrlKey) { e.stopPropagation(); e.preventDefault(); this.redo(); } }, { signal: this.shutdown_signal.signal }); // Steal buttons from the individual editors. this.inpaint_buttons = this.inpaint_editor.container.querySelector(".image-editor-button-row"); this.inpaint_buttons.remove(); this.container.querySelector(".image-editor-buttons.bottom").appendChild(this.inpaint_buttons); } // Return true if the crop editor is active. get editing_crop() { return settings.get("image_editing", false) && settings.get("image_editing_mode", null) == "crop"; } shutdown() { this.crop_editor.shutdown(); this.inpaint_editor.shutdown(); super.shutdown(); } visibility_changed() { settings.set("image_editing", this.visible); // Refresh to update editor visibility. this.refresh(); this.onvisibilitychanged(); super.visibility_changed(); } async refresh_internal({ illust_data }) { // If the illust ID hasn't changed, don't reimport data from illust_data. Just // import it once when media_id is set so we don't erase edits. let media_id = illust_data?.id; if(illust_data && media_id == this.editing_media_id) return; // Clear undo/redo on load. this.undo_stack = []; this.redo_stack = []; this.editing_media_id = media_id; this.crop_editor.set_illust_data(illust_data); this.inpaint_editor.set_illust_data(illust_data); // We just loaded, so clear dirty. this.dirty = false; } // This is called when the ImageEditingOverlayContainer changes. set overlay_container(overlay_container) { this.inpaint_editor.overlay_container = overlay_container; this.crop_editor.overlay_container = overlay_container; } refresh() { super.refresh(); this.visible = settings.get("image_editing", false); helpers.set_class(this.save_edits, "dirty", this.dirty); let showing_crop = settings.get("image_editing_mode", null) == "crop" && this.visible; this.crop_editor.visible = showing_crop; helpers.set_class(this.show_crop, "selected", showing_crop); let showing_inpaint = settings.get("image_editing_mode", null) == "inpaint" && this.visible; this.inpaint_editor.visible = showing_inpaint; this.inpaint_buttons.hidden = !showing_inpaint; helpers.set_class(this.show_inpaint, "selected", showing_inpaint); // Disable hiding the mouse cursor when editing is enabled. This also prevents // the top button row from being hidden. if(showing_crop || showing_inpaint) hide_mouse_cursor_on_idle.disable_all("image-editing"); else hide_mouse_cursor_on_idle.enable_all("image-editing"); } // Store the current data as an undo state. save_undo() { this.undo_stack.push(this.get_state()); this.redo_stack = []; // Anything that adds to the undo stack causes us to be dirty. this.dirty = true; } // Revert to the previous undo state, if any. undo() { if(this.undo_stack.length == 0) return; this.redo_stack.push(this.get_state()); this.set_state(this.undo_stack.pop()); // If InpaintEditor was adding a line, we just undid the first point, so end it. this.inpaint_editor.adding_line = null; } // Redo the last undo. redo() { if(this.redo_stack.length == 0) return; this.undo_stack.push(this.get_state()); this.set_state(this.redo_stack.pop()); } // Load and save state, for undo. get_state() { return { inpaint: this.inpaint_editor.get_inpaint_data(), crop: this.crop_editor.get_state(), }; } set_state(state) { console.log(state); this.inpaint_editor.set_inpaint_data(state.inpaint); this.crop_editor.set_state(state.crop); } async save() { // Clear dirty before saving, so any edits made while saving will re-dirty, but set // it back to true if there's an error saving. this.dirty = false; let spinner = this.save_edits.querySelector(".spinner"); spinner.hidden = false; try { // Get data from each editor, so we can save them together. let edits = { }; for(let editor of [this.inpaint_editor, this.crop_editor]) { for(let [key, value] of Object.entries(editor.get_data_to_save())) edits[key] = value; } let result = await local_api.local_post_request(\`/api/set-image-edits/\${this.media_id}\`, edits); if(!result.success) { console.error("Error saving image edits:", result); this.dirty = true; return; } // Let the widgets know that we saved. await this.inpaint_editor.after_save(result); } finally { spinner.hidden = true; } } get dirty() { return this._dirty; } set dirty(value) { if(this._dirty == value) return; this._dirty = value; this.refresh(); } } // This is a custom element that roughly emulates an HTMLImageElement, but contains two // overlaid images instead of one to overlay the inpaint, and holds the InpaintEditorOverlay. // Load and error events are dispatched, and the image is considered loaded or complete when // both of its images are loaded or complete. This allows on_click_viewer to display inpainting // and the inpaint editor without needing to know much about it, so we can avoid complicating // the viewer. ppixiv.ImageEditingOverlayContainer = class extends HTMLElement { static get observedAttributes() { return ["image_src", "inpaint_src"]; } constructor() { super(); this._onload = this._onload.bind(this); this._onerror = this._onerror.bind(this); this.attachShadow({mode: "open"}); let container = document.createElement("div"); container.setAttribute("class", "container"); this.shadowRoot.append(container); this._main_img = document.createElement("img"); this._main_img.dataset.img = "main-image"; this._inpaint_img = document.createElement("img"); this._inpaint_img.dataset.img = "inpaint-image"; // Let pointer events through to the underlying image. this._inpaint_img.style.pointerEvents = "none"; for(let img of [this._main_img, this._inpaint_img]) { img.classList.add("filtering"); img.addEventListener("load", this._onload); img.addEventListener("error", this._onerror); container.appendChild(img); } // Create slots to hold the editors. let inpaint_slot = document.createElement("slot"); inpaint_slot.name = "inpaint-editor"; container.append(inpaint_slot); let crop_slot = document.createElement("slot"); crop_slot.name = "crop-editor"; container.append(crop_slot); let style = document.createElement("style"); style.textContent = \` .container, .container > * { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } img { will-change: transform; } \`; this.shadowRoot.append(style); this.setAttribute("image_src", ""); this.setAttribute("inpaint_src", ""); } set_image_urls(image_url, inpaint_url) { this.image_src = image_url || ""; this.inpaint_src = inpaint_url || ""; } // Note that load will currently be fired twice, once for each image. _onload(e) { // Dispatch loaded on ourself if both images are loaded. if(this.complete) this.dispatchEvent(new Event("load")); } _onerror(e) { this.dispatchEvent(new Event("error")); } // Set the image URLs. If set to null, use a blank image instead so we don't trigger // load errors. get image_src() { return this.getAttribute("image_src"); } set image_src(value) { this.setAttribute("image_src", value); } get inpaint_src() { return this.getAttribute("inpaint_src"); } set inpaint_src(value) { this.setAttribute("inpaint_src", value); } get complete() { return this._main_img.complete && this._inpaint_img.complete; } decode() { return Promise.all([this._main_img.decode(), this._inpaint_img.decode()]); } attributeChangedCallback(name, oldValue, newValue) { if(newValue == "") newValue = helpers.blank_image; if(name == "image_src") this._main_img.src = newValue; if(name == "inpaint_src") this._inpaint_img.src = newValue; } get width() { return this._main_img.width; } get height() { return this._main_img.height; } get naturalWidth() { return this._main_img.naturalWidth; } get naturalHeight() { return this._main_img.naturalHeight; } get hide_inpaint() { return this._inpaint_img.style.opacity == 0; } set hide_inpaint(value) { this._inpaint_img.style.opacity = value? 0:1; } } customElements.define("image-editing-overlay-container", ppixiv.ImageEditingOverlayContainer); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/editing.js `; ppixiv.resources["src/editing_crop.js"] = `"use strict"; ppixiv.CropEditor = class extends ppixiv.widget { constructor(options) { super({...options, template: \`
\`}); this.pointermove = this.pointermove.bind(this); this.shutdown_signal = new AbortController(); this.width = 1; this.height = 1; this.editor_overlay = this.container.querySelector(".crop-editor-overlay"); this.editor_overlay.remove(); this.editor_overlay.slot = "crop-editor"; this.current_crop = null; this.editor_overlay.addEventListener("dblclick", this.ondblclick.bind(this), { signal: this.shutdown_signal.signal }); this.pointer_listener = new ppixiv.pointer_listener({ element: this.editor_overlay, callback: this.pointerevent.bind(this), signal: this.shutdown_signal.signal, }); this.svg = this.editor_overlay.querySelector(".crop-box"); this.refresh(); } // Clear the crop on double-click. ondblclick(e) { e.preventDefault(); e.stopPropagation(); console.log("clear"); this.parent.save_undo(); this.current_crop = null; this.refresh(); } pointerevent(e) { e.preventDefault(); e.stopPropagation(); if(!e.pressed) { window.removeEventListener("pointermove", this.pointermove); return; } this.parent.save_undo(); let clicked_handle; if(this.current_crop == null) { let {x,y} = this.get_point_from_click(e); this.current_crop = new FixedDOMRect(x, y, x, y); clicked_handle = "bottomright"; } else clicked_handle = e.target.dataset.crop; // Which dimensions each handle moves: let drag_parts = { all: "move", topleft: ["top", "left"], top: ["top"], topright: ["top", "right"], left: ["left"], right: ["right"], bottomleft: ["bottom", "left"], bottom: ["bottom"], bottomright: ["bottom", "right"], } window.addEventListener("pointermove", this.pointermove); this.dragging = drag_parts[clicked_handle]; this.drag_pos = [e.clientX, e.clientY]; this.refresh(); } get_point_from_click({clientX, clientY}) { let {width, height, top, left} = this.editor_overlay.getBoundingClientRect(); console.log("overlay size", width, height, this.width, this.height); let x = (clientX - left) / width * this.width; let y = (clientY - top) / height * this.height; return { x: x, y: y }; } pointermove(e) { // Get the delta in client coordinates. Don't use movementX/movementY, since it's // in screen pixels and will be wrong if the browser is scaled. let delta_x = e.clientX - this.drag_pos[0]; let delta_y = e.clientY - this.drag_pos[1]; this.drag_pos = [e.clientX, e.clientY]; // Scale movement from client coordinates to the size of the container. let {width, height} = this.editor_overlay.getBoundingClientRect(); delta_x *= this.width / width; delta_y *= this.height / height; // Apply the drag. if(this.dragging == "move") { this.current_crop.x += delta_x; this.current_crop.y += delta_y; this.current_crop.x = Math.max(0, this.current_crop.x); this.current_crop.y = Math.max(0, this.current_crop.y); this.current_crop.x = Math.min(this.width - this.current_crop.width, this.current_crop.x); this.current_crop.y = Math.min(this.height - this.current_crop.height, this.current_crop.y); } else { for(let part of this.dragging) { let min_size = 1; switch(part) { case "left": this.current_crop.left += Math.min(this.current_crop.width - min_size, delta_x); break; case "top": this.current_crop.top += Math.min(this.current_crop.height - min_size, delta_y); break; case "right": this.current_crop.right -= Math.min(this.current_crop.width - min_size, -delta_x); break; case "bottom": this.current_crop.bottom -= Math.min(this.current_crop.height - min_size, -delta_y); break; } } // Clamp the crop to the image bounds. this.current_crop.left = Math.max(0, this.current_crop.left); this.current_crop.top = Math.max(0, this.current_crop.top); this.current_crop.right = Math.min(this.width, this.current_crop.right); this.current_crop.bottom = Math.min(this.height, this.current_crop.bottom); } this.refresh(); } refresh() { let box = this.editor_overlay.querySelector(".crop-box"); box.hidden = this.current_crop == null; if(this.current_crop == null) return; box.style.width = \`\${100 * this.current_crop.width / this.width}%\`; box.style.height = \`\${100 * this.current_crop.height / this.height}%\`; box.style.left = \`\${100 * this.current_crop.left / this.width}%\`; box.style.top = \`\${100 * this.current_crop.top / this.height}%\`; } shutdown() { // Signal shutdown_signal to remove event listeners. console.assert(this.shutdown_signal != null); this.shutdown_signal.abort(); this.shutdown_signal = null; } set_illust_data(illust_data) { if(illust_data == null) return; this.width = illust_data.width; this.height = illust_data.height; this.svg.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); this.set_state(illust_data.crop); this.refresh(); } set overlay_container(overlay_container) { console.assert(overlay_container instanceof ImageEditingOverlayContainer) if(this.editor_overlay.parentNode) this.editor_overlay.remove(); overlay_container.appendChild(this.editor_overlay); this._overlay_container = overlay_container; } get_data_to_save() { // If there's no crop, save an empty array to clear it. let crop = this.get_state(); if(crop == null) crop = []; return { crop: crop, } } get_state() { if(this.current_crop == null) return null; return [ Math.round(this.current_crop.left), Math.round(this.current_crop.top), Math.round(this.current_crop.right), Math.round(this.current_crop.bottom), ] } set_state(crop) { if(crop == null) this.current_crop = null; else this.current_crop = new FixedDOMRect(crop[0], crop[1], crop[2], crop[3]); this.refresh(); } visibility_changed() { super.visibility_changed(); this.editor_overlay.hidden = !this.visible; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/editing_crop.js `; ppixiv.resources["src/editing_inpaint.js"] = `"use strict"; ppixiv.InpaintEditor = class extends ppixiv.widget { constructor(options) { super({...options, template: \`
\`}); this.pointermove_drag_point = this.pointermove_drag_point.bind(this); this.onmousehover = this.onmousehover.bind(this); this.shutdown_signal = new AbortController(); this.width = 100; this.height = 100; this.lines = []; this._downscale_ratio = 1; this._blur = 0; this.dragging_segment_point = -1; this.drag_start = null; this.selected_line_idx = -1; // Remove .inpaint-editor-overlay. It's inserted into the image overlay when we // have one, so it pans and zooms with the image. this.editor_overlay = this.container.querySelector(".inpaint-editor-overlay"); this.editor_overlay.remove(); this.editor_overlay.slot = "inpaint-editor"; this.svg = this.editor_overlay.querySelector(".inpaint-container"); this.create_lines_button = this.container.querySelector(".create-lines"); this.create_lines_button.addEventListener("click", (e) => { this.create_lines = !this.create_lines; }); // Update the selected line's thickness when the thickness slider changes. this.line_width_slider = this.container.querySelector(".inpaint-line-width"); this.line_width_slider_box = this.container.querySelector(".inpaint-line-width-box"); this.line_width_slider.addEventListener("input", (e) => { if(this.selected_line == null) return; this.selected_line.thickness = parseInt(this.line_width_slider.value); }); this.line_width_slider.value = settings.get("inpaint_default_thickness", 10); // Hide the inpaint while dragging the thickness slider. this.pointer_listener = new ppixiv.pointer_listener({ element: this.line_width_slider, callback: (e) => { this._overlay_container.hide_inpaint = e.pressed; }, }); this.downscale_slider = this.container.querySelector(".inpaint-downscale"); this.downscale_slider.addEventListener("change", (e) => { this.parent.save_undo(); this.downscale_ratio = parseFloat(this.downscale_slider.value); }, { signal: this.shutdown_signal.signal }); this.blur_slider = this.container.querySelector(".inpaint-blur"); this.blur_slider.addEventListener("change", (e) => { this.parent.save_undo(); this.blur = parseFloat(this.blur_slider.value); }, { signal: this.shutdown_signal.signal }); let view_inpaint_button = this.container.querySelector(".view-inpaint"); this.pointer_listener = new ppixiv.pointer_listener({ element: view_inpaint_button, callback: (e) => { this.visible = !e.pressed; }, signal: this.shutdown_signal.signal, }); // "Save default" buttons: this.container.querySelector(".save-default-thickness").addEventListener("click", (e) => { let value = parseInt(this.line_width_slider.value); settings.set("inpaint_default_thickness", value); console.log("Saved default line thickness:", value); }, { signal: this.shutdown_signal.signal }); this.container.querySelector(".save-default-downscale").addEventListener("click", (e) => { let value = parseFloat(this.downscale_slider.value); settings.set("inpaint_default_downscale", value); console.log("Saved default downscale:", value); }, { signal: this.shutdown_signal.signal }); this.container.querySelector(".save-default-soften").addEventListener("click", (e) => { let value = parseFloat(this.blur_slider.value); settings.set("inpaint_default_blur", value); console.log("Saved default blur:", value); }, { signal: this.shutdown_signal.signal }); this.pointer_listener = new ppixiv.pointer_listener({ element: this.editor_overlay, callback: this.pointerevent.bind(this), signal: this.shutdown_signal.signal, }); // This is a pain. We want to handle clicks when modifier buttons are pressed, and // let them through otherwise so panning works. Every other event system lets you // handle or not handle a mouse event and have it fall through if you don't handle // it, but CSS won't. Work around this by watching for our modifier keys and setting // pointer-events: none as needed. this.ctrl_pressed = false; for(let modifier of ["Control", "Alt", "Shift"]) { new ppixiv.key_listener(modifier, (pressed) => { this.ctrl_pressed = pressed; this.refresh_pointer_events(); }, { signal: this.shutdown_signal.signal }); } this._create_lines = settings.get("inpaint_create_lines", false); // Prevent fullscreening if a UI element is double-clicked. this.editor_overlay.addEventListener("dblclick", this.ondblclick.bind(this), { signal: this.shutdown_signal.signal }); this.editor_overlay.addEventListener("mouseover", this.onmousehover, { signal: this.shutdown_signal.signal }); this.refresh_pointer_events(); } shutdown() { super.shutdown(); // Clear lines when shutting down so we remove their event listeners. this.clear(); } // This is called when the ImageEditingOverlayContainer changes. set overlay_container(overlay_container) { console.assert(overlay_container instanceof ImageEditingOverlayContainer) if(this.editor_overlay.parentNode) this.editor_overlay.remove(); overlay_container.appendChild(this.editor_overlay); this._overlay_container = overlay_container; } refresh() { super.refresh(); helpers.set_class(this.create_lines_button, "selected", this.create_lines); if(this.selected_line) this.line_width_slider.value = this.selected_line.thickness; this.downscale_slider.value = this.downscale_ratio; this.blur_slider.value = this.blur; } update_menu(menu_container) { let create = menu_container.querySelector(".edit-inpaint"); helpers.set_class(create, "enabled", true); helpers.set_class(create, "selected", this.editor?.create_lines); } visibility_changed() { super.visibility_changed(); this.editor_overlay.hidden = !this.visible; } set_illust_data(illust_data) { // Scale the thickness slider to the size of the image. let size = illust_data? Math.min(illust_data.width, illust_data.height):50; this.line_width_slider.max = size / 25; this.clear(); if(illust_data == null) return; // Match the size of the image. this.set_size(illust_data.width, illust_data.height); this.set_inpaint_data(illust_data.inpaint); // If there's no data at all, load the user's defaults. if(illust_data.inpaint == null) { this.downscale_ratio = settings.get("inpaint_default_downscale", 1); this.blur = settings.get("inpaint_default_blur", 0); } } get_data_to_save() { return { inpaint: this.get_inpaint_data({for_saving: true}), } } async after_save(result) { let illust = result.illust; if(illust.urls.inpaint) { // Saving the new inpaint data will change the inpaint URL. It'll be generated the first // time it's fetched, which can take a little while. Fetch it before updating image // data, so it's already generated when viewer_images updates with the new URL. // Otherwise, we'll be stuck looking at the low-res preview while it generates. let img = new Image(); img.src = illust.urls.inpaint; await helpers.wait_for_image_load(img); } // Update the illust info. The new info has the data we just saved, as well // as updated image URLs that include the new inpaint. // // This updates image_data directly, since we don't currently have a path for // updating illust data after it's already loaded.. local_api.adjust_illust_info(illust); image_data.singleton().image_data[illust.id] = illust; image_data.singleton().call_illust_modified_callbacks(illust.id); // Update the thumbnail URL, so the new image shows up in search results and the // load preview. thumbnail_data.singleton().update_illust_data(illust.id, { previewUrls: [illust.urls.small], }); return true; } // Return inpaint data for saving. // // If for_saving is true, return data to send to the server. This clears the // data entirely if there are no lines, so the inpaint data is removed entirely. // Otherwise, returns the full state, which is used for things like undo. get_inpaint_data({for_saving=false}={}) { if(for_saving && this.lines.length == 0) return []; let result = []; let settings = { } if(this._downscale_ratio != 1) settings.downscale = this._downscale_ratio; if(this.blur != 0) settings.blur = this.blur; if(Object.keys(settings).length > 0) { settings.action = "settings"; result.push(settings); } for(let line of this.lines) { let segments = []; for(let segment of line.segments) segments.push([segment[0], segment[1]]); let entry = { action: "line", thickness: line.thickness, line: segments, }; result.push(entry); } return result; } // Replace the inpaint data. set_inpaint_data(inpaint) { this.clear(); // Each entry looks like: // // [action: "settings", blur: 10, downscale: 2} // {action: "line", thickness: 10, line: [[1,1], [2,2], [3,3], [4,4], ...]} for(let part of inpaint || []) { let cmd = part.action; switch(cmd) { case "settings": if(part.downscale) this.downscale_ratio = parseFloat(part.downscale); if(part.blur) this.blur = parseFloat(part.blur); break; case "line": let line = this.add_line(); if(part.thickness) line.thickness = part.thickness; for(let point of part.line || []) line.add_point({x: point[0], y: point[1]}); break; default: console.error("Unknown inpaint command:", cmd); break; } } this.refresh(); } get downscale_ratio() { return this._downscale_ratio; } set downscale_ratio(value) { if(this._downscale_ratio == value) return; this._downscale_ratio = value; this.refresh(); } get blur() { return this._blur; } set blur(value) { if(this._blur == value) return; this._blur = value; this.refresh(); } clear() { while(this.lines.length) this.remove_line(this.lines[0]); this._downscale_ratio = 1; this._blur = 0; } onmousehover(e) { let over = e.target.closest(".inpaint-line, .inpaint-handle") != null; this._overlay_container.hide_inpaint = over; // While we think we're hovering, add a mouseover listener to window, so we catch // all mouseover events that tell us we're no longer hovering. If we don't do this, // we won't see any event if the element that's being hovered is removed from the // document while it's being hovered. if(over) window.addEventListener("mouseover", this.onmousehover, { signal: this.shutdown_signal.signal }); else window.removeEventListener("mouseover", this.onmousehover, { signal: this.shutdown_signal.signal }); } get create_lines() { return this._create_lines; } set create_lines(value) { if(this._create_lines == value) return; this._create_lines = value; settings.set("inpaint_create_lines", this.create_lines); this.refresh_pointer_events(); // If we're turning quick line creation off and we have an incomplete line, // delete it. if(!this._create_lines && this.adding_line) { this.remove_line(this.adding_line); this.adding_line = null; } this.refresh(); } refresh_pointer_events() { helpers.set_class(this.editor_overlay, "creating-lines", this._create_lines); if(this.ctrl_pressed || this._create_lines) this.editor_overlay.style.pointerEvents = "auto"; else this.editor_overlay.style.pointerEvents = "none"; } get_control_point_from_element(node) { let inpaint_segment = node.closest(".inpaint-segment")?.widget; let control_point = node.closest("[data-type='control-point']"); let inpaint_line = node.closest(".inpaint-line"); if(inpaint_segment == null) return { }; let control_point_idx = control_point? parseInt(control_point.dataset.idx):-1; let inpaint_line_idx = inpaint_line? parseInt(inpaint_line.dataset.idx):-1; // If we're on an inpaint segment we should always have a point or line. If we // don't for some reason, ignore the segment too. if(control_point_idx == -1 && inpaint_line_idx == -1) inpaint_segment = null; return { inpaint_segment: inpaint_segment, control_point_idx: control_point_idx, inpaint_line_idx: inpaint_line_idx }; } pointerevent(e) { let { x, y } = this.get_point_from_click(e); let { inpaint_segment, control_point_idx, inpaint_line_idx } = this.get_control_point_from_element(e.target); this.selected_line = inpaint_segment; // Check if we're in the middle of adding a line. Don't do this if the // same point was clicked (fall through and allow moving the point). if(e.pressed && this.adding_line != null && (inpaint_segment == null || inpaint_segment != this.adding_line)) { e.preventDefault(); e.stopPropagation(); if(inpaint_segment == this.adding_line) return; this.parent.save_undo(); // If another segment was clicked while adding a line, connect to that line. if(inpaint_segment && control_point_idx != -1) { // We can only connect to the beginning or end. Connect to whichever end is // closer to the point thta was clicked. let point_idx = 0; if(control_point_idx >= inpaint_segment.segments.length/2) point_idx = inpaint_segment.segments.length; let point = this.adding_line.segments[0]; this.remove_line(this.adding_line); this.adding_line = null; inpaint_segment.add_point({x: point[0], y: point[1], at: point_idx}); // Drag the point we connected to, not the new point. this.start_dragging_point(inpaint_segment, control_point_idx, e); return; } let new_control_point_idx = this.adding_line.add_point({x: x, y: y}); this.start_dragging_point(this.adding_line, new_control_point_idx, e); this.adding_line = null; return; } if(e.pressed && inpaint_segment) { e.preventDefault(); e.stopPropagation(); this.parent.save_undo(); // If shift is held, clicking a line segment inserts a point. Otherwise, it // drags the whole segment. if(control_point_idx == -1 && e.shiftKey) { let { x, y } = this.get_point_from_click(e); control_point_idx = inpaint_segment.add_point({x: x, y: y, at: inpaint_line_idx}); } this.start_dragging_point(inpaint_segment, control_point_idx, e); return; } else if(this.dragging_segment) { // We released dragging a segment. this.dragging_segment_point = -1; window.removeEventListener("pointermove", this.pointermove_drag_point); } // If we're in create line mode, create points on click. if(e.pressed && this._create_lines) { e.preventDefault(); e.stopPropagation(); this.parent.save_undo(); this.adding_line = this.add_line(); this.adding_line.thickness = settings.get("inpaint_default_thickness", 10); let control_point_idx = this.adding_line.add_point({x: x, y: y}); this.start_dragging_point(this.adding_line, control_point_idx, e); } } start_dragging_point(inpaint_segment, point_idx=-1, e) { this.dragging_segment = inpaint_segment; this.dragging_segment_point = point_idx; this.drag_pos = [e.clientX, e.clientY]; window.addEventListener("pointermove", this.pointermove_drag_point); } // Convert a click from client coordinates to image coordinates. get_point_from_click({clientX, clientY}) { let {width, height, top, left} = this.editor_overlay.getBoundingClientRect(); let x = (clientX - left) / width * this.width; let y = (clientY - top) / height * this.height; return { x: x, y: y }; } ondblclick(e) { // Block double-clicks to stop screen_illust from toggling fullscreen. e.stopPropagation(); // Delete segments and points on double-click. let { inpaint_segment, control_point_idx } = this.get_control_point_from_element(e.target); if(inpaint_segment) { this.parent.save_undo(); if(control_point_idx == -1) this.remove_line(inpaint_segment); else { inpaint_segment.remove_point(control_point_idx); // If only one point is left, delete the segment. if(inpaint_segment.segments.length < 2) this.remove_line(inpaint_segment); } } } pointermove_drag_point(e) { // Get the delta in client coordinates. Don't use movementX/movementY, since it's // in screen pixels and will be wrong if the browser is scaled. let delta_x = e.clientX - this.drag_pos[0]; let delta_y = e.clientY - this.drag_pos[1]; this.drag_pos = [e.clientX, e.clientY]; // Scale movement from client coordinates to the size of the container. let {width, height} = this.editor_overlay.getBoundingClientRect(); delta_x *= this.width / width; delta_y *= this.height / height; // Update the control points we're editing. If dragging_segment_point is -1, update // the whole segment, otherwise update just that control point. let segments = this.dragging_segment.segments; for(let idx = 0; idx < segments.length; ++idx) { if(this.dragging_segment_point != -1 && this.dragging_segment_point != idx) continue; let segment = segments[idx]; segment[0] += delta_x; segment[1] += delta_y; // Clamp the position so it doesn't go offscreen. segment[0] = helpers.clamp(segment[0], 0, this.width); segment[1] = helpers.clamp(segment[1], 0, this.height); } this.dragging_segment.update_segment(); } add_line() { let line = new LineEditorSegment({ parent: this, container: this.svg, }); this.lines.push(line); this.refresh_lines(); return line; } remove_line(line) { line.container.remove(); let idx = this.lines.indexOf(line); console.assert(idx != -1); // Deselect the line if it's selected. if(this.selected_line_idx == idx) this.selected_line = null; if(this.adding_line == line) this.adding_line = null; this.lines.splice(idx, 1); this.refresh_lines(); } set selected_line(line) { if(line == null) this.selected_line_idx = -1; else this.selected_line_idx = this.lines.indexOf(line); this.refresh_lines(); this.refresh(); } get selected_line() { if(this.selected_line_idx == -1) return null; return this.lines[this.selected_line_idx]; } refresh_lines() { for(let idx = 0; idx < this.lines.length; ++idx) { let line = this.lines[idx]; if(idx == this.selected_line_idx) line.container.classList.add("selected"); else line.container.classList.remove("selected"); } } set_size(width, height) { this.width = width; this.height = height; this.svg.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); } } ppixiv.LineEditorSegment = class extends ppixiv.widget { constructor({container, ...options}) { // Templates don't work, because it doesn't create the as an SVG // element. Is there a way to make that work? let contents = document.createElementNS(helpers.xmlns, "g"); contents.setAttribute("class", "inpaint-segment"); container.appendChild(contents); super({...options, contents: contents}); this.edit_points = []; this._thickness = 15; this.segments = []; this.segment_lines = []; this.create_edit_points(); } get thickness() { return this._thickness; } set thickness(value) { this._thickness = value; this.create_edit_points(); } add_point({x, y, at=-1}) { let new_segment = [x, y]; if(at == -1) at = this.segments.length; this.segments.splice(at, 0, new_segment); this.create_edit_points(); return at; } remove_point(idx) { console.assert(idx < this.segments.length); this.segments.splice(idx, 1); this.create_edit_points(); } create_edit_point() { let point = document.createElementNS(helpers.xmlns, "ellipse"); point.setAttribute("class", "inpaint-handle"); point.setAttribute("cx", "100"); point.setAttribute("cy", "100"); point.setAttribute("rx", "10"); point.setAttribute("ry", "10"); return point; } create_edit_points() { for(let line of this.segment_lines) line.remove(); for(let point of this.edit_points) point.remove(); this.segment_lines = []; this.edit_points = []; if(!this.polyline) { this.polyline = document.createElementNS(helpers.xmlns, "polyline"); this.polyline.setAttribute("class", "inpaint-line"); this.container.appendChild(this.polyline); } if(0) for(let idx = 0; idx < this.segments.length-1; ++idx) { // Use a rect for the lines. It doesn't join as cleanly as a polyline, // but it lets us set both the fill and the stroke. let line = document.createElementNS(helpers.xmlns, "rect"); line.setAttribute("class", "inpaint-line"); line.dataset.idx = idx; this.container.appendChild(line); this.segment_lines.push(line); } for(let idx = 0; idx < this.segments.length; ++idx) { let point = this.create_edit_point(); point.dataset.type = "control-point"; point.dataset.idx = idx; this.edit_points.push(point); this.container.appendChild(point); } this.update_segment(); } // Update the line and control points when they've moved. If segments have been added // or deleted, call create_segments instead. update_segment() { let points = []; for(let point of this.segments) points.push(\`\${point[0]},\${point[1]}\`); this.polyline.setAttribute("points", points.join(" ")); this.polyline.setAttribute("stroke-width", this._thickness); if(0) for(let idx = 0; idx < this.segments.length-1; ++idx) { let line = this.segment_lines[idx]; let p0 = this.segments[idx]; let p1 = this.segments[idx+1]; let length = Math.pow(p0[0]-p1[0], 2) + Math.pow(p0[1]-p1[1],2); length = Math.sqrt(length); let angle = Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI; line.setAttribute("transform", \`translate(\${p0[0]}, \${p0[1]}) rotate(\${angle}, 0, 0) translate(0 \${-this._thickness/2})\`); line.setAttribute("x", 0); line.setAttribute("y", 0); line.setAttribute("rx", this._thickness/4); line.setAttribute("width", length); line.setAttribute("height", this._thickness); } /* let points = []; for(let segment of this.segments) points.push(\`\${segment[0]},\${segment[1]}\`); points = points.join(" "); this.line.setAttribute("points", points); */ for(let idx = 0; idx < this.segments.length; ++idx) { let segment = this.segments[idx]; let edit_point = this.edit_points[idx]; edit_point.setAttribute("cx", segment[0]); edit_point.setAttribute("cy", segment[1]); let radius = this._thickness / 2; edit_point.setAttribute("rx", radius); edit_point.setAttribute("ry", radius); } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/editing_inpaint.js `; ppixiv.resources["src/menu_option.js"] = `"use strict"; ppixiv.settings_dialog = class extends ppixiv.dialog_widget { constructor({show_page="thumbnail", ...options}) { super({...options, visible: true, template: \`
Settings
\`}); this.pages = {}; this.page_buttons = new Map(); this.container.querySelector(".close-button").addEventListener("click", (e) => { this.shutdown(); }, { signal: this.shutdown_signal.signal }); this.add_settings(); // 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.shutdown(); }, { signal: this.shutdown_signal.signal }); // Hide on any state change. window.addEventListener("popstate", (e) => { this.shutdown(); }, { signal: this.shutdown_signal.signal }); this.show_page(show_page); } shutdown() { super.shutdown(); this.visible = false; this.container.remove(); this.link_tabs.shutdown(); } visibility_changed() { super.visibility_changed(); if(!this.visible) { // Remove the widget when it's hidden. this.container.remove(); } } add_settings() { this.items = this.container.querySelector(".items"); // Options that we pass to all menu_options: let global_options = { container: this.items, parent: this, classes: ["settings-row"], // Share our shutdown signal with the widgets, so their event listeners will be // shut down when we shut down. shutdown_signal: this.shutdown_signal, }; // Each settings widget. Doing it this way lets us move widgets around in the // menu without moving big blocks of code around. let settings_widgets = { thumbnail_size: () => { let button = new menu_option_button({ ...global_options, label: "Thumbnail size", buttons: [ new thumbnail_size_slider_widget({ ...global_options, parent: this, container: this.container, setting: "thumbnail-size", classes: ["size-slider"], min: 0, max: 7, }), ], }); button.container.querySelector(".size-slider").style.flexGrow = .25; }, manga_thumbnail_size: () => { let button = new menu_option_button({ ...global_options, label: "Thumbnail size (manga)", buttons: [ new thumbnail_size_slider_widget({ ...global_options, parent: this, container: this.container, setting: "manga-thumbnail-size", classes: ["size-slider"], min: 0, max: 7, }), ], }); button.container.querySelector(".size-slider").style.flexGrow = .25; }, disabled_by_default: () => { return new menu_option_toggle({ ...global_options, label: "Disabled by default", setting: "disabled-by-default", explanation_enabled: "Go to Pixiv by default.", explanation_disabled: "Go here by default.", }); }, no_hide_cursor: () => { return new menu_option_toggle({ ...global_options, label: "Hide cursor", setting: "no-hide-cursor", invert_display: true, explanation_enabled: "Hide the cursor while the mouse isn't moving.", explanation_disabled: "Don't hide the cursor while the mouse isn't moving.", }); }, invert_popup_hotkey: () => { return new menu_option_toggle({ ...global_options, label: "Shift-right-click to show the popup menu", setting: "invert-popup-hotkey", explanation_enabled: "Shift-right-click to open the popup menu", explanation_disabled: "Right click opens the popup menu", }); }, ctrl_opens_popup: () => { return new menu_option_toggle({ ...global_options, label: "Hold ctrl to show the popup menu", setting: "ctrl_opens_popup", explanation_enabled: "Pressing Ctrl shows the popup menu (for laptops)", }); }, ui_on_hover: () => { new menu_option_toggle({ ...global_options, label: "Hover to show search box", setting: "ui-on-hover", onchange: this.update_from_settings, explanation_enabled: "Only show the search box when hovering over it", explanation_disabled: "Always show the search box", }); }, invert_scrolling: () => { return new menu_option_toggle({ ...global_options, label: "Invert image panning", setting: "invert-scrolling", explanation_enabled: "Dragging down moves the image down", explanation_disabled: "Dragging down moves the image up", }); }, theme: () => { return new menu_option_toggle({ ...global_options, label: "Light mode", setting: "theme", on_value: "light", off_value: "dark", explanation_enabled: "FLASHBANG", }); }, disable_translations: () => { return new menu_option_toggle({ ...global_options, label: "Show tag translations when available", setting: "disable-translations", invert_display: true, }); }, disable_thumbnail_panning: () => { return new menu_option_toggle({ ...global_options, label: "Pan thumbnails while hovering over them", setting: "disable_thumbnail_panning", invert_display: true, }); }, disable_thumbnail_zooming: () => { return new menu_option_toggle({ ...global_options, label: "Zoom out thumbnails while hovering over them", setting: "disable_thumbnail_zooming", invert_display: true, }); }, bookmark_privately_by_default: () => { return new menu_option_toggle({ ...global_options, label: "Bookmark privately by default", setting: "bookmark_privately_by_default", explanation_disabled: "Pressing Ctrl-B will bookmark publically", explanation_enabled: "Pressing Ctrl-B will bookmark privately", }); }, quick_view: () => { return new menu_option_toggle({ ...global_options, label: "Quick view", setting: "quick_view", explanation_enabled: "Navigate to images immediately when the mouse button is pressed", 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; }, }); }, auto_pan: () => { return new menu_option_toggle({ ...global_options, label: "Pan images", setting: "auto_pan", //animation explanation_enabled: "Pan images while viewing them (drag the image to stop)", }); }, auto_pan_speed: () => { let button; let slider = new menu_option_slider_setting({ ...global_options, setting: "auto_pan_duration", list: [1, 2, 3, 5, 10, 15, 20, 30, 45, 60], classes: ["size-slider"], // Refresh the label when the value changes. onchange: function() { button.refresh(); }, }); button = new menu_option_button({ ...global_options, label: "Time per image", get_label: () => { let seconds = settings.get("auto_pan_duration");; return \`Pan duration: \${seconds} \${seconds != 1? "seconds":"second"}\`; }, buttons: [slider], }); button.container.querySelector(".size-slider").style.flexGrow = .25; }, slideshow_speed: () => { let button; let slider = new menu_option_slider_setting({ ...global_options, setting: "slideshow_duration", list: [1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180], classes: ["size-slider"], // Refresh the label when the value changes. onchange: function() { button.refresh(); }, }); button = new menu_option_button({ ...global_options, label: "Time per image", get_label: () => { let seconds = settings.get("slideshow_duration");; return \`Slideshow duration: \${seconds} \${seconds != 1? "seconds":"second"}\`; }, buttons: [slider], }); button.container.querySelector(".size-slider").style.flexGrow = .25; }, no_recent_history: () => { return new menu_option_toggle({ ...global_options, label: "Remember recent history", setting: "no_recent_history", invert_display: true, explanation_enabled: "Remember recently seen thumbnails", explanation_disabled: "Don't remember recently seen thumbnails", }); }, view_mode: () => { new menu_option_toggle({ ...global_options, label: "Return to the top when changing images", setting: "view_mode", on_value: "manga", off_value: "illust", }); }, link_tabs: () => { return new link_tabs_popup({ ...global_options, }); }, enable_linked_tabs: () => { return new menu_option_toggle({ ...global_options, label: "Enabled", setting: "linked_tabs_enabled", }); }, unlink_all_tabs: () => { return new menu_option_button({ ...global_options, label: "Unlink all tabs", onclick: () => { settings.set("linked_tabs", []); }, }); }, muted_tags: () => { return new muted_tags_popup({ mute_type: "tag", ...global_options, }); }, muted_users: () => { return new muted_tags_popup({ mute_type: "user", ...global_options, }); }, }; this.create_page("thumbnail", "Thumbnail options", global_options); settings_widgets.thumbnail_size(); if(!ppixiv.native) settings_widgets.manga_thumbnail_size(); settings_widgets.disable_thumbnail_panning(); settings_widgets.disable_thumbnail_zooming(); settings_widgets.quick_view(); settings_widgets.ui_on_hover(); this.create_page("image", "Image viewing", global_options); settings_widgets.auto_pan(); settings_widgets.auto_pan_speed(); settings_widgets.slideshow_speed(); settings_widgets.view_mode(); settings_widgets.invert_scrolling(); settings_widgets.no_hide_cursor(); this.create_page("tag_muting", "Muted tags", global_options); this.muted_tags = settings_widgets.muted_tags(); this.create_page("user_muting", "Muted users", global_options); this.muted_users = settings_widgets.muted_users(); this.create_page("linked_tabs", "Linked tabs", global_options); this.link_tabs = settings_widgets.link_tabs(); settings_widgets.enable_linked_tabs(); settings_widgets.unlink_all_tabs(); this.create_page("other", "Other", global_options); settings_widgets.disable_translations(); if(!ppixiv.native) settings_widgets.disabled_by_default(); // Firefox's contextmenu behavior is broken, so hide this option. if(navigator.userAgent.indexOf("Firefox/") == -1) settings_widgets.invert_popup_hotkey(); settings_widgets.ctrl_opens_popup(); // settings_widgets.theme(); settings_widgets.bookmark_privately_by_default(); // Hidden for now (not very useful) // settings_widgets.no_recent_history(); } create_page(id, title, global_options) { let page = this.create_template({name: "settings-page", html: \`
\`}); this.items.appendChild(page); global_options.container = page; let page_button = this.create_template({name: "settings-page-button", html: \` \`}); page_button.innerText = title; page.hidden = true; page_button.addEventListener("click", (e) => { this.show_page(id); }); this.container.querySelector(".sections").appendChild(page_button); this.pages[id] = { page: page, page_button: page_button, }; this.page_buttons.set(page, page_button); if(this.pages.length == 1) this.show_page(page); return page; } show_page(id) { if(this.visible_page != null) { helpers.set_class(this.visible_page.page_button, "selected", false); this.visible_page.page.hidden = true; } this.visible_page = this.pages[id]; this.visible_page.page.hidden = false; helpers.set_class(this.visible_page.page_button, "selected", true); this.refresh(); } refresh() { this.link_tabs.visible = this.visible && this.visible_page == this.pages.linked_tabs; this.muted_tags.visible = this.visible && this.visible_page == this.pages.tag_muting; this.muted_users.visible = this.visible && this.visible_page == this.pages.user_muting; } visibility_changed() { super.visibility_changed(); this.refresh(); } }; // Simple menu settings widgets. ppixiv.menu_option = class extends widget { constructor({ classes=[], onchange=null, ...options }) { super(options); for(let class_name of classes) this.container.classList.add(class_name); this.refresh = this.refresh.bind(this); this.onchange = onchange; } refresh() { if(this.onchange) this.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, label, get_label=null, onclick=null, explanation_enabled=null, explanation_disabled=null, buttons=[], ...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 = this.onclick.bind(this); this.onclick_handler = onclick; this._enabled = true; this.explanation_enabled = explanation_enabled; this.explanation_disabled = explanation_disabled; this.get_label = get_label; // If an icon was provided, add it. if(options.icon) { // This can be a resource name, or an element, usually created with helpers.create_icon. let node = options.icon; if(!(node instanceof HTMLElement)) node = helpers.create_ppixiv_inline(node); let icon = this.container.querySelector(".icon"); icon.appendChild(node); icon.hidden = false; } // Add items. for(let item of buttons) { // Move the button in. let item_container = item.container; item_container.remove(); this.container.appendChild(item_container); } // If a button was provided, add it. if(options.button) { let node = helpers.create_ppixiv_inline(options.button); this.container.appendChild(node); } if(this.onclick_handler != null) this.container.classList.add("clickable"); this.container.querySelector(".label").innerText = label; this.container.addEventListener("click", this.onclick); } refresh() { super.refresh(); if(this.get_label) this.container.querySelector(".label").innerText = this.get_label(); } 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); } } } // A simpler button, used for sub-buttons such as "Edit". ppixiv.menu_option_nested_button = class extends ppixiv.menu_option { constructor({ onclick=null, label, ...options}) { super({...options, template: \` \`}); this.container.querySelector(".label").innerText = label; this.container.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); onclick(e); }); } } ppixiv.menu_option_toggle = class extends ppixiv.menu_option_button { constructor({ setting=null, buttons=[], // Most settings are just booleans, but this can be used to toggle between // string keys. This can make adding more values to the option easier later // on. A default value should be set in settings.js if this is used. on_value=true, off_value=false, ...options}) { super({...options, button: "resources/checkbox.svg", buttons: buttons, onclick: (e) => { if(this.options && this.options.check && !this.options.check()) return; this.value = !this.value; }, }); this.setting = setting; this.on_value = on_value; this.off_value = off_value; if(this.setting) settings.changes.addEventListener(this.setting, this.refresh, { signal: this.shutdown_signal.signal }); } 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"; // Update the explanation text. let text = value? this.explanation_enabled:this.explanation_disabled; let explanation = this.container.querySelector(".explanation"); explanation.hidden = text == null; explanation.innerText = text; } get value() { return settings.get(this.setting) == this.on_value; } set value(value) { settings.set(this.setting, value? this.on_value:this.off_value); } } class menu_option_slider extends ppixiv.menu_option { constructor({ min=null, max=null, // If set, this is a list of allowed values. list=null, ...options }) { super({...options, template: \` \`}); this.oninput = this.oninput.bind(this); this.list = list; this.container.addEventListener("input", this.oninput); this.container.addEventListener("click", (e) => { e.stopPropagation(); }); this.slider = this.container.querySelector("input"); if(this.list != null) { this.slider.min = 0; this.slider.max = this.list.length - 1; } else { this.slider.min = min; this.slider.max = 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; } _slider_index_to_value(value) { if(this.list == null) return value; return this.list[value]; } _value_to_slider_index(value) { if(this.list == null) return value; let closest_idx = -1; let closest_distance = null; for(let idx = 0; idx < this.list.length; ++idx) { let v = this.list[idx]; let distance = Math.abs(value - v); if(closest_distance == null || distance < closest_distance) { closest_idx = idx; closest_distance = distance; } } return closest_idx; } set _slider_value(value) { value = this._value_to_slider_index(value); if(this.slider.value == value) return; this.slider.value = value; } get _slider_value() { let value = parseInt(this.slider.value); value = this._slider_index_to_value(value); return value; } } ppixiv.menu_option_slider_setting = class extends menu_option_slider { constructor({setting, ...options}) { super(options); this.setting = setting; } get min_value() { return this.options.min; } get max_value() { return this.options.max; } get value() { return settings.get(this.setting); } set value(value) { settings.set(this.setting, value); this.refresh(); } }; // A widget to control the thumbnail size slider. ppixiv.thumbnail_size_slider_widget = class extends menu_option_slider_setting { constructor({...options}) { super(options); this.refresh(); } // Increase or decrease zoom. move(down) { settings.adjust_zoom(this.setting, down); } get value() { let value = super.value; if(typeof(value) != "number" || isNaN(value)) value = 4; return value; } set value(value) { super.value = value; } static thumbnail_size_for_value(value) { return 100 * Math.pow(1.3, value); } get thumbnail_size() { return thumbnail_size_slider_widget.thumbnail_size_for_value(this.slider.value); } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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"; } set show_page_number(value) { this._show_page_number = value; this.refresh(); } refresh_internal({ media_id, 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. If the data source is data_source.vview, show // the index of the current file if it's loaded all results. let current_page = this._page; let page_count = illust_data.pageCount; let show_page_number = this._show_page_number; if(this.data_source?.name == "vview" && this.data_source.all_pages_loaded) { let page = this.data_source.id_list.get_page_for_illust(media_id); let ids = this.data_source.id_list.media_ids_by_page.get(page); if(ids != null) { current_page = ids.indexOf(media_id); page_count = ids.length; show_page_number = true; } } let page_text = ""; if(page_count > 1) { if(show_page_number) page_text = \`Page \${current_page+1}/\${page_count}\`; else page_text = \`\${page_count} pages\`; } set_info(".page-count", page_text); // 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 = ""; let width = null, height = null; if(this._page == 0) { width = illust_data.width; height = illust_data.height; } else { width = illust_data.mangaPages[this._page].width; height = illust_data.mangaPages[this._page].height; } if(width != null && height != null) info += width + "x" + 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); this.container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate); set_info(".post-age", age); } set_data_source(data_source) { if(this.data_source == data_source) return; this.data_source = data_source; this.refresh(); } } // 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); // Use key_listener to watch for ctrl being held. new key_listener("Control", this.ctrl_pressed.bind(this)); // 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; } } ctrl_pressed(down) { if(!settings.get("ctrl_opens_popup")) return; this.buttons_down["Control"] = 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 is signalled when the context menu is closed, and can be awaited // with wait_until_closed. this._closed_signal = new AbortController; 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("context-menu"); } wait_until_closed() { if(this._closed_signal == null) return null; return this._closed_signal.signal.wait(); } // 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("context-menu"); 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; } this._closed_signal.abort(); this._closed_signal = 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._media_id = 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 }); // If the page is navigated while the popup menu is open, clear the ID the // user clicked on, so we refresh and show the default. window.addEventListener("popstate", (e) => { this._clicked_media_id = null; this.refresh(); }); 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) => { e.preventDefault(); e.stopPropagation(); history.back(); }); this.container.addEventListener("click", this.handle_link_click); this.container.querySelector(".button-parent-folder").addEventListener("click", this.clicked_go_to_parent.bind(this)); 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({ contents: this.container.querySelector(".button-bookmark-tags"), parent: this, bookmark_tag_widget: bookmark_tag_widget, require_image: true, }), new toggle_dropdown_menu_widget({ contents: this.container.querySelector(".button-more"), parent: this, bookmark_tag_widget: more_options_widget, }), new like_button_widget({ parent: this, contents: this.container.querySelector(".button-like"), }), new like_count_widget({ parent: this, contents: this.container.querySelector(".button-like .count"), }), new context_menu_image_info_widget({ parent: this, contents: this.container.querySelector(".context-menu-image-info"), }), new bookmark_count_widget({ parent: this, contents: this.container.querySelector(".button-bookmark.public .count") }), ]; this.illust_widgets.push(new view_in_explorer_widget({ parent: this, contents: this.container.querySelector(".view-in-explorer"), })); // The bookmark buttons, and clicks in the tag dropdown: for(let a of this.container.querySelectorAll("[data-bookmark-type]")) { this.illust_widgets.push(new bookmark_button_widget({ parent: this, contents: a, bookmark_type: a.dataset.bookmarkType, 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 media 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_media_id() { if(this._clicked_media_id != null) return this._clicked_media_id; else return this._media_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; } // When the effective illust ID changes, let our widgets know. _effective_media_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 media_id = this.effective_media_id; if(!this.visible && media_id != null) return; this.refresh(); } set_media_id(media_id) { if(this._media_id == media_id) return; this._media_id = media_id; this._effective_media_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 && !this._on_click_viewer.slideshow_enabled; } set_data_source(data_source) { if(this.data_source == data_source) return; this.data_source = data_source; for(let widget of this.illust_widgets) { if(widget.set_data_source) widget.set_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 media_id = this.effective_media_id; // All of these hotkeys require Ctrl. if(!e.ctrlKey) return; if(e.key.toUpperCase() == "V") { (async() => { if(media_id == null) return; actions.like_image(media_id); })(); return true; } if(e.key.toUpperCase() == "B") { (async() => { if(media_id == null) return; let illust_data = await thumbnail_data.singleton().get_or_load_illust_data(media_id); // Ctrl-Shift-Alt-B: add a bookmark tag if(e.altKey && e.shiftKey) { actions.add_new_tag(media_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(media_id); return; } // Ctrl-B: bookmark with default privacy // Ctrl-Alt-B: bookmark privately let bookmark_privately = null; if(e.altKey) bookmark_privately = true; if(illust_data.bookmarkData != null) { message_widget.singleton.show("Already bookmarked (^B to remove bookmark)"); return; } actions.bookmark_add(media_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; } this._on_click_viewer.stop_animation(); // 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_media_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, media_id } = main_controller.singleton.get_illust_at_element(target); if(user_id != null) this._set_temporary_user(user_id); if(media_id != null) this._set_temporary_illust(media_id); } super.show(x, y, target); // Make sure we're up to date if we deferred an update while hidden. this._effective_media_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(media_id) { // Store the media_id immediately, so it's available without waiting for image // info to load. this._clicked_media_id = media_id; this._effective_media_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_media_id = 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_media_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 media_id = this.effective_media_id; let user_id = this.effective_user_id; for(let widget of this.illust_widgets) { if(widget.set_media_id) widget.set_media_id(media_id); if(widget.set_user_id) widget.set_user_id(user_id); // If _clicked_media_id is set, we're open for a search result image the user right-clicked // on. Otherwise, we're open for the image actually being viewed. Tell context_menu_image_info_widget // to show the current manga page if we're on a viewed image, but not if we're on a search // result. let showing_viewed_image = (this._clicked_media_id == null); widget.show_page_number = showing_viewed_image; } // If we're on a local ID, show the parent folder button. Otherwise, show the // author button. We only show one or the other of these. // // If we don't have an illust ID, see if the data source has a folder ID, so this // works when right-clicking outside thumbs on search pages. let folder_button = this.container.querySelector(".button-parent-folder"); let author_button = this.container.querySelector(".avatar-widget-container"); let is_local = helpers.is_media_id_local(this.folder_id_for_parent); folder_button.hidden = !is_local; author_button.hidden = is_local; helpers.set_class(folder_button, "enabled", this.parent_folder_id != null); } 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) { e.preventDefault(); e.stopPropagation(); main_controller.singleton.navigate_out(); } clicked_fullscreen(e) { e.preventDefault(); e.stopPropagation(); helpers.toggle_fullscreen(); } // "Zoom lock", zoom as if we're holding the button constantly clicked_zoom_toggle(e) { e.preventDefault(); e.stopPropagation(); if(!this._is_zoom_ui_enabled) return; this._on_click_viewer.stop_animation(); 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) { e.preventDefault(); e.stopPropagation(); if(!this._is_zoom_ui_enabled) return; this._on_click_viewer.stop_animation(); 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(); } // Return the illust ID whose parent the parent button will go to. get folder_id_for_parent() { return this.effective_media_id || this.data_source.viewing_folder; } // Return the folder ID that the parent button goes to. get parent_folder_id() { let folder_id = this.folder_id_for_parent; let is_local = helpers.is_media_id_local(folder_id); if(!is_local) return null; // Go to the parent of the item that was clicked on. let parent_folder_id = local_api.get_parent_folder(folder_id); // If the user right-clicked a thumbnail and its parent is the folder we're // already displaying, go to the parent of the folder instead (otherwise we're // linking to the page we're already on). This makes the parent button make // sense whether you're clicking on an image in a search result (go to the // location of the image), while viewing an image (also go to the location of // the image), or in a folder view (go to the folder's parent). let currently_displaying_id = local_api.get_local_id_from_args(helpers.args.location); if(parent_folder_id == currently_displaying_id) parent_folder_id = local_api.get_parent_folder(parent_folder_id); return parent_folder_id; } clicked_go_to_parent(e) { e.preventDefault(); let parent_folder_id = this.parent_folder_id; if(parent_folder_id == null) return; let args = new helpers.args("/", ppixiv.location); local_api.get_args_for_id(parent_folder_id, args); helpers.set_page_url(args.url, true, "navigation"); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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(", ")); media_ids = media_ids.slice(); for(let new_id of ids_to_remove) { let idx = media_ids.indexOf(new_id); media_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(media_ids.length == 0) return; this.media_ids_by_page.set(page, media_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.media_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. // If illust_id is null, start at the first loaded illustration. get_neighboring_media_id(media_id, next, options={}) { for(let i = 0; i < 100; ++i) // sanity limit { media_id = this._get_neighboring_media_id_internal(media_id, next, options); if(media_id == null) return null; // If it's not an illustration, keep looking. let { type } = helpers.parse_media_id(media_id); if(type == "illust" || type == "file") return media_id; } return null; } // The actual logic for get_neighboring_media_id, except for skipping entries. _get_neighboring_media_id_internal(media_id, next, { skip_manga_pages=false }={}) { if(media_id == null) return this.get_first_id(); // If we're navigating forwards, grab thumbnail info to get the page count to // see if we're at the end. let id = helpers.parse_media_id(media_id); if(id.type == "illust" && !skip_manga_pages) { // If we're navigating backwards and we're past page 1, just go to the previous page. if(!next && id.page > 0) { id.page--; return helpers.encode_media_id(id); } // If we're navigating forwards, grab illust data to see if we can navigate to the // next page. if(next) { let info = thumbnail_data.singleton().get_illust_data_sync(media_id); if(info == null) { console.log("Thumbnail info missing for", media_id); return null; } let [old_illust_id, old_page] = helpers.media_id_to_illust_id_and_page(media_id); if(old_page < info.pageCount - 1) { // There are more pages, so just navigate to the next page. id.page++; return helpers.encode_media_id(id); } } } // We only know about the first page of each media ID. Get the media ID for the first // page of media_id. id.page = 0; media_id = helpers.encode_media_id(id); let page = this.get_page_for_illust(media_id); if(page == null) return null; let ids = this.media_ids_by_page.get(page); let idx = ids.indexOf(media_id); let new_idx = idx + (next? +1:-1); let new_media_id = null; if(new_idx < 0) { // Get the last illustration on the previous page, or null if that page isn't loaded. let prev_page_no = page - 1; let prev_page_media_ids = this.media_ids_by_page.get(prev_page_no); if(prev_page_media_ids == null) return null; new_media_id = prev_page_media_ids[prev_page_media_ids.length-1]; } else if(new_idx >= ids.length) { // Get the first illustration on the next page, or null if that page isn't loaded. let next_page_no = page + 1; let next_page_media_ids = this.media_ids_by_page.get(next_page_no); if(next_page_media_ids == null) return null; new_media_id = next_page_media_ids[0]; } else { new_media_id = ids[new_idx]; } // If we're navigating backwards, get the last page on new_media_id. if(helpers.parse_media_id(new_media_id).type == "illust" && !next && !skip_manga_pages) { let info = thumbnail_data.singleton().get_illust_data_sync(new_media_id); if(info == null) { console.log("Thumbnail info missing for", media_id); return null; } let id = helpers.parse_media_id(new_media_id); id.page = info.pageCount - 1; new_media_id = helpers.encode_media_id(id); } return new_media_id; }; // 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.media_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.media_ids_by_page.size == 0) return null; let keys = this.media_ids_by_page.keys(); let page = keys.next().value; return this.media_ids_by_page.get(page)[0]; } // Return true if the given page is loaded. is_page_loaded(page) { return this.media_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.loaded_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); // Remove /en from the URL if it's present. url = helpers.get_url_without_language(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_sources.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"); // Ignore filenames for local IDs. args.hash.delete("file"); // slideshow is used by the viewer and doesn't affect the data source. args.hash.delete("slideshow"); 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 }={}) { // Note that we don't remove entries from loading_pages when they finish, so // future calls to load_page will still return a promise for that page that will // resolve immediately. let result = this.loaded_pages[page] || this.loading_pages[page]; if(result == null) { result = this._load_page_async(page, cause); this.loading_pages[page] = result; result.finally(() => { delete this.loading_pages[page]; this.loaded_pages[page] = result; }); } 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.loaded_pages[page] || 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; } // Return true if the data source can load the given page. // // This returns false for the page before the first loaded page, even if the data source // is technically able to load it. We can do that as a special case for the "load previous // results" button, which ignores this, but in most cases (such as clicking a page 1 // link when on page 2), we don't and instead create a new data source. can_load_page(page) { // Most data sources can load any page if they haven't loaded a page yet. Once // a page is loaded, they only load contiguous pages. if(!this.id_list.any_pages_loaded) return true; // If we've loaded pages 5-6, we can load anything between pages 4 and 7. let lowest_page = this.id_list.get_lowest_loaded_page(); let highest_page = this.id_list.get_highest_loaded_page(); return page >= lowest_page && page <= highest_page+1; } 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; // Run the actual load. 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.media_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_media_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 helpers.illust_id_to_media_id(args.hash.get("illust_id")); return this.id_list.get_first_id(); }; // If we're viewing a folder, return its ID. This is used for local searches. get viewing_folder() { return null; } // Return the page title to use. get page_title() { return "Pixiv"; } // Set the page icon. set_page_icon() { helpers.set_icon(); } // 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_media_id(media_id, args) { let [illust_id] = helpers.media_id_to_illust_id_and_page(media_id); 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; } // If true, all pages are loaded. This is only used by data_sources.vview. get all_pages_loaded() { return false; } // Store the current page in the URL. // // This is only used if supports_start_page is true. set_start_page(args, page) { // Remove the page for page 1 to keep the initial URL clean. if(page == 1) args.query.delete("p"); else 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, media_ids) { this.id_list.add_page(page, media_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, { current_url=null }={}) { this.set_item2(container, {type: type, fields: fields, default_values: default_values, current_url: current_url }); } set_item2(container, { type=null, fields=null, default_values=null, current_url=null, toggle=false, // If provided, this allows modifying URLs that put parameters in URL segments instead // of the query where they belong. If url_format is "abc/def/ghi", a key of "/abc" will modify // the first segment, and so on. url_format=null, // This can be used to adjust the link's URL without affecting anything else. adjust_url=null }={}) { let link = container.querySelector(\`[data-type='\${type}']\`); if(link == null) { console.warn("Couldn't find button with selector", type); return; } // If url_parts is provided, create a map from "/segment" to a segment number like "/1" that // args.set uses. let url_parts = {}; if(url_format != null) { let parts = url_format.split("/"); for(let idx = 0; idx < parts.length; ++idx) url_parts["/" + parts[idx]] = "/" + idx; } // The URL the button is relative to: if(current_url == null) current_url = this.url; // Adjust the URL for this button. let args = new helpers.args(new URL(current_url)); // Don't include the page number in search buttons, so clicking a filter goes // back to page 1. args.set("p", null); // This button is selected if all of the keys it sets are present in the URL. let button_is_selected = true; for(let [key, value] of Object.entries(fields)) { // If the key is "/path", look up the path index. if(key.startsWith("/")) { if(url_parts[key] == null) { console.warn(\`URL key \${key} not specified in URL: \${args}\`); continue; } key = url_parts[key]; } // The value we're setting in the URL: var this_value = value; if(this_value == null && default_values != null) this_value = default_values[key]; // The value currently in the URL: let selected_value = args.get(key); if(selected_value == null && default_values != null) selected_value = default_values[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[key]) value = null; args.set(key, value); } // If this is a toggle and the button is selected, remove the fields, turning // this into an "off" button. if(toggle && button_is_selected) { for(let key of Object.keys(fields)) args.set(key, null); } helpers.set_class(link, "selected", button_is_selected); if(adjust_url) adjust_url(args); link.href = args.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(); }; // Set the active class on all top-level dropdowns which have something other than // the default selected. set_active_popup_highlight(container) { // popup-menu-box-button is buttons that have dropdowns. Only affect .box-link, // so we don't mess with icons that are also buttons for popups. for(let button of container.querySelectorAll(".popup-menu-box-button.box-link")) { // 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, if any. let selected_item = box.querySelector(".selected"); let selected_default = selected_item == null || 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) { } // 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_media_id(media_id, next, options={}) { return this.id_list.get_neighboring_media_id(media_id, next, options); } // Return the next or previous image to navigate to from illust_id. If we're at the end of // the loaded results, load the next or previous page. If illust_id is null, return the first // image. This only returns illusts, not users or folders. // // This currently won't load more than one page. If we load a page and it only has users, // we won't try another page. async get_or_load_neighboring_media_id(media_id, next, options={}) { let new_media_id = this.get_neighboring_media_id(media_id, next, options); if(new_media_id != null) return new_media_id; // We didn't have the new illustration, so we may need to load another page of search results. // Find the page this illustration is on, or use the initial page if media_id is null. let next_page = this.id_list.get_page_for_neighboring_illust(media_id, next, options); if(media_id == null) next_page = this.initial_page; // If we can't find the next page, then the 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). if(next_page == null) { // We should normally know which page the illustration we're currently viewing is on. console.log("Don't know the next page for illust", media_id); return null; } console.log("Loading 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.id_list.is_page_loaded(next_page)) { console.error("Page", next_page, "is already loaded"); return null; } // Load a page. let new_page_loaded = await this.load_page(next_page, { cause: "illust navigation" }); if(!new_page_loaded) return null; // Now that we've loaded data, try to find the new image again. console.log("Finishing navigation after data load"); return this.id_list.get_neighboring_media_id(media_id, next, options); } }; // 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) { let media_ids = await this.load_all_results(); this.pages = paginate_illust_ids(media_ids, this.estimated_items_per_page); } // Register this page. var media_ids = this.pages[page-1] || []; this.add_page(page, media_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 media_ids = []; for(let thumb of thumbs) media_ids.push(helpers.illust_id_to_media_id(thumb.id)); tag_translations.get().add_translations_dict(result.body.tagTranslation); this.add_page(page, media_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"); let media_id = helpers.illust_id_to_media_id(illust_id) image_data.singleton().get_media_info(media_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 media_ids = []; for(let thumb of thumbs) media_ids.push(helpers.illust_id_to_media_id(thumb.id)); tag_translations.get().add_translations_dict(result.body.tagTranslation); this.add_page(page, media_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: 8, 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 media_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; media_ids.push("user:" + user.userId); let illustIds = user.illustIds || user.recentIllustIds; for(let illust_id of illustIds) media_ids.push(helpers.illust_id_to_media_id(illust_id)); } // Register the new page of data. this.add_page(page, media_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 media_ids = []; for(var item of result.contents) media_ids.push(helpers.illust_id_to_media_id("" + item.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, media_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 media_ids = this.parse_document(doc); if(media_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(media_ids.length, this.items_per_page); // Register the new page of data. this.add_page(page, media_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; this.booth_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_media_ids = await this.load_all_results(); this.pages = paginate_illust_ids(all_media_ids, this.estimated_items_per_page); } let media_ids = this.pages[page-1] || []; if(media_ids.length) { let illust_ids = []; for(let media_id of media_ids) { let illust_id = helpers.media_id_to_illust_id_and_page(media_id)[0]; illust_ids.push(illust_id); } // 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, media_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 media_ids = []; for(var illust_data of result.body.works) media_ids.push(helpers.illust_id_to_media_id(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, media_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"}); if(this.booth_url) links.push({url: this.booth_url, label: "Booth"}); } 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(); } // If this user has a linked Booth account, look it up. Only do this if the profile indicates // that it exists. Don't wait for this to complete. if(result.body?.externalSiteWorksStatus?.booth) this.load_booth(); 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); }); var media_ids = []; for(let illust_id of illust_ids) media_ids.push(helpers.illust_id_to_media_id(illust_id)); return media_ids; }; async load_booth() { let booth_request = await helpers.get_request("https://api.booth.pm/pixiv/shops/show.json", { pixiv_user_id: this.viewing_user_id, adult: "include", limit: 24, }); let booth = await booth_request; if(booth.error) { console.log(\`Error reading Booth profile for \${this.viewing_user_id}\`); reteurn; } this.booth_url = booth.body.url; this.call_update_listeners(); } 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("/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("/"); let illust_id = parts[2]; this.media_id = helpers.illust_id_to_media_id(illust_id); } // 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 media ID. get_current_media_id() { return this.media_id; } // We don't return any posts to navigate to, but this can still be called by // quick view. set_current_media_id(media_id, args) { let [illust_id] = helpers.media_id_to_illust_id_and_page(media_id); // 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 = "/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] || []; // illust.id is an int if this image is deleted. Convert it to a string so it's // like other images. let media_id = helpers.illust_id_to_media_id(illust.id.toString()); image_data.singleton().update_cached_bookmark_image_tags(media_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_item2(container, {type: "order-shuffle", fields: {"#shuffle": 1, ...set_public}, toggle: true, default_values: {"#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 bookmarks"; 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; } 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); // Set whether the tags menu item is highlighted. let box = container.querySelector(".bookmark-tags-box .box-link"); helpers.set_class(box, "active", current_tag != null); this.set_active_popup_highlight(container); } 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 media_ids = []; for(let illust_data of result.works) media_ids.push(helpers.illust_id_to_media_id(illust_data.id)); // If we're shuffling, shuffle the individual illustrations too. if(this.shuffle) helpers.shuffle_array(media_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, media_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. let media_ids = []; for(var i = 0; i < 2; ++i) if(this.bookmark_illust_ids[i] != null && this.bookmark_illust_ids[i][page] != null) media_ids = media_ids.concat(this.bookmark_illust_ids[i][page]); this.add_page(page, media_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 media_ids = []; for(let illust_data of result.works) media_ids.push(helpers.illust_id_to_media_id(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(media_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] = media_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, }); if(result.body.illusts.length > 0) { this.last_id = result.body.illusts[result.body.illusts.length-1].id; this.last_id_page++; } let media_ids = []; for(var illust_data of result.body.illusts) media_ids.push(helpers.illust_id_to_media_id(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.illusts, "normal"); // Register the new page of data. this.add_page(page, media_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 media_ids = []; for(let illust of data.thumbnails.illust) media_ids.push(helpers.illust_id_to_media_id(illust.id)); // Register the new page of data. this.add_page(page, media_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"); let media_ids = []; for(let illust of illusts) media_ids.push(helpers.illust_id_to_media_id(illust.id)); // Register the new page of data. this.add_page(page, media_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(".tag-search-box .input-field-container > input").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 media_ids = []; for(let illust of illusts) media_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, media_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"); let media_id = helpers.illust_id_to_media_id(illust_id) this.illust_info = await image_data.singleton().get_media_info(media_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; }; } ppixiv.data_sources.completed_requests = class extends data_source { get name() { return "completed-requests"; } get supports_start_page() { return true; } async load_page_internal(page) { let args = new helpers.args(new URL(this.url)); let showing = args.get("type") || "latest"; // "latest" or "recommended" let mode = args.get("mode") || "all"; let type = args.get_pathname_segment(2); // "illust" in "request/complete/illust" let url = \`/ajax/commission/page/request/complete/\${type}\`; let request_args = { "mode": mode, "p": page, "lang": "en", }; let result = await helpers.get_request(url, request_args); // Convert the request data from an array to a dictionary. let request_data = {}; for(let request of result.body.requests) request_data[request.requestId] = request; for(let user of result.body.users) image_data.singleton().add_user_data(user); thumbnail_data.singleton().loaded_thumbnail_info(result.body.thumbnails.illust, "normal"); tag_translations.get().add_translations_dict(result.body.tagTranslation); let media_ids = []; let request_ids = result.body.page[showing == "latest"? "requestIds":"recommendRequestIds"]; for(let request_id of request_ids) { // This has info for the request, like the requester and request text, but we just show these // as regular posts. let request = request_data[request_id]; let request_post_id = request.postWork.postWorkId; let media_id = helpers.illust_id_to_media_id(request_post_id); // This returns a lot of post IDs that don't exist. Why are people deleting so many of these? // Check whether the post was in result.body.thumbnails.illust. if(thumbnail_data.singleton().get_illust_data_sync(media_id) == null) continue; media_ids.push(media_id); } // Register the new page of data. this.add_page(page, media_ids); } refresh_thumbnail_ui(container, thumbnail_view) { this.set_item2(container, { type: "completed-requests-latest", fields: {type: "latest"}, default_values: {type: "latest"}}); this.set_item2(container, { type: "completed-requests-recommended", fields: {type: "recommended"}, default_values: {type: "latest"}}); this.set_item2(container, { type: "completed-requests-all", fields: {mode: "all"}, default_values: {mode: "all"}}); this.set_item2(container, { type: "completed-requests-safe", fields: {mode: "safe"}, default_values: {mode: "all"}}); this.set_item2(container, { type: "completed-requests-r18", fields: {mode: "r18"}, default_values: {mode: "all"}}); let url_format = "request/complete/type"; this.set_item2(container, { url_format: url_format, type: "completed-requests-illust", fields: {"/type": "illust"} }); this.set_item2(container, { url_format: url_format, type: "completed-requests-ugoira", fields: {"/type": "ugoira"} }); this.set_item2(container, { url_format: url_format, type: "completed-requests-manga", fields: {"/type": "manga"} }); } get page_title() { return "Completed requests"; }; get_displaying_text() { return "Completed requests"; } } // /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"; } 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_media_ids(); this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page); } // Register this page. let media_ids = this.pages[page-1] || []; let found_media_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(media_ids); thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "internal"); for(let thumb of thumbs) found_media_ids.push(helpers.illust_id_to_media_id(thumb.id)); this.add_page(page, found_media_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"); } } ppixiv.data_sources.vview = class extends data_source { get name() { return "vview"; } constructor(url) { super(url); this.reached_end = false; this.prev_page_uuid = null; this.next_page_uuid = null; this.next_page_offset = null; this.bookmark_tag_counts = null; this._all_pages_loaded = false; this.load_page(1, { cause: "preload" }); } get supports_start_page() { return true; } // If we've loaded all pages, this is true to let the context menu know it // should display page numbers. get all_pages_loaded() { return this._all_pages_loaded; } async load_page_internal(page) { // If the last result was at the end, stop. if(this.reached_end) return; this.fetch_bookmark_tag_counts(); // We should only be called in one of three ways: a start page (any page, but only if we have // nothing loaded), or a page at the start or end of pages we've already loaded. Figure out which // one this is. "page" is set to result.next of the last page to load the next page, or result.prev // of the first loaded page to load the previous page. let lowest_page = this.id_list.get_lowest_loaded_page(); let highest_page = this.id_list.get_highest_loaded_page(); let page_uuid = null; let loading_direction; if(page == lowest_page - 1) { // Load the previous page. page_uuid = this.prev_page_uuid; loading_direction = "backwards"; } else if(page == highest_page + 1) { // Load the next page. page_uuid = this.next_page_uuid; loading_direction = "forwards"; } else if(this.next_page_offset == null) { loading_direction = "initial"; } else { // This isn't our start page, and it doesn't match up with our next or previous page. console.log(\`Loaded unexpected page \${page} (\${lowest_page}...\${highest_page})\`); return; } if(this.next_page_offset == null) { // We haven't loaded any pages yet, so we can't resume the search in-place. Set next_page_offset // to the approximate offset to skip to this page number. this.next_page_offset = this.estimated_items_per_page * (page-1); } // Use the search options if there's no path. Otherwise, we're navigating inside // the search, so just view the contents of where we navigated to. let args = new helpers.args(this.url); let { search_options } = local_api.get_search_options_for_args(args); let folder_id = local_api.get_local_id_from_args(args, { get_folder: true }); if(args.hash.get("path") != null) search_options = null; let order = args.hash.get("order"); // If we have no search options, we're viewing a single directory. Load the whole // ID list with /ids. This only returns media IDs, but returns the entire directory, // and we can register the whole thing as one big page. This lets us handle local // files better: if you load a random file in a big directory and then back out to // the search, we can show the file you were on instead of going back to the top. // screen_search will load media info as needed when they're actually displayed. if(search_options == null) { console.log("Loading folder contents:", folder_id); let result_ids = await local_api.local_post_request(\`/api/ids/\${folder_id}\`, { ...search_options, ids_only: true, order: args.hash.get("order"), }); if(!result_ids.success) { message_widget.singleton.show("Error reading directory: " + result.reason); return result; } this.reached_end = true; this._all_pages_loaded = true; this.add_page(page, result_ids.ids); return; } // Note that this registers the results with thumbnail_data automatically. let result = await local_api.list(folder_id, { ...search_options, order: order, // If we have a next_page_uuid, use it to load the next page. page: page_uuid, limit: this.estimated_items_per_page, // This is used to approximately resume the search if next_page_uuid has expired. skip: this.next_page_offset, }); if(!result.success) { message_widget.singleton.show("Error reading directory: " + result.reason); return result; } // If we got a local path, store it to allow copying it to the clipboard. this.local_path = result.local_path; // Update the next and previous page IDs. If we're loading backwards, always update // the previous page. If we're loading forwards, always update the next page. If // either of these are null, update both. if(loading_direction == "backwards" || loading_direction == "initial") this.prev_page_uuid = result.pages.prev; if(loading_direction == "forwards" || loading_direction == "initial") this.next_page_uuid = result.pages.next; this.next_page_offset = result.next_offset; // If next is null, we've reached the end of the results. if(result.pages.next == null) this.reached_end = true; let found_media_ids = []; for(let thumb of result.results) found_media_ids.push(thumb.id); this.add_page(page, found_media_ids); }; // Override can_load_page. If we've already loaded a page, we've cached the next // and previous page UUIDs and we don't want to load anything else, even if the first // page we loaded had no results. can_load_page(page) { // next_page_offset is null if we haven't tried to load anything yet. if(this.next_page_offset == null) return true; // If we've loaded pages 5-6, we can load anything between pages 4 and 7. let lowest_page = this.id_list.get_lowest_loaded_page(); let highest_page = this.id_list.get_highest_loaded_page(); return page >= lowest_page && page <= highest_page+1; } get viewing_folder() { let args = new helpers.args(this.url); return local_api.get_local_id_from_args(args, { get_folder: true }); } get page_title() { return this.get_displaying_text(); } set_page_icon() { helpers.set_icon({vview: true}); } get_displaying_text() { // If we have a path inside a search, show the path, since we're not showing the // top-level search. Otherwise, get the search title. let args = new helpers.args(this.url); if(args.hash.get("path") != null) { let folder_id = local_api.get_local_id_from_args(args, { get_folder: true }); return helpers.get_path_suffix(helpers.parse_media_id(folder_id).id); } else { return local_api.get_search_options_for_args(args).title; } } // Put the illust ID in the hash instead of the path. Pixiv doesn't care about this, // and this avoids sending the user's filenames to their server as 404s. set_current_media_id(media_id, args) { local_api.get_args_for_id(media_id, args); } get_current_media_id() { let args = helpers.args.location; // If illust_id is in the URL, it's a regular ID. let illust_id = args.hash.get("illust_id"); if(illust_id) return illust_id; illust_id = local_api.get_local_id_from_args(args); if(illust_id != null) return illust_id; return this.id_list.get_first_id(); } // Tell the navigation tree widget which search to view. refresh_thumbnail_ui(container) { let current_args = helpers.args.location; // Hide the "copy local path" button if we don't have one. container.querySelector(".copy-local-path").hidden = this.local_path == null; this.set_item2(container, { type: "local-bookmarks-only", fields: {"#bookmarks": "1"}, toggle: true, current_url: current_args.url, adjust_url: (args) => { // If the button is exiting bookmarks, remove bookmark-tag too. if(!args.hash.has("bookmarks")) args.hash.delete("bookmark-tag"); } }); this.set_item2(container, { type: "local-type-all", fields: {"#type": null}, current_url: current_args.url }); this.set_item2(container, { type: "local-type-videos", fields: {"#type": "videos"}, current_url: current_args.url }); this.set_item2(container, { type: "local-type-images", fields: {"#type": "images"}, current_url: current_args.url }); this.set_item2(container, { type: "local-aspect-ratio-all", fields: {"#aspect-ratio": null}, current_url: current_args.url }); this.set_item2(container, { type: "local-aspect-ratio-landscape", fields: {"#aspect-ratio": \`3:2...\`}, current_url: current_args.url }); this.set_item2(container, { type: "local-aspect-ratio-portrait", fields: {"#aspect-ratio": \`...2:3\`}, current_url: current_args.url }); this.set_item2(container, { type: "local-res-all", fields: {"#pixels": null}, current_url: current_args.url }); this.set_item2(container, { type: "local-res-high", fields: {"#pixels": "4000000..."}, current_url: current_args.url }); this.set_item2(container, { type: "local-res-medium", fields: {"#pixels": "1000000...3999999"}, current_url: current_args.url }); this.set_item2(container, { type: "local-res-low", fields: {"#pixels": "...999999"}, current_url: current_args.url }); this.set_item2(container, {type: "local-sort-normal", fields: {"#order": null}, current_url: current_args.url }); this.set_item2(container, {type: "local-sort-invert", fields: {"#order": "-normal"}, current_url: current_args.url }); this.set_item2(container, {type: "local-sort-newest", fields: {"#order": "-ctime"}, current_url: current_args.url }); this.set_item2(container, {type: "local-sort-oldest", fields: {"#order": "ctime"}, current_url: current_args.url }); this.set_item2(container, {type: "local-sort-shuffle", fields: {"#order": "shuffle"}, toggle: true, current_url: current_args.url }); this.refresh_bookmark_tag_list(container); this.set_active_popup_highlight(container); } refresh_bookmark_tag_list(container) { // Clear the tag list. let tag_list = container.querySelector(".local-bookmark-tag-list"); for(let tag of tag_list.querySelectorAll(".following-tag")) tag.remove(); // Hide the bookmark box if we're not showing bookmarks. container.querySelector(".local-bookmark-tags-box").hidden = !helpers.args.location.hash.has("bookmarks"); // Stop if we don't have the tag list yet. if(this.bookmark_tag_counts == null) return; let add_tag_link = (tag) => { let tag_count = this.bookmark_tag_counts[tag]; let 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 bookmarks"; 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 args = helpers.args.location; let current_tag = args.hash.get("bookmark-tag"); if(tag == current_tag) a.classList.add("selected"); args.hash.delete("path"); if(tag == null) args.hash.delete("bookmark-tag"); else args.hash.set("bookmark-tag", tag); args.query.delete("p"); a.href = args.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); } this.set_active_popup_highlight(container); } async fetch_bookmark_tag_counts() { if(this.fetched_bookmark_tag_counts) return; this.fetched_bookmark_tag_counts = true; // We don't need to do this if we're not showing bookmarks. if(!helpers.args.location.hash.has("bookmarks")) return; let result = await local_api.local_post_request(\`/api/bookmark/tags\`); if(!result.success) { console.log("Error fetching bookmark tag counts"); return; } this.bookmark_tag_counts = result.tags; this.call_update_listeners(); } copy_link() { // The user clicked the "copy local link" button. navigator.clipboard.writeText(this.local_path); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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/r126/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 stationary. This corresponds to the mouseinactive event. get stationary() { return !this._active; } // 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) { this._active = false; window.dispatchEvent(new Event("mouseinactive")); } } } // 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 { static instances = new Set(); constructor(element) { hide_mouse_cursor_on_idle.add_style(); hide_mouse_cursor_on_idle.instances.add(this); this.track = new track_mouse_movement(); this.element = element; window.addEventListener("mouseactive", this.refresh_cursor_stationary.bind(this)); window.addEventListener("mouseinactive", this.refresh_cursor_stationary.bind(this)); settings.register_change_callback("no-hide-cursor", hide_mouse_cursor_on_idle.update_from_settings); hide_mouse_cursor_on_idle.update_from_settings(); } static disabled_by = new Set(); 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 = !this.is_enabled; } // Temporarily disable hiding all mouse cursors. source is a key for the UI that's doing // this, so different UI can disable cursor hiding without conflicting. static enable_all(source) { this.disabled_by.delete(source); this.update_from_settings(); for(let instance of hide_mouse_cursor_on_idle.instances) instance.refresh_hide_cursor(); } static disable_all(source) { this.disabled_by.add(source); this.update_from_settings(); for(let instance of hide_mouse_cursor_on_idle.instances) instance.refresh_hide_cursor(); } static get mouse_stationary() { return this._mouse_stationary; } static set mouse_stationary(value) { this._mouse_stationary = value; } static get is_enabled() { return !settings.get("no-hide-cursor") && this.disabled_by.size == 0; } refresh_cursor_stationary(e) { this.refresh_hide_cursor(); } refresh_hide_cursor() { // cursor-stationary means the mouse isn't moving, whether or not we're hiding // the cursor when it's stationary. hide-cursor is set to actually hide the cursor // and UI elements that are hidden with the cursor. let stationary = track_mouse_movement.singleton.stationary; let hidden = stationary && hide_mouse_cursor_on_idle.is_enabled; helpers.set_class(this.element, "hide-cursor", hidden); helpers.set_class(this.element, "show-cursor", !hidden); helpers.set_class(this.element, "cursor-stationary", stationary); helpers.set_class(this.element, "cursor-active", !stationary); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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 extends EventTarget { constructor() { super(); 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_media_ids = { }; 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); let event = new Event("usermodified"); event.user_id = user_id; this.dispatchEvent(event); } call_illust_modified_callbacks(media_id) { this.illust_modified_callbacks.call(media_id); let event = new Event("mediamodified"); event.media_id = media_id; this.dispatchEvent(event); } // Get media data asynchronously. // // await get_media_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_media_info(media_id) { media_id = helpers.get_media_id_first_page(media_id); if(media_id == null) return null; // Stop if we know this illust doesn't exist. if(media_id in this.nonexistant_media_ids) return null; // If we already have the image data, just return it. if(this.image_data[media_id] != null) return Promise.resolve(this.image_data[media_id]); // If there's already a load in progress, return the running promise. if(this.illust_loads[media_id] != null) return this.illust_loads[media_id]; var load_promise = this.load_media_info(media_id); this._started_loading_image_info(media_id, load_promise); return load_promise; } _started_loading_image_info(media_id, load_promise) { this.illust_loads[media_id] = load_promise; this.illust_loads[media_id].then(() => { delete this.illust_loads[media_id]; }); } // Like get_media_info, but return the result immediately. // // If the image info isn't loaded, don't start a request and just return null. get_media_info_sync(media_id) { media_id = helpers.get_media_id_first_page(media_id); return this.image_data[media_id]; } // Load media_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_media_info(media_id, { illust_data=null, load_user_info=false }={}) { media_id = helpers.get_media_id_first_page(media_id); let [illust_id] = helpers.media_id_to_illust_id_and_page(media_id); // 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[media_id] != null) return; media_id = helpers.get_media_id_first_page(media_id); delete this.nonexistant_media_ids[media_id]; // 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", media_id); // If this is a local image, use our API to retrieve it. if(helpers.is_media_id_local(media_id)) return await this._load_local_image_info(media_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(media_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_media_ids[media_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; // Switch the data URL to i-cf..pximg.net. let url = new URL(illust_data.ugoiraMetadata.originalSrc); helpers.adjust_image_url_hostname(url); illust_data.ugoiraMetadata.originalSrc = url.toString(); } // 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[media_id] = illust_data; return illust_data; } // If get_image_info or get_user_info returned null, return the error message. get_illust_load_error(media_id) { media_id = helpers.get_media_id_first_page(media_id); return this.nonexistant_media_ids[media_id]; } get_user_load_error(user_id) { return "user:" + this.nonexistant_media_ids[user_id]; } // Load image info from the local API. async _load_local_image_info(media_id) { let illust_data = await local_api.load_media_info(media_id); if(!illust_data.success) { media_id = helpers.get_media_id_first_page(media_id); this.nonexistant_media_ids[media_id] = illust_data.reason; return null; } this.image_data[media_id] = illust_data.illust; return illust_data.illust; } // 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. let base_media_id = "user:" + user_id; if(base_media_id in this.nonexistant_media_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) { let base_media_id = "user:" + user_id; // -1 is for illustrations with no user, which is used for local images. if(user_id == -1) return null; // 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_media_ids[base_media_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) { let media_id = helpers.illust_id_to_media_id(illust_data.illustId); var load_promise = this.load_media_info(media_id, { illust_data: illust_data }); this._started_loading_image_info(media_id, 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. async load_bookmark_details(media_id) { // If we know the image isn't bookmarked, we know there are no bookmark tags, so // we can skip this. media_id = helpers.get_media_id_first_page(media_id); let thumb = thumbnail_data.singleton().get_illust_data_sync(media_id); if(thumb && thumb.bookmarkData == null) return []; // Stop if this is already loaded. if(this.bookmarked_image_tags[media_id]) return this.bookmarked_image_tags[media_id]; // The local API just puts bookmark info on the illust info. if(helpers.is_media_id_local(media_id)) { this.bookmarked_image_tags[media_id] = thumb.bookmarkData.tags; return this.bookmarked_image_tags[media_id]; } let [illust_id] = helpers.media_id_to_illust_id_and_page(media_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[media_id] = tags; return this.bookmarked_image_tags[media_id]; } // Replace our cache of bookmark tags for an image. This is used after updating // a bookmark. update_cached_bookmark_image_tags(media_id, tags) { media_id = helpers.get_media_id_first_page(media_id); if(tags == null) delete this.bookmarked_image_tags[media_id]; else this.bookmarked_image_tags[media_id] = tags; this.call_illust_modified_callbacks(media_id); } // Remember when we've liked an image recently, so we don't spam API requests. get_liked_recently(media_id) { media_id = helpers.get_media_id_first_page(media_id); return this.recent_likes[media_id]; } add_liked_recently(media_id) { media_id = helpers.get_media_id_first_page(media_id); this.recent_likes[media_id] = true; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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, onviewcontainerchange}) { 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; // The image box is the container that we translate and scale. this.image_box = document.createElement("div"); this.image_box.classList.add("image-box"); this.image_box.style.position = "relative"; this.image_box.style.transformOrigin = "0 0"; this.image_box.style.right = "auto"; this.image_box.style.bottom = "auto"; this.image_container.appendChild(this.image_box); // The crop box is only used when cropping an image, and otherwise just holds // the image. this.crop_box = document.createElement("div"); this.crop_box.classList.add("crop-box"); this.crop_box.style.position = "relative"; this.crop_box.style.width = "100%"; this.crop_box.style.height = "100%"; this.image_box.appendChild(this.crop_box); this.onviewcontainerchange = onviewcontainerchange; this.media_id = null; this.original_width = 1; this.original_height = 1; this._cropped_size = null; 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 }); } // Return the URL or preview URL being displayed. get displaying_url() { let url = this.img?.src; return url == helpers.blank_image? null:url; } get displaying_preview_url() { let url = this.preview_img?.src; return url == helpers.blank_image? null:url; } // Load the given illust and page. set_new_image = async(signal, { media_id, url, preview_url, inpaint_url, width, height, // "history" to restore from history, "auto" to set automatically, or null to // leave the position alone. restore_position, // This callback will be run once an image has actually been displayed. ondisplayed, // If true, we're in slideshow mode. We'll always start an animation, and image // navigation will be disabled. slideshow=false, // If we're animating, this will be called when the animation finishes. onnextimage=null, // True if we should support inpaint images and editing (local only). enable_editing=false, // If set, this is a FixedDOMRect to crop the image to. crop=null, }={}) => { // 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; } // Don't show low-res previews during slideshows. if(slideshow) preview_url = url; // Don't restore the position if we're displaying the same image, so we don't interrupt // the user interacting with the image. if(media_id == this.media_id) restore_position = null; let img = document.createElement("img"); img.src = url? url:helpers.blank_image; img.className = "filtering"; img.style.width = "100%"; img.style.height = "100%"; img.style.position = "absolute"; // If image editing is enabled, wrap the image in an ImageEditingOverlayContainer. This acts like // an image as far as we're concerned. If editing isn't enabled, skip this. if(enable_editing) { let inpaint_container = new ImageEditingOverlayContainer(); inpaint_container.set_image_urls(url, inpaint_url); img = inpaint_container; } // 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"; preview_img.style.width = "100%"; preview_img.style.height = "100%"; preview_img.style.position = "absolute"; // 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(); // We're ready to finalize the new URLs by removing the old images and setting the // new ones. This is where displaying_url and displaying_preview_url change. // If we're displaying the same image, don't remove the animation if one is running. this.remove_images({remove_animation: !this.animation_enabled || media_id != this.media_id}); this.media_id = media_id; this.original_width = width; this.original_height = height; this._cropped_size = crop? new FixedDOMRect(crop[0], crop[1], crop[2], crop[3]):null; this.img = img; this.preview_img = preview_img; this.onnextimage = onnextimage; this.crop_box.appendChild(img); this.crop_box.appendChild(preview_img); this.update_crop(); // Only show the preview image until we're ready to show the main image. img.hidden = true; preview_img.hidden = true; // If the main image is already ready, show it. Otherwise, show the preview image. if(img_ready) this.img.hidden = false; else this.preview_img.hidden = false; // Restore history or set the initial position, then call reposition() to apply it // and do any clamping. Do this atomically with updating the images, so the caller // knows that restore_position happens when displaying_url changes. If the caller // wants to update if(restore_position == "auto" || this.slideshow_enabled) this.reset_position(); else if(restore_position == "history") this.restore_from_history(); // If we're in slideshow mode, we aren't using the preview image. Pause the animation // until we actually display it so it doesn't run while there's nothing visible. if(this.slideshow_enabled) this._pause_animation = true; this.reposition(); // We've changed the view container, so call onviewcontainerchange. if(this.onviewcontainerchange) this.onviewcontainerchange(img); // 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) { this._pause_animation = false; 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(); // If we paused an animation, resume it. this._pause_animation = false; this.img.hidden = false; this.preview_img.hidden = true; } async decode_img(img) { this.decoding = true; try { await img.decode(); } catch(e) { // Ignore exceptions from aborts. } finally { this.decoding = false; } } remove_images({remove_animation=true}={}) { this.cancel_save_to_history(); this.media_id = null; // 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.src = helpers.blank_image; this.img.remove(); this.img = null; } if(this.preview_img) { this.preview_img.src = helpers.blank_image; this.preview_img.remove(); this.preview_img = null; } if(remove_animation) this.stop_animation(); } shutdown() { this.stop_dragging(); this.remove_images(); if(this.image_box) { this.image_box.remove(); this.image_box = null; } this.event_shutdown.abort(); this.set_new_image.abort(); this.image_container = null; } // Return "portrait" if the image is taller than the screen, otherwise "landscape". get relative_aspect() { // 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); return (screen_width/this.cropped_width) > (screen_height/this.cropped_height)? "portrait":"landscape"; } // Set the pan position to the default for this image, or start the selected animation. reset_position() { // See if we want to play an animation instead. this.refresh_autopan(); if(this.animations != null) return; // Illustration viewing mode: // // 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. let aspect = this.relative_aspect; if(this.set_initial_image_position && aspect != this.initial_image_position_aspect) this.set_initial_image_position = false; // If view_mode is "manga", always reset to the top. It's better for reading top-to-bottom // than preserving the pan position. if(settings.get("view_mode") == "manga") 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(); if(this.animations) this.refresh_autopan(); } 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) { this.stop_animation(); // 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. 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 || this.slideshow_enabled) return; if(e.pressed) { e.preventDefault(); 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"); // Stop animating if this is a real click. If it's a carried-over click during quick // view, don't stop animating until we see a drag. if(e.type != "simulatedpointerdown") this.stop_animation(); 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.drag_movement = [0,0]; 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}) { // If we're animating, only start dragging after we pass a drag threshold, so we // don't cancel the animation in quick view. this.drag_movement[0] += movementX; this.drag_movement[1] += movementY; if(this.animations) { // This matches Windows's default SM_CXDRAG/SM_CYDRAG behavior. if(this.drag_movement[0] < 4 && this.drag_movement[1] < 4) return; this.stop_animation(); } // 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.cropped_width, screen_height/this.cropped_height); } // If we're cropping, this is the natural size of the crop rectangle. If we're // not cropping, this is just the size of the image. get cropped_width() { return this._cropped_size? this._cropped_size.width:this.original_width; } get cropped_height() { return this._cropped_size? this._cropped_size.height:this.original_height; } // Return the width and height of the image when at 1x zoom. get width() { return this.cropped_width * this._image_to_screen_ratio; } get height() { return this.cropped_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; // Stop if there's an animation active. if(this.animations != 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 let x = screen_width/2; let y = screen_height/2; // current_zoom_pos is the position that should be centered on screen. At // [0.5,0.5], the image is centered. let [pos_x, pos_y] = this.current_zoom_pos; x -= pos_x * zoomed_width; y -= pos_y * zoomed_height; // Only shift by integer amounts. This only matters when at 1:1, so there's // no subpixel offset. x = Math.round(x); y = Math.round(y); this.image_box.style.width = Math.round(zoomed_width) + "px"; this.image_box.style.height = Math.round(zoomed_height) + "px"; this.image_box.style.transform = \`translate(\${x}px, \${y}px)\`; } update_crop() { helpers.set_class(this.image_box, "cropping", this._cropped_size != null); // If we're not cropping, just turn the crop box off entirely. if(this._cropped_size == null) { this.crop_box.style.width = "100%"; this.crop_box.style.height = "100%"; this.crop_box.style.transformOrigin = "0 0"; this.crop_box.style.transform = ""; return; } // Crop the image by scaling up crop_box to cut off the right and bottom, // then shifting left and up. The size is relative to image_box, so this // doesn't actually increase the image size. let crop_width = this._cropped_size.width / this.original_width; let crop_height = this._cropped_size.height / this.original_height; let crop_left = this._cropped_size.left / this.original_width; let crop_top = this._cropped_size.top / this.original_height; this.crop_box.style.width = \`\${(1/crop_width)*100}%\`; this.crop_box.style.height = \`\${(1/crop_height)*100}%\`; this.crop_box.style.transformOrigin = "0 0"; this.crop_box.style.transform = \`translate(\${-crop_left*100}%, \${-crop_top*100}%)\`; } // 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; } // If we were animating, start animating again. if(args.state.zoom.animating) this.refresh_autopan(); 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, animating: this.animations != null, }; 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; } } // Start a pan/zoom animation. If it's already running, update it in place. refresh_autopan() { if(!this.animation_enabled) { this.stop_animation(); return; } // This is like the thumbnail animation, which gives a reasonable default for both landscape // and portrait animations. let auto_pan_ease = ppixiv.settings.get("auto_pan_ease"); // The target duration of the animation: let pan_duration = this.slideshow_enabled? ppixiv.settings.get("slideshow_duration"): ppixiv.settings.get("auto_pan_duration"); // Max speed sets how fast the image is allowed to move. If it's 0.5, the image shouldn't // scroll more half a screen per second, and the duration will be increased if needed to slow // it down. This keeps the animation from being too fast for very tall and wide images. // // Scale the max speed based on the duration. With a 5-second duration, allow the image // to move half a screen per second. With a 15-second duration, slow it down to no more // than a quarter screen per second. let max_speed = helpers.scale(pan_duration, 5, 15, 0.5, 0.25); max_speed = helpers.clamp(max_speed, 0.25, 0.5); let default_pan = { pan: [{ x: 0, y: 0, zoom: 1, max_speed: true, speed: max_speed, duration: pan_duration, ease: auto_pan_ease, }, { x: 1, y: 1, zoom: 1, duration: 2, }], }; // This adds a fade, and a slight delay at the start and end of the pan which // overlaps the fade. let default_pan_with_fade = { fade_in: 1, fade_out: 1, pan: [{ x: 0, y: 0, zoom: 1, duration: 0.5, }, { x: 0, y: 0, zoom: 1, max_speed: true, speed: max_speed, duration: pan_duration, ease: auto_pan_ease, }, { x: 1, y: 1, zoom: 1, duration: 1.0, }, { x: 1, y: 1, zoom: 1, }], }; // This zooms from "contain" to a slight zoom over "cover". let pull_in_with_fade = { fade_in: 1, fade_out: 1, pan: [{ x: 0.5, y: 0.0, zoom: 0, duration: pan_duration, ease: 0, // linear }, { x: 0.5, y: 0.0, zoom: 1.2, }], }; let animation = this.prepare_animation(this.slideshow_enabled? default_pan_with_fade:default_pan); // If the image's aspect ratio is very close to the screen's, a pan animation has nowhere to // go. If we're in slideshow mode, switch to a zoom. This let image_screen_ratio = (this.cropped_width/this.cropped_height) / (this.container_width/this.container_height); if(this.slideshow_enabled && Math.abs(image_screen_ratio - 1) < 0.05) animation = this.prepare_animation(pull_in_with_fade); this.run_animation(animation); } // Prepare an animation. This figures out the actual translate and scale for each // keyframe, and the total duration. The results depend on the image and window // size. prepare_animation(animation) { // Make a deep copy before modifying it. animation = JSON.parse(JSON.stringify(animation)); let screen_width = this.container_width; let screen_height = this.container_height; // At 1x zoom, the image will fit the screen. At _zoom_factor_cover, the image will // cover the screen. For auto-pan mode, scale the image to _zoom_factor_cover, so // the default size is to fill the screen. 1/_zoom_factor_cover is then the minimum // zoom we'll allow, which allows scaling down just enough to fit the image onscreen. // This also means that a 0x zoom can be used to zoom to fit the image onscreen. let zoom_factor_cover = this._zoom_factor_cover; animation.default_width = this.width * zoom_factor_cover; animation.default_height = this.height * zoom_factor_cover; // Calculate the scale and translate for each point. for(let point of animation.pan) { // Don't let the zoom go below the original 1:1 size. This allows panning to 1:1 // by setting zoom to 0. let minimum_zoom = 1 / zoom_factor_cover; let zoom = Math.max(minimum_zoom, point.zoom); // The screen size the image will have: let zoomed_width = animation.default_width * zoom; let zoomed_height = animation.default_height * zoom; // The amount we're allowed to move down and right: let max_x = zoomed_width - screen_width; let max_y = zoomed_height - screen_height; // By default, the image will be aligned to the top-left of the screen. Shift right and // down to center the top-left of the image on the screen: let move_x = screen_width/2; let move_y = screen_height/2; // Then shift up and left to center the point: move_x -= point.x*zoomed_width; move_y -= point.y*zoomed_height; // move_x and move_y are negative to move the image up and left. Clamp this so it never // moves right/down, and doesn't move the right/bottom of the image past the edge of the screen. move_x = Math.max(-max_x, Math.min(move_x, 0)); move_y = Math.max(-max_y, Math.min(move_y, 0)); // If the image isn't filling the screen on either axis, center it. This only applies at // keyframes (we won't always be centered while animating). if(zoomed_width < screen_width) move_x = (screen_width - zoomed_width) / 2; if(zoomed_height < screen_height) move_y = (screen_height - zoomed_height) / 2; point.computed_zoom = zoom; point.computed_tx = move_x; point.computed_ty = move_y; } // Calculate the duration for keyframes that specify a speed. // // If max_speed is true, speed is a cap. We'll move at the specified duration or // the duration based on speed, whichever is longer. for(let idx = 0; idx < animation.pan.length - 1; ++idx) { let p0 = animation.pan[idx+0]; let p1 = animation.pan[idx+1]; if(p0.speed == null) continue; // speed is relative to the screen size, so it's not tied too tightly to the resolution // of the window. The "size" of the window depends on which way we're moving: if we're moving // horizontally we only care about the horizontal size, and if we're moving diagonally, weight // the two. This way, the speed is relative to the screen size in the direction we're moving. // If it's 0.5 and we're only moving horizontally, we'll move half a screen width per second. let distance_x = Math.abs(p0.computed_tx - p1.computed_tx); let distance_y = Math.abs(p0.computed_ty - p1.computed_ty); if(distance_x == 0 && distance_y == 0) { // We're not moving at all. If the animation is based on speed, just set a small duration // to avoid division by zero. p0.actual_speed = 0; if(p0.duration == null) p0.duration = 0.1; continue; } let distance_ratio = distance_y / (distance_x + distance_y); // 0 = horizontal only, 1 = vertical only let screen_size = (screen_height * distance_ratio) + (screen_width * (1-distance_ratio)); // The screen distance we're moving: let distance_in_pixels = helpers.distance([p0.computed_tx, p0.computed_ty], [p1.computed_tx, p1.computed_ty]); // pixels_per_second is the speed we'll move at the given speed. Note that this ignores // easing, and we'll actually move faster or slower than this during the transition. let speed = Math.max(p0.speed, 0.01); let pixels_per_second = speed * screen_size; let duration = distance_in_pixels / pixels_per_second; if(p0.max_speed) p0.duration = Math.max(p0.duration, duration); else p0.duration = duration; // Reverse it to get the actual speed we ended up with. let actual_pixels_per_second = distance_in_pixels / p0.duration; p0.actual_speed = actual_pixels_per_second / screen_size; } // Calculate the total duration. The last point doesn't have a duration. let total_time = 0; for(let point of animation.pan.slice(0, animation.pan.length-1)) total_time += point.duration; animation.total_time = Math.max(total_time, 0.01); // For convenience, calculate the maximum speed of the animation. animation.max_speed = 0; for(let point of animation.pan) { if(point.actual_speed != null) animation.max_speed = Math.max(animation.max_speed, point.actual_speed); } return animation; } // animation must be prepared with prepare_animation first. run_animation(animation) { // If we're not updating an already-running animation, set up the image for animating. if(this.animation == null) { // Opacity from fades is applied when the animation stops, so the image doesn't reappear // while the next image is loading. If there's an opacity left over from the previous // image, remove it now. this.image_box.style.opacity = ""; this.image_box.style.width = Math.round(animation.default_width) + "px"; this.image_box.style.height = Math.round(animation.default_height) + "px"; } let keyframes = []; let current_time = 0; for(let point of animation.pan) { // Each point can specify an ease value. This is scaled so 0 is linear and 1 is ease-in-out. let ease = point.ease != null? point.ease: 1; ease *= 0.42; let keyframe = { transform: \`translateX(\${point.computed_tx}px) translateY(\${point.computed_ty}px) scale(\${point.computed_zoom})\`, easing: \`cubic-bezier(\${ease}, 0.0, \${1-ease}, 1.0)\`, offset: current_time / animation.total_time, }; keyframes.push(keyframe); current_time += point.duration; } // Create the animation, or update it in-place if it already exists, probably due to the // window being resized. total_time won't be updated when we do this. if(this.animations == null) { let main_animation = new Animation(new KeyframeEffect( this.image_box, keyframes, { duration: animation.total_time * 1000, fill: 'forwards', } )); // Create a separate animation for fade-in and fade-out. let fade_keyframes = [{ }]; let fade_duration = animation.fade_in + animation.fade_out; if(fade_duration > 0 && fade_duration <= animation.total_time) { fade_keyframes = [{ opacity: 0, }, { opacity: 1, easing: "linear", offset: animation.fade_in / animation.total_time, }, { opacity: 1, offset: 1 - (animation.fade_out / animation.total_time), }, { opacity: 0, offset: 1, }]; } let fade_animation = new Animation(new KeyframeEffect( this.image_box, fade_keyframes, { duration: animation.total_time * 1000, fill: 'forwards', } )); this.animations = [main_animation, fade_animation]; // Commit and remove the animation when it finishes, so the history state remembers that // we were no longer animating. This way, viewing an image in a linked tab and then removing // it doesn't restart a long-finished animation. We only pay attention to the main animation // for this and ignore the fade. this.animations[0].onfinish = (e) => { this.stop_animation(); if(this.slideshow_enabled && this.onnextimage) this.onnextimage(); }; for(let animation of this.animations) animation.play(); } else { this.animations[0].effect.setKeyframes(keyframes); } } // If a pan animation is running, cancel it. // // Animation is separate from pan and zoom, and any interaction with pan and zoom will // cancel the animation. stop_animation() { if(this.animations == null) return; // Commit the current state of the main animation so we can read where the image was. // This also commits the opacity, so if we're ending one image to display another the // image won't flash on screen. let applied_animations = true; try { for(let animation of this.animations) animation.commitStyles(); } catch { applied_animations = false; } // Cancel all animations. We don't need to wait for animation.pending here. for(let animation of this.animations) animation.cancel(); this.animations = null; if(!applied_animations) { // For some reason, commitStyles throws an exception if we're not visible, which happens // if we're shutting down. In this case, just cancel the animations. return; } // Figure out the zoom factor the animation left us with. The zoom factor is 1 if // the image width equals this.width. let { width, left, top } = this.image_box.getBoundingClientRect(); let zoom_factor = width / this.width; let zoom_level = this.zoom_factor_to_zoom_level(zoom_factor); // Apply the current zoom and pan position. If zoom_factor is 1x then just disable // zoom, and use "cover" if the zoom level matches it. The zoom we set here doesn't // have to be one that's selectable in the UI. this.locked_zoom = true; if(Math.abs(zoom_factor - 1) < 0.01) this.locked_zoom = false; else if(Math.abs(zoom_level - this._zoom_level_cover) < 0.01) this.zoom_level = "cover"; else this.zoom_level = zoom_level; // Set the image position to match where the animation left it. this.set_image_position([left, top], [0,0], false); this.reposition(); } // Return true if we want to be animating. get animation_enabled() { if(ppixiv.settings.get("auto_pan")) return true; return this.slideshow_enabled; } get slideshow_enabled() { return helpers.args.location.hash.get("slideshow") == "1"; } set _pause_animation(pause) { if(this.animations == null) return; for(let animation of this.animations) animation.updatePlaybackRate(pause? 0:1); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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/r126/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/r126/src/progress_bar.js `; ppixiv.resources["src/seek_bar.js"] = `"use strict"; ppixiv.seek_bar = class extends widget { constructor({...options}) { super({...options, template: \`
\` }); this.mousedown = this.mousedown.bind(this); this.mouseup = this.mouseup.bind(this); this.mousemove = this.mousemove.bind(this); this.container.addEventListener("mousedown", this.mousedown); this.current_time = 0; this.duration = 1; this.amount_loaded = 1; 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.container, "dragging", this.dragging); // 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); } stop_dragging() { if(!this.dragging) return; this.dragging = false; helpers.set_class(this.container, "dragging", this.dragging); 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.container.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.container.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; }; set_duration(seconds) { this.duration = seconds; this.refresh(); }; set_current_time(seconds) { this.current_time = seconds; this.refresh(); }; // Set the amount of the video that's loaded. If 1 or greater, the loading indicator will be // hidden. set_loaded(value) { this.amount_loaded = value; this.refresh(); } refresh() { let position = this.duration > 0.0001? (this.current_time / this.duration):0; this.container.querySelector(".seek-fill").style.width = (position * 100) + "%"; let loaded = this.amount_loaded < 1? this.amount_loaded:0; this.container.querySelector(".seek-loaded").style.width = (loaded * 100) + "%"; }; } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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/r126/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. These can also be played // when reading from the local API, since it'll decode these videos and turn them back into a ZIP. 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/r126/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({...options}) { super(options); 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 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/r126/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, template: \`
\` }); 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({ container: this.container, onviewcontainerchange: (viewcontainer) => { // Let image_editor know when the overlay container changes. if(viewcontainer instanceof ImageEditingOverlayContainer) this.image_editor.overlay_container = viewcontainer; }, }); // Create the inpaint editor. This is passed down to on_click_viewer to group // it with the image, but we create it here and reuse it. this.image_editor = new ppixiv.ImageEditor({ container: this.container, onvisibilitychanged: () => { this.refresh(); }, // refresh when crop editing is changed }); main_context_menu.get.on_click_viewer = this.on_click_viewer; } async load(signal, media_id, { restore_history=false, slideshow=false, onnextimage=null, }={}) { this.restore_history = restore_history; this.media_id = media_id; this._page = helpers.parse_media_id(media_id).page; this._slideshow = slideshow; this._onnextimage = onnextimage; // If this is a local image, tell the inpaint editor about it. this.image_editor.set_media_id(helpers.is_media_id_local(this.media_id)? this.media_id:null); // 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. This can return either // thumbnail info or illust info. // // 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.media_id); // Stop if we were removed before the request finished. signal.check(); // See if we got illust info or thumbnail info. if(early_illust_data.mangaPages != null) { // We got illust data and not thumbnail data, so we have all we need. this.illust_data = early_illust_data; } else { // We got thumbnail data, which 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, crop: early_illust_data.crop, // not per-page }]; this.refresh(); // Now wait for full illust info to load. this.illust_data = await image_data.singleton().get_media_info(this.media_id); // Stop if we were removed before the request finished. signal.check(); } // Update the list to include the image URLs. this.refresh_from_illust_data(); } refresh_from_illust_data() { if(this.illust_data == null) return; 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, inpaint_url: manga_page.urls.inpaint, width: manga_page.width, height: manga_page.height, crop: this.illust_data.crop, // not per-page }); } this.refresh(); } // If illust data changes, refresh in case any image URLs have changed. illust_data_changed() { // If we don't have illust_data, load() is still going. Don't do anything here, // let it finish and it'll pick up the latest data. if(this.illust_data == null) return; // Get the updated illust data. let illust_data = image_data.singleton().get_media_info_sync(this.media_id); if(illust_data == null) return; this.illust_data = illust_data; this.refresh_from_illust_data(); } // 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; } if(this.image_editor) { this.image_editor.shutdown(); this.image_editor = null; } main_context_menu.get.on_click_viewer = null; } 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; } // 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({ media_id: this.media_id, url: current_image.url, preview_url: current_image.preview_url, inpaint_url: current_image.inpaint_url, width: current_image.width, height: current_image.height, crop: this.image_editor.editing_crop? null:current_image.crop, // no cropping while editing cropping restore_position: this.restore_history? "history":"auto", // Only enable editing for local images. enable_editing: helpers.is_media_id_local(this.media_id), slideshow: this._slideshow, onnextimage: this._onnextimage, 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 case 35: // end e.stopPropagation(); e.preventDefault(); let id = helpers.parse_media_id(this.media_id); if(e.keyCode == 35) id.page = this.illust_data.pageCount - 1; else id.page = 0; let new_media_id = helpers.encode_media_id(id); main_controller.singleton.show_media(new_media_id); return; } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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
\`}); } async load(media_id, { slideshow=false, onnextimage=null, }={}) { 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"); }); // We don't skip muted images in slideshow immediately, since it could cause // API hammering if something went wrong, and most of the time slideshow is used // on bookmarks where there aren't a lot of muted images anyway. Just wait a couple // seconds and call onnextimage. if(slideshow && onnextimage) { let slideshow_timer = this.slideshow_timer = (async() => { await helpers.sleep(2000); if(slideshow_timer != this.slideshow_timer) return; onnextimage(); })(); } this.illust_data = await image_data.singleton().get_media_info(media_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); this.slideshow_timer = null; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/viewer_muted.js `; ppixiv.resources["src/viewer_ugoira.js"] = `"use strict"; ppixiv.viewer_ugoira = class extends ppixiv.viewer { constructor({...options}) { super({...options, template: \`
\`}); // Create the video UI. this.video_ui = new ppixiv.video_ui({ container: this.container.querySelector(".video-ui-container"), parent: this, }); this.refresh_focus = this.refresh_focus.bind(this); this.clicked_canvas = this.clicked_canvas.bind(this); this.onkeydown = this.onkeydown.bind(this); this.ontimeupdate = this.ontimeupdate.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 = this.video_ui.seek_bar; this.seek_bar.set_current_time(0); this.seek_bar.set_callback(this.seek_callback); this.video_container = this.container.querySelector(".video-container"); // 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.video_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, media_id, { slideshow=false, onnextimage=null, }={}) { this.unload(); // 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(media_id); signal.check(); this.create_preview_images(early_illust_data.previewUrls[0], null); // Load full data. this.illust_data = await image_data.singleton().get_media_info(media_id); signal.check(); // illust_data.urls for Pixiv, mangaPages[0] for local. let urls = this.illust_data.urls || this.illust_data.mangaPages[0].urls; this.create_preview_images(urls.small, urls.original); // This can be used to abort ZipImagePlayer's download. this.abort_controller = new AbortController; let source = null; let local = helpers.is_media_id_local(media_id); if(local) { // The local API returns a separate path for these, since it doesn't have // illust_data.ugoiraMetadata. source = this.illust_data.mangaPages[0].urls.mjpeg_zip; } else { source = this.illust_data.ugoiraMetadata.originalSrc; } // Create the player. this.player = new ZipImagePlayer({ metadata: this.illust_data.ugoiraMetadata, autoStart: false, source: source, local: local, mime_type: this.illust_data.ugoiraMetadata?.mime_type, signal: this.abort_controller.signal, autosize: true, canvas: this.canvas, loop: !slideshow, progress: this.progress, onfinished: onnextimage, }); this.player.video_interface.addEventListener("timeupdate", this.ontimeupdate, { signal: this.abort_controller.signal }); this.video_ui.video_changed({player: this, video: this.player.video_interface}); this.refresh_focus(); } // Undo load(). unload() { // Cancel the player's download and remove event listeners. 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); this.canvas.hidden = true; 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.video_ui) { this.video_ui.video_changed(); this.video_ui = null; } 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.video_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.video_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.seek_bar) { if(value == null) value = 1; this.seek_bar.set_loaded(value); } } // Once we draw a frame, hide the preview and show the canvas. This avoids // flicker when the first frame is drawn. ontimeupdate() { 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. 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/r126/src/viewer_ugoira.js `; ppixiv.resources["src/viewer_video.js"] = `"use strict"; // A player for video files. // // This is only used for local files, since Pixiv doesn't have any video support. // See viewer_ugoira for Pixiv's jank animation format. // // We don't show buffering. This is only used for viewing local files. ppixiv.viewer_video = class extends ppixiv.viewer { constructor({...options}) { super({...options, template: \`
\`}); // Create the video UI. this.video_ui = new ppixiv.video_ui({ container: this.container.querySelector(".video-ui-container"), parent: this, }); this.refresh_focus = this.refresh_focus.bind(this); this.clicked_video = this.clicked_video.bind(this); this.onkeydown = this.onkeydown.bind(this); this.update_seek_bar = this.update_seek_bar.bind(this); this.seek_callback = this.seek_callback.bind(this); this.seek_bar = this.video_ui.seek_bar; this.seek_bar.set_current_time(0); this.seek_bar.set_callback(this.seek_callback); // Create a canvas to render into. this.video = document.createElement("video"); this.video.loop = true; this.video.controls = false; this.video.preload = "auto"; this.video.volume = settings.get("volume"); this.video.muted = settings.get("mute"); // Store changes to volume. this.video.addEventListener("volumechange", (e) => { settings.set("volume", this.video.volume); settings.set("mute", this.video.muted); }); this.video.autoplay = true; this.video.className = "filtering"; this.video.style.width = "100%"; this.video.style.height = "100%"; this.video.style.display = "block"; this.video_container = this.container.querySelector(".video-container"); this.video_container.appendChild(this.video); this.video.addEventListener("timeupdate", this.update_seek_bar); this.video.addEventListener("progress", this.update_seek_bar); this.video_container.addEventListener("click", this.clicked_video); // In case we start PIP without playing first, switch the poster when PIP starts. this.video.addEventListener("enterpictureinpicture", (e) => { this.switch_poster_to_thumb(); }); // 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; } async load(media_id, { slideshow=false, onnextimage=null, }={}) { this.unload(); this.illust_data = await image_data.singleton().get_media_info(media_id); // Remove the old source, if any, and create a new one. if(this.source) this.source.remove(); this.source = document.createElement("source"); // Don't loop in slideshow. this.video.loop = !slideshow; this.video.onended = () => { if(onnextimage) onnextimage(); }; this.video.appendChild(this.source); // Set the video URLs. this.video.poster = this.illust_data.mangaPages[0].urls.poster; this.source.src = this.illust_data.mangaPages[0].urls.original; this.update_seek_bar(); // Sometimes mysteriously needing a separate load() call isn't isn't a sign of // good HTML element design. Everything else just updates after you change it, // how did this go wrong? this.video.load(); // Tell the video UI about the video. this.video_ui.video_changed({player: this, video: this.video}); this.refresh_focus(); } // Undo load(). unload() { this.illust_data = null; if(this.source) { this.source.remove(); this.source = null; } if(this.player) { this.player.pause(); this.player = null; } } // Undo load() and the constructor. shutdown() { this.unload(); super.shutdown(); if(this.video_ui) { this.video_ui.video_changed(); this.video_ui = null; } if(this.seek_bar) { this.seek_bar.set_callback(null); this.seek_bar = null; } this.video.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(); } // Replace the poster with the thumbnail if we enter PIP. Chrome displays the poster // in the main window while PIP is active, and the thumbnail is better for that. It's // low res, but Chrome blurs this image anyway. switch_poster_to_thumb() { if(this.illust_data != null) this.video.poster = this.illust_data.mangaPages[0].urls.small; } update_seek_bar() { if(this.seek_bar != null) { // Update the seek bar. let current_time = isNaN(this.video.currentTime)? 0:this.video.currentTime; let duration = isNaN(this.video.duration)? 1:this.video.duration; this.seek_bar.set_current_time(current_time); this.seek_bar.set_duration(duration); } } toggle_mute() { this.video.muted = !this.video.muted; } // This is sent manually by the UI handler so we can control focus better. onkeydown(e) { if(this.video == null) return; 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.video) 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.video.playbackRate = speed; return; } switch(e.keyCode) { case 77: // m this.toggle_mute(); break; case 32: // space e.stopPropagation(); e.preventDefault(); this.set_want_playing(!this.want_playing); return; case 36: // home e.stopPropagation(); e.preventDefault(); if(!this.video) return; this.video.currentTime = 0; return; case 35: // end e.stopPropagation(); e.preventDefault(); if(!this.video) return; this.pause(); this.video.currentTime = this.video.duration; 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.source == null) return; let active = this.want_playing && !this.seeking && this._active; if(active) this.video.play(); else this.video.pause(); }; async clicked_video(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.video.currentTime = seconds; this.update_seek_bar(); this.video_ui.time_changed(); } }; } // The overlay video UI. This is created by screen_illust, since viewer_video gets // recreated ppixiv.video_ui = class extends ppixiv.widget { constructor({...options}) { super({ ...options, template: \`
pause play_arrow
volume_up volume_off
picture_in_picture_alt
\`}); // Set .dragging to stay visible during drags. this.pointer_listener = new ppixiv.pointer_listener({ element: this.container, callback: (e) => { helpers.set_class(this.container, "dragging", e.pressed); }, }); // Add the seek bar. This moves between seek-bar-container-top and seek-bar-container-bottom. this.seek_bar = new seek_bar({ container: this.container.querySelector(".seek-bar-container-top"), parent: this, }); this.set_seek_bar_pos(); this.volume_slider = new volume_slider_widget({ contents: this.container.querySelector(".volume-slider"), parent: this, started_dragging: () => { // Remember what the volume was before the drag started. this.saved_volume = this.video.volume; }, stopped_dragging: () => { this.saved_volume = null; }, ondrag: (volume) => { if(!this.video) return; // Dragging the volume slider to 0 mutes and resets the underlying volume. if(volume == 0) { this.video.volume = this.saved_volume; this.video.muted = true; } else { this.video.volume = volume; this.video.muted = false; } }, }); this.time = this.container.querySelector(".time"); this.container.querySelector(".play-button").addEventListener("click", () => { if(this.player != null) this.player.set_want_playing(!this.player.want_playing); }); for(let button of this.container.querySelectorAll("[data-volume]")) button.addEventListener("click", () => { if(this.video == null) return; this.video.muted = !this.video.muted; }); this.container.querySelector(".pip-button").addEventListener("click", async () => { if(this.video == null) return; if(this.video.requestPictureInPicture == null) return false; try { await this.video.requestPictureInPicture(); return true; } catch(e) { return false; } }); document.addEventListener("fullscreenchange", (e) => { this.set_seek_bar_pos(); }); this.container.querySelector(".fullscreen").addEventListener("click", () => { helpers.toggle_fullscreen(); }); this.video_changed(); } // Set whether the seek bar is above or below the video UI. set_seek_bar_pos() { let top = document.fullscreenElement == null; // Insert the seek bar into the correct container. this.seek_bar.container.remove(); let seek_bar_container = top? ".seek-bar-container-top":".seek-bar-container-bottom"; this.container.querySelector(seek_bar_container).appendChild(this.seek_bar.container); this.seek_bar.container.dataset.position = top? "top":"bottom"; } video_changed({player=null, video=null}={}) { if(this.remove_video_listeners) { this.remove_video_listeners.abort(); this.remove_video_listeners = null; } this.player = player; this.video = video; // Only display the main UI when we have a video. Don't hide the seek bar, since // it's also used by viewer_ugoira. this.container.querySelector(".video-ui-strip").hidden = this.video == null; if(this.video == null) return; this.remove_video_listeners = new AbortController(); this.video.addEventListener("volumechange", (e) => { this.volume_changed(); }, { signal: this.remove_video_listeners.signal }); this.video.addEventListener("play", (e) => { this.pause_changed(); }, { signal: this.remove_video_listeners.signal }); this.video.addEventListener("pause", (e) => { this.pause_changed(); }, { signal: this.remove_video_listeners.signal }); this.video.addEventListener("timeupdate", (e) => { this.time_changed(); }, { signal: this.remove_video_listeners.signal }); this.video.addEventListener("loadedmetadata", (e) => { this.time_changed(); }, { signal: this.remove_video_listeners.signal }); this.video.addEventListener("progress", (e) => { this.time_changed(); }, { signal: this.remove_video_listeners.signal }); // Hide the PIP button if the browser or this video doesn't support it. this.container.querySelector(".pip-button").hidden = this.video.requestPictureInPicture == null; this.pause_changed(); this.volume_changed(); this.time_changed(); } pause_changed() { this.container.querySelector("[data-play='play']").style.display = !this.video.paused? "":"none"; this.container.querySelector("[data-play='pause']").style.display = this.video.paused? "":"none"; } volume_changed() { if(this.video.hide_audio_controls) { for(let element of this.container.querySelectorAll("[data-volume]")) element.style.display = "none"; this.volume_slider.container.hidden = true; } else { // Update the displayed volume icon. When not muted, scale opacity based on the volume. let opacity = (this.video.volume * 0.75) + 0.25; this.container.querySelector("[data-volume='high']").style.display = !this.video.muted? "":"none"; this.container.querySelector("[data-volume='high']").style.opacity = opacity; this.container.querySelector("[data-volume='mute']").style.display = this.video.muted? "":"none"; // Update the volume slider. If the video is muted, display 0 instead of the // underlying volume. this.volume_slider.container.hidden = false; this.volume_slider.set_value(this.video.muted? 0:this.video.volume); } } time_changed() { if(this.video == null) return; let duration = this.video.duration; let now = this.video.currentTime; if(isNaN(duration)) { this.time.innerText = ""; return; } if(duration < 10) { let fmt = (total_seconds) => { let seconds = Math.floor(total_seconds); let ms = Math.round((total_seconds * 1000) % 1000); return "" + seconds + "." + ms.toString().padStart(3, '0'); }; this.time.innerText = \`\${fmt(now)} / \${fmt(duration)}\`; } else { this.time.innerText = \`\${helpers.format_seconds(now)} / \${helpers.format_seconds(duration)}\`; } } } class volume_slider_widget extends ppixiv.widget { constructor({ ondrag, started_dragging, stopped_dragging, ...options }) { super(options); this.pointermove = this.pointermove.bind(this); this.ondrag = ondrag; this.started_dragging = started_dragging; this.stopped_dragging = stopped_dragging; this.volume_line = this.container.querySelector(".volume-line"); this.pointer_listener = new ppixiv.pointer_listener({ element: this.container, callback: (e) => { if(e.pressed) { this.started_dragging(); this.captured_pointer_id = e.pointerId; this.container.setPointerCapture(this.captured_pointer_id); this.container.addEventListener("pointermove", this.pointermove); this.handle_drag(e); } else { this.stop_dragging(); } }, }); } get is_dragging() { return this.captured_pointer_id != null; } pointermove(e) { this.handle_drag(e); } stop_dragging() { this.stopped_dragging(); this.container.removeEventListener("pointermove", this.pointermove); if(this.captured_pointer_id != null) { this.container.releasePointerCapture(this.captured_pointer_id); this.captured_pointer_id = null; } } set_value(value) { // Ignore external changes while we're dragging. if(this.is_dragging) return; this.set_value_internal(value); } set_value_internal(value) { value = 1 - value; this.volume_line.style.background = \`linear-gradient(to left, #000 \${value*100}%, #FFF \${value*100}px)\`; } handle_drag(e) { // Get the mouse position relative to the volume slider. let {left, width} = this.volume_line.getBoundingClientRect(); let volume = (e.clientX - left) / width; volume = Math.max(0, Math.min(1, volume)); this.set_value_internal(volume); this.ondrag(volume); }; } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/src/viewer_video.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, }); // If this fails, the error was already logged. The most common cause is being cancelled. if(response == null) return null; // 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. let result = await this.reader.read(file_size); // Read past the data descriptor if this file has one. let flags = view.getUint16(6, true); if(flags & 8) { let descriptor = await this.reader.read(16); let descriptor_view = new DataView(descriptor); if(descriptor_view.getUint32(0, true) != 0x08074b50) throw Error("Unrecognized file"); } return result; } }; // This gives a small subset of HTMLVideoPlayer's API to control the video, so // video_ui can work with this in the same way as a regular video. class ZipVideoInterface extends EventTarget { constructor(player) { super(); this.player = player; } // This is to tell video_ui to hide audio controls, since we have no audio. Somehow // there's no interface on HTMLVideoElement for this. get hide_audio_controls() { return true; } get paused() { return this.player.paused; } get duration() { // Expose the seekable duration rather than the full duration, since it looks // weird if you seek to the end of the seek bar and the time isn't at the end. // // Some crazy person decided to use NaN as a sentinel for unknown duration instead // of null, so mimic that. let result = this.player.get_seekable_duration(); if(result == null) return NaN; else return result; } get currentTime() { return this.player.get_current_frame_time(); } play() { return this.player.play(); } pause() { return this.player.pause(); } } ppixiv.ZipImagePlayer = class { constructor(options) { this.next_frame = this.next_frame.bind(this); this.op = options; this.interface = new ZipVideoInterface(this); // If true, continue playback when we get more data. this.waiting_for_frame = true; this.dead = false; this.context = options.canvas.getContext("2d"); // The frame that we want to be displaying: this.frame = 0; this.failed = false; // These aren't available until load() completes. this.frameTimestamps = []; this.total_length = 0; this.frame_count = 0; this.seekable_length = null; 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, }); if(this.op.local) { // For local files, the first file in the ZIP contains the metadata. let data; try { data = await this.downloader.get_next_frame(); } catch(e) { // This will usually be cancellation. console.info("Error downloading file", e); return; } // Is there really no "decode databuffer to string with encoding" API? data = new Uint8Array(data); data = String.fromCharCode.apply(null, data); data = JSON.parse(data); this.frame_metadata = data; } else { this.frame_metadata = this.op.metadata.frames; } // Make a list of timestamps for each frame. this.frameTimestamps = []; let milliseconds = 0; let last_frame_time = 0; for(let frame of this.frame_metadata) { this.frameTimestamps.push(milliseconds); milliseconds += frame.delay; last_frame_time = frame.delay; } this.total_length = milliseconds; this.frame_count = this.frame_metadata.length; // 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; 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/jpeg"; 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); this.video_interface.dispatchEvent(new Event("timeupdate")); if(this.paused) return; let meta = this.frame_metadata[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.frame_metadata[this.frame]; return meta.delay; } next_frame(frame) { this.timer = null; if(this.frame >= (this.frame_count - 1)) { if(!this.op.loop) { this.pause(); if(this.op.onfinished) this.op.onfinished(); 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(); this.video_interface.dispatchEvent(new Event("play")); } } pause() { if(this.dead) return; if(!this.paused) { this.unset_timer(); this.paused = true; this.video_interface.dispatchEvent(new Event("pause")); } } set_pause(value) { if(this.dead) return; if(this.paused = value) return; this.context.canvas.paused = this.paused; this.paused = value; } get video_interface() { return this.interface; } 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() { if(this.seekable_length == null) return null; else return this.seekable_length / 1000; } get_current_frame_time() { let timestamp = this.frameTimestamps[this.frame]; return timestamp == null? null: timestamp / 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.frame_metadata.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/r126/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, }); } // 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; } // Return the media ID being displayed, or null if none. get displayed_media_id() { return null; } // These are called to restore the scroll position on navigation. scroll_to_top() { } restore_scroll_position() { } scroll_to_media_id(media_id) { } async set_active(active) { // Show or hide the screen. this.visible = active; if(!active) { // 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/r126/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_media_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); this.view_container = this.container.querySelector(".view-container"); // Fullscreen on double-click. this.view_container.addEventListener("dblclick", () => { helpers.toggle_fullscreen(); }); // 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.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.view_container.hidden; } set _hide_image(value) { this.view_container.hidden = value; } async set_active(active, { media_id, 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_media_id(null); this.flashed_page_change = false; this.stop_displaying_image(); return; } this.set_data_source(data_source); this.show_image(media_id, restore_history); // Focus the container, so it receives keyboard events like home/end. this.container.focus(); } // Show an image. async show_image(media_id, restore_history) { console.assert(media_id != null); helpers.set_class(document.body, "force-ui", unsafeWindow.debug_show_ui); let [illust_id, manga_page] = helpers.media_id_to_illust_id_and_page(media_id); // 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(); if(await this.load_first_image(media_id)) return; // Remember that this is the image we want to be displaying. this.wanted_media_id = media_id; // If linked tabs are active, send this image. if(settings.get("linked_tabs_enabled")) SendImage.send_image(media_id, 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(media_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_media_id != media_id) { console.log("show_image: illust ID or page changed while async, stopping"); return; } // If we didn't get illust info, the image has probably 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; } console.log(\`Showing image \${media_id}\`); helpers.set_title_and_icon(early_illust_data); // Tell the preloader about the current image. image_preloader.singleton.set_current_image(media_id); // 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_media_id == null || 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. // // If we're not local, don't do this when showing the first image, since the most common // case is simply viewing a single image and then backing out to the search, so this avoids // doing extra loads every time you load a single illustration. if(!is_first_image_displayed || helpers.is_media_id_local(media_id)) { // get_navigation may block to load more search results. Run this async without // waiting for it. (async() => { let { media_id: new_media_id } = await this.get_navigation(this.latest_navigation_direction_down); // Let image_preloader handle speculative loading. If new_media_id is null, // we're telling it that we don't need to load anything. image_preloader.singleton.set_speculative_image(new_media_id); })(); } // Finalize the new illust ID. this.current_media_id = media_id; this.current_user_id = early_illust_data.userId; this.viewing_manga = early_illust_data.pageCount > 1; // for navigate_out_target this.ui.media_id = media_id; this.refresh_ui(); // 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; this.viewing_muted_image = this.view_muted; let is_muted = this.should_hide_muted_image(early_illust_data).is_muted; if(is_muted) viewer_class = viewer_muted; else if(early_illust_data.illustType == 2) viewer_class = viewer_ugoira; else if(early_illust_data.illustType == "video") viewer_class = viewer_video; 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) { this.viewer = new viewer_class({ container: this.view_container, manga_page_bar: this.manga_page_bar, }); } let slideshow = helpers.args.location.hash.get("slideshow") == "1"; this.viewer.load(media_id, { restore_history: restore_history, slideshow: slideshow, onnextimage: async () => { if(main_context_menu.get.visible) { // If the context menu is open, wait until it's closed before going // to the next image, so we don't change images while the user is // editing a bookmark. await main_context_menu.get.wait_until_closed(); } if(!this._active) return; // The viewer wants to go to the next image, normally during slideshows. this.navigate_to_next(1, { loop: true }); }, }); // 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(); } // If we're loading "*", it's a placeholder saying to view the first search result. // This allows viewing shuffled results. This can be a Pixiv illust ID of *, or // a local ID with a filename of *. Load the initial data source page if it's not // already loaded, and navigate to the first result. async load_first_image(media_id) { if(helpers.is_media_id_local(media_id)) { let args = helpers.args.location; local_api.get_args_for_id(media_id, args); if(args.hash.get("file") != "*") return false; } else if(helpers.parse_media_id(media_id).id != "*") return false; // This will load results if needed, skip folders so we only pick images, and return // the first ID. let new_media_id = await this.data_source.get_or_load_neighboring_media_id(null, true); if(new_media_id == null) { message_widget.singleton.show("Couldn't find an image to view"); return true; } main_controller.singleton.show_media(new_media_id, { add_to_history: false, }); return true; } 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 }; } // Remove the old viewer, if any. remove_viewer() { if(this.viewer != null) { this.viewer.shutdown(); this.viewer.container.remove(); 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() { this.remove_viewer(); if(this.manga_thumbnails) this.manga_thumbnails.set_illust_info(null); this.wanted_media_id = null; this.current_media_id = null; this.refresh_ui(); // Tell the preloader that we're not displaying an image anymore. image_preloader.singleton.set_current_image(null); image_preloader.singleton.set_speculative_image(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. this.ui.media_id = this.current_media_id; // Tell the context menu which user is being viewed. main_context_menu.get.user_id = this.current_user_id; main_context_menu.get.set_media_id(this.current_media_id); // Update the disable UI button to point at the current image's illustration page. var disable_button = this.container.querySelector(".disable-ui-button"); let [illust_id] = helpers.media_id_to_illust_id_and_page(this.current_media_id); 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", this.current_media_id == null); if(this.current_media_id == null) return; this.ui.refresh(); // Tell the view that illust data changed. if(this.viewer?.illust_data_changed) this.viewer.illust_data_changed(); } 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.navigate_to_next(down, { skip_manga_pages: e.shiftKey }); } get displayed_media_id() { return this.wanted_media_id; } 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.navigate_to_next(false, { skip_manga_pages: e.shiftKey }); break; case 39: // right case 40: // down case 34: // pgdn e.preventDefault(); e.stopPropagation(); this.navigate_to_next(true, { skip_manga_pages: e.shiftKey }); break; } } // Get the media_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, loop=false }={}) { // Check if we're just changing pages within the same manga post. // If we have a target media_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. let navigate_from_media_id = this.wanted_media_id; if(navigate_from_media_id == null) navigate_from_media_id = this.current_media_id; // Get the next (or previous) illustration after the current one. This will be null if we've // reached the end of the list. let new_media_id = await this.data_source.get_or_load_neighboring_media_id(navigate_from_media_id, down, { skip_manga_pages: skip_manga_pages }); // If we're at the end and we're looping, go to the first (or last) image. if(new_media_id == null && loop) new_media_id = down? this.data_source.id_list.get_first_id():this.data_source.id_list.get_last_id(); if(new_media_id == null) return { }; // If we're moving backwards and not skipping manga pages, we want to go to the last page // on the new image. Load image info to get the page count. let page = 0; if(!down && !skip_manga_pages) { let new_page_info = await thumbnail_data.singleton().get_or_load_illust_data(new_media_id); page = new_page_info.pageCount - 1; } // If the media ID changed and we have more than one page, we're leaving a manga post. let leaving_manga_post = false; if(navigate_from_media_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(navigate_from_media_id); let num_pages = early_illust_data.pageCount; if(num_pages > 1 && helpers.parse_media_id(this.wanted_media_id).id != helpers.parse_media_id(new_media_id).id) leaving_manga_post = true; } return { media_id: new_media_id, 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 navigate_to_next(down, { skip_manga_pages=false, loop=false }={}) { // 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 { media_id: new_media_id, end, leaving_manga_post } = await this.get_navigation(down, { skip_manga_pages: skip_manga_pages, loop: loop, }); // 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_media_id == null) { console.log("Reached the end of the list"); this.flash_end_indicator(down, "last-image"); return; } // 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 && !skip_manga_pages && !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_media_id != null) image_data.singleton().get_media_info(new_media_id); return; } // Go to the new illustration if we have one. if(new_media_id != null) main_controller.singleton.show_media(new_media_id); } 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/r126/src/screen_illust.js `; ppixiv.resources["src/screen_search.js"] = `"use strict"; let thumbnail_ui = class extends ppixiv.widget { constructor(options) { super({ ...options, template: \` \` }); } } // 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); this.scroll_container = this.container.querySelector(".search-results"); 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); new thumbnail_ui({ parent: this, container: this.container.querySelector(".thumbnail-ui-box-container"), }); 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", }); this.scroll_container.addEventListener("scroll", (e) => { this.schedule_store_scroll_position(); }, { passive: true, }); // Create the tag widget used by the search data source. this.tag_widget = new tag_widget({ contents: 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); this.container.querySelector(".bookmark-tags-box .bookmark-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true); this.container.querySelector(".local-bookmark-tags-box .local-bookmark-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.mediaId == null) return; await image_data.singleton().get_media_info(a.dataset.mediaId); }, 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) => { if(!e.pressed) return; 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 { media_id } = main_controller.singleton.get_illust_at_element(e.target); if(media_id == null) return; // Don't stopPropagation. We want the illustration view to see the press too. e.preventDefault(); // e.stopImmediatePropagation(); main_controller.singleton.show_media(media_id, { 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(); }); this.container.querySelector(".preferences-button").addEventListener("click", (e) => { new ppixiv.settings_dialog({ container: document.body }); }); 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); muting.singleton.addEventListener("mutes-changed", this.refresh_after_mute_change); // Zoom the thumbnails on ctrl-mousewheel: this.container.addEventListener("wheel", (e) => { if(!e.ctrlKey) return; e.preventDefault(); e.stopImmediatePropagation(); settings.adjust_zoom("thumbnail-size", e.deltaY > 0); }, { passive: false }); this.container.addEventListener("keydown", (e) => { let zoom = helpers.is_zoom_hotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); settings.adjust_zoom("thumbnail-size", zoom < 0); } }); // Create the tag dropdown for the search page input. new tag_search_box_widget({ contents: this.container.querySelector(".tag-search-box") }); // Create the tag dropdown for the search input in the menu dropdown. new tag_search_box_widget({ contents: this.container.querySelector(".navigation-search-box") }); // The search history dropdown for local searches. new local_search_box_widget({ contents: this.container.querySelector(".local-tag-search-box") }); new close_search_widget({ parent: this, container: this.container.querySelector(".local-navigation-box"), }); this.local_nav_widget = new ppixiv.local_navigation_widget({ parent: this, container: this.container.querySelector(".local-navigation-box"), }); this.container.querySelector(".copy-local-path").addEventListener("click", (e) => { this.data_source.copy_link(); }); // 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_data_source_page(); this.first_visible_thumbs_changed(); }, { root: this.scroll_container, threshold: 1, })); this.intersection_observers.push(new IntersectionObserver((entries) => { let any_changed = false; for(let entry of entries) { // Ignore special entries, if(entry.target.dataset.special) continue; helpers.set_dataset(entry.target.dataset, "nearby", entry.isIntersecting); any_changed = true; } // If no actual thumbnails changed, don't refresh. We don't want to trigger a refresh // from the special buttons being removed and added. if(!any_changed) return; // Set up any thumbs that just came nearby, and see if we need to load more search results. this.refresh_images(); this.set_visible_thumbs(); this.load_data_source_page(); }, { root: this.scroll_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.scroll_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_data_source_page(); 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: "Rankings", url: "/ranking.php#ppixiv" }, { label: "Recommended works", url: "/discovery#ppixiv" }, { label: "Recommended users", url: "/discovery/users#ppixiv" }, { label: "Completed requests", url: "/request/complete/illust#ppixiv" }, { label: "Search users", url: "/search_user.php#ppixiv" }, { label: "Recent history", url: "/history.php#ppixiv", classes: ["recent-history-link"] }, { label: "Local search", url: \`\${local_api.path}#ppixiv/\`, local: true, onclick: local_api.show_local_search }, ]; let create_option = (option) => { let button = new menu_option_button({ container: option_box, parent: this, no_icon_padding: true, label: option.label, url: option.url, classes: option.classes, onclick: option.onclick, }) // Hide the local search menu option if it's not enabled. if(option.local && !local_api.is_enabled()) button.container.hidden = true; return button; }; 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); } get_first_visible_thumb() { // Find the first thumb that's fully onscreen. Ignore elements not specific to a page (load previous results). return this.container.querySelector(\`.thumbnails > [data-id][data-fully-on-screen][data-search-page]\`); } // 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.get_first_visible_thumb(); 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.searchPage == null) return; let args = helpers.args.location; this.data_source.set_start_page(args, first_thumb.dataset.searchPage); helpers.set_page_url(args, false, "viewing-page", { send_popstate: 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_media_ids = []; for(let element of this.container.querySelectorAll(\`.thumbnails > [data-id][data-visible]:not([data-special])\`)) { let { type, id } = helpers.parse_media_id(element.dataset.id); if(type != "illust") continue; visible_media_ids.push(element.dataset.id); } ppixiv.recently_seen_illusts.get().add_illusts(visible_media_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); // Clear the view when the data source changes. If we leave old thumbs in the list, // it confuses things if we change the sort and refresh_thumbs tries to load thumbs // based on what's already loaded. let 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; // Cancel any async scroll restoration if the data source changes. this.cancel_restore_scroll_pos(); 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); }; 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); } } this.data_source.set_page_icon(); 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_slideshow_button(); 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_media_id() { if(this.data_source == null) return super.displayed_media_id; let user_id = this.data_source.viewing_user_id; if(user_id != null) return "user:" + user_id; let folder_id = this.data_source.viewing_folder; if(folder_id != null) return folder_id; return super.displayed_media_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.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); } // Mute/unmute if(user_id != null) { let entry = this.create_template({name: "mute-link", html: \` \`}); let muted = muting.singleton.is_muted_user_id(user_id); let a = entry.querySelector(".extra-link"); a.dataset.popup = \`\${muted? "Unmute":"Mute"} \${user_info?.name || "this user"}\`; row.insertAdjacentElement("beforeend", entry); a.addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); if(muting.singleton.is_muted_user_id(user_id)) muting.singleton.unmute_user_id(user_id); else await actions.add_mute(user_id, null, {type: "user"}); }); } // 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; } // Refresh the slideshow button. refresh_slideshow_button() { // For local images, set file=*. For Pixiv, set the file to *. let args = helpers.args.location; if(this.data_source.name == "vview") args.hash.set("file", "*"); else this.data_source.set_current_media_id("*", args); args.hash.set("slideshow", "1"); args.hash.set("view", "illust"); let node = this.container.querySelector("A.slideshow"); node.href = args.url; } // 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", "fantia.jp", "skeb.jp", "ko-fi.com", "dmm.co.jp", ], ".twitter-icon": [ "twitter.com", ], ".fanbox-icon": [ "fanbox.cc", ], ".booth-icon": [ "booth.pm", ], ".twitch-icon": [ "twitch.tv", ], }; // 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 ".fanbox-icon"; 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, old_media_id }) { if(this._active == active && this.data_source == data_source) return; this._active = active; await super.set_active(active); if(active) { this.set_data_source(data_source); this.initial_refresh_ui(); this.refresh_ui(); console.log("Showing search, came from media ID:", old_media_id); old_media_id = helpers.get_media_id_first_page(old_media_id); // We might get data_source_updated callbacks during load_data_source_page. // Make sure we ignore those, since we want the first refresh_images call // to be the one we make below. this.activating = true; try { // Make the first call to load_data_source_page, to load the initial page of images. await this.load_data_source_page(); } finally { this.activating = false; } // Show images. If we were displaying an image before we came here, forced_media_id // will force it to be included in the displayed results. this.finish_load_and_restore_scroll_pos(old_media_id); this.container.querySelector(".search-results").focus(); } else { this.stop_pulsing_thumbnail(); this.cancel_restore_scroll_pos(); main_context_menu.get.user_id = null; } } // Wait for the initial page to finish loading, then restore the scroll position if possible. async finish_load_and_restore_scroll_pos(old_media_id) { // Before we can set the scroll position, we need to wait for the initial page load to finish // so we can create thumbnails to scroll to. let restore_scroll_pos_id = this.restore_scroll_pos_id = new Object(); await this.data_source.load_page(this.data_source.initial_page, { cause: "initial scroll" }); // Stop if we were called again while we were waiting, or if we were cancelled. if(restore_scroll_pos_id !== this.restore_scroll_pos_id || !this._active) return; // Create the initial thumbnails. This will happen automatically, but we need to do it now so // we can scroll to them. this.refresh_images({ forced_media_id: old_media_id }); // If we have no saved scroll position or previous ID, scroll to the top. let args = helpers.args.location; if(args.state.scroll == null && old_media_id == null) { console.log("Scroll to top for new search"); this.scroll_container.scrollTop = 0; return; } // If we have a previous media ID, try to scroll to it. if(old_media_id != null) { // If we were displaying an image, pulse it to make it easier to find your place. this.pulse_thumbnail(old_media_id); // 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. if(this.scroll_to_media_id(old_media_id)) { console.log("Restored scroll position to:", old_media_id); return; } console.log("Couldn't restore scroll position for:", old_media_id); } if(this.restore_scroll_position(args.state.scroll?.scroll_position)) console.log("Restored scroll position from history"); } // Schedule storing the scroll position, resetting the timer if it's already running. schedule_store_scroll_position() { if(this.scroll_position_timer != -1) { clearTimeout(this.scroll_position_timer); this.scroll_position_timer = -1; } this.scroll_position_timer = setTimeout(() => { this.store_scroll_position(); }, 100); } // Save the current scroll position, so it can be restored from history. store_scroll_position() { let args = helpers.args.location; args.state.scroll = { scroll_position: this.save_scroll_position(), nearby_media_ids: this.get_nearby_media_ids(), }; helpers.set_page_url(args, false, "viewing-page", { send_popstate: false }); } // Cancel any call to restore_scroll_pos that's waiting for data. cancel_restore_scroll_pos() { this.restore_scroll_pos_id = null; } get active() { return this._active; } data_source_updated() { this.refresh_ui(); // Don't load or refresh images if we're in the middle of set_active. if(this.activating) return; this.refresh_images(); this.load_data_source_page(); } // Return all media IDs currently loaded in the data source, and the page // each one is on. get_data_source_media_ids() { let media_ids = []; let media_id_pages = {}; if(this.data_source == null) return [media_ids, media_id_pages]; 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(); for(let page = min_page; page <= max_page; ++page) { let media_ids_on_page = id_list.media_ids_by_page.get(page); console.assert(media_ids_on_page != null); // Create an image for each ID. for(let media_id of media_ids_on_page) { media_ids.push(media_id); media_id_pages[media_id] = page; } } return [media_ids, media_id_pages]; } // Make a list of media IDs that we want loaded. This has a few inputs: // // - The thumbnails that are already loaded, if any. // - A media ID that we want to have loaded. If we're coming back from viewing an image // and it's in the search results, we always want that image loaded so we can scroll to // it. // - The thumbnails that are near the scroll position (nearby thumbs). These should always // be loaded. // // Try to keep thumbnails that are already loaded in the list, since there's no performance // benefit to unloading thumbs. Creating thumbs can be expensive if we're creating thousands of // them, but once they're created, content-visibility keeps things fast. // // If forced_media_id is set and it's in the search results, always include it in the results, // extending the list to include it. If forced_media_id is set and we also have thumbs already // loaded, we'll extend the range to include both. If this would result in too many images // being added at once, we'll remove previously loaded thumbs so forced_media_id takes priority. // // If we have no nearby thumbs and no ID to force load, it's an initial load, so we'll just // start at the beginning. // // The result is always a contiguous subset of media IDs from the data source. get_media_ids_to_display({forced_media_id=null, column_count=null}={}) { // Get all media IDs from the data source. let [all_media_ids, media_id_pages] = this.get_data_source_media_ids(); if(all_media_ids.length == 0) return [[], []]; let [first_nearby_media_id, last_nearby_media_id] = this.get_nearby_media_ids(); let [first_loaded_media_id, last_loaded_media_id] = this.get_loaded_media_ids(); // If we're restoring a scroll position, use the nearby media IDs that we // saved when we left, so we load the same range. Only do this for the initial // refresh, when we don't already have thumbs nearby. let args = helpers.args.location; if(first_nearby_media_id == null && args.state.scroll?.nearby_media_ids != null) { first_nearby_media_id = args.state.scroll.nearby_media_ids[0]; last_nearby_media_id = args.state.scroll.nearby_media_ids[1]; } // The indices of each related media_id. These can all be -1. Note that it's // possible for nearby entries to not be in the data source, if the data source // was just refreshed and entries were removed. let first_nearby_media_id_idx = all_media_ids.indexOf(first_nearby_media_id); let last_nearby_media_id_idx = all_media_ids.indexOf(last_nearby_media_id); let first_loaded_media_id_idx = all_media_ids.indexOf(first_loaded_media_id); let last_loaded_media_id_idx = all_media_ids.indexOf(last_loaded_media_id); let forced_media_id_idx = all_media_ids.indexOf(forced_media_id); // Figure out the range of all_media_ids that we want to have loaded. let start_idx = 999999; let end_idx = 0; // If there are visible thumbs, extend the range to include them. if(first_nearby_media_id_idx != -1) start_idx = Math.min(start_idx, first_nearby_media_id_idx); if(last_nearby_media_id_idx != -1) end_idx = Math.max(end_idx, last_nearby_media_id_idx); // If we have a media ID to display, extend the range to include it. if(forced_media_id_idx != -1) { start_idx = Math.min(start_idx, forced_media_id_idx); end_idx = Math.max(end_idx, forced_media_id_idx); } // If we have a range, extend it outwards in both directions to load images // around it. if(start_idx != 999999) { start_idx -= 10; end_idx += 10; } // If there are thumbs already loaded, extend the range to include them. Do this // after extending the range above. if(first_loaded_media_id_idx != -1) start_idx = Math.min(start_idx, first_loaded_media_id_idx); if(last_loaded_media_id_idx != -1) end_idx = Math.max(end_idx, last_loaded_media_id_idx); // If we don't have anything, start at the beginning. if(start_idx == 999999) { start_idx = 0; end_idx = 0; } // Clamp the range. start_idx = Math.max(start_idx, 0); end_idx = Math.min(end_idx, all_media_ids.length-1); end_idx = Math.max(start_idx, end_idx); // make sure start_idx <= end_idx // If we're forcing an image to be included, and we also have images already // loaded, we can end up with a huge range if the two are far apart. For example, // if an image is loaded from a search, the user navigates for a long time in the // image view and then returns to the search, we'll load the image he ended up on // all the way to the images that were loaded before. Check the number of images // we're adding, and if it's too big, ignore the previously loaded thumbs and just // load IDs around forced_media_id. if(forced_media_id_idx != -1) { // See how many thumbs this would cause us to load. let loaded_thumb_ids = new Set(); for(let node of this.get_loaded_thumbs()) loaded_thumb_ids.add(node.dataset.id); let loading_thumb_count = 0; for(let thumb_id of all_media_ids.slice(start_idx, end_idx+1)) { if(!loaded_thumb_ids.has(thumb_id)) loading_thumb_count++; } if(loading_thumb_count > 100) { console.log("Reducing loading_thumb_count from", loading_thumb_count); start_idx = forced_media_id_idx - 10; end_idx = forced_media_id_idx + 10; start_idx = Math.max(start_idx, 0); end_idx = Math.min(end_idx, all_media_ids.length-1); } } // Snap the start of the range to the column count, so images always stay on the // same column if we add entries to the beginning of the list. This only works if // the data source provides all IDs at once, but if it doesn't then we won't // auto-load earlier images anyway. if(column_count != null) start_idx -= start_idx % column_count; let media_ids = all_media_ids.slice(start_idx, end_idx+1); /* console.log( "Nearby range:", first_nearby_media_id_idx, "to", last_nearby_media_id_idx, "Loaded range:", first_loaded_media_id_idx, "to", last_loaded_media_id_idx, "Forced idx:", forced_media_id_idx, "Returning:", start_idx, "to", end_idx); */ // Load thumbnail info for the results. We don't wait for this to finish. this.load_thumbnail_data_for_media_ids(all_media_ids, start_idx, end_idx); return [media_ids, media_id_pages]; } load_thumbnail_data_for_media_ids(all_media_ids, start_idx, end_idx) { // Stop if the range is already loaded. let media_ids = all_media_ids.slice(start_idx, end_idx+1); if(thumbnail_data.singleton().are_all_media_ids_loaded_or_loading(media_ids)) return; // Make a list of IDs that need to be loaded, removing ones that are already // loaded. let media_ids_to_load = []; for(let media_id of media_ids) { if(!thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id)) media_ids_to_load.push(media_id); } if(media_ids_to_load.length == 0) return; // Try not to request thumbnail info in tiny chunks. If we load them as they // scroll on, we'll make dozens of requests for 4-5 thumbnails each and spam // the API. Avoid this by extending the list outwards, so we load a bigger chunk // in one request and then stop for a while. // // Don't do this for the local API. Making lots of tiny requests is harmless // there since it's all local, and requesting file info causes the file to be // scanned if it's not yet cached, so it's better to make fine-grained requests. let min_to_load = this.data_source?.name == "vview"? 10: 30; let load_start_idx = start_idx; let load_end_idx = end_idx; while(media_ids_to_load.length < min_to_load && (load_start_idx >= 0 || load_end_idx < all_media_ids.length)) { let media_id = all_media_ids[load_start_idx]; if(media_id != null && !thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id)) media_ids_to_load.push(media_id); media_id = all_media_ids[load_end_idx]; if(media_id != null && !thumbnail_data.singleton().is_media_id_loaded_or_loading(media_id)) media_ids_to_load.push(media_id); load_start_idx--; load_end_idx++; } thumbnail_data.singleton().get_thumbnail_info(media_ids_to_load); } // Return the first and last media IDs that are nearby. get_nearby_media_ids() { let nearby_thumbs = this.scroll_container.querySelectorAll(\`[data-id][data-nearby]:not([data-special])\`); let first_nearby_media_id = nearby_thumbs[0]?.dataset?.id; let last_nearby_media_id = nearby_thumbs[nearby_thumbs.length-1]?.dataset?.id; return [first_nearby_media_id, last_nearby_media_id]; } // Return the first and last media IDs that's currently loaded into thumbs. get_loaded_media_ids() { let loaded_thumbs = this.scroll_container.querySelectorAll(\`[data-id]:not([data-special]\`); let first_loaded_media_id = loaded_thumbs[0]?.dataset?.id; let last_loaded_media_id = loaded_thumbs[loaded_thumbs.length-1]?.dataset?.id; return [first_loaded_media_id, last_loaded_media_id]; } refresh_images({forced_media_id=null}={}) { // Update the thumbnail size style. This also tells us the number of columns being // displayed. let ul = this.container.querySelector(".thumbnails"); let thumbnail_size = settings.get("thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); let [dimensions_css, column_count] = 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, }); this.thumbnail_dimensions_style.textContent = dimensions_css; // Get the thumbnail media IDs to display. let [media_ids, media_id_pages] = this.get_media_ids_to_display({ column_count: column_count, forced_media_id: forced_media_id, }); // Save the scroll position relative to the first thumbnail. Do this before making // any changes. let saved_scroll = this.save_scroll_position(); // Remove special:previous-page if it's in the list. It'll confuse the insert logic. // We'll add it at the end if it should be there. let special = this.container.querySelector(\`.thumbnails > [data-special]\`); if(special) special.remove(); // 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 media_ids. // 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. // Make a dictionary of all illust IDs and pages, so we can look them up quickly. let media_id_index = {}; for(let i = 0; i < media_ids.length; ++i) { let media_id = media_ids[i]; media_id_index[media_id] = i; } let get_node_idx = function(node) { if(node == null) return null; let media_id = node.dataset.id; return media_id_index[media_id]; } // Find the first match (4 in the above example). let first_matching_node = ul.firstElementChild; 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 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 media_id = media_ids[idx]; let search_page = media_id_pages[media_id]; let node = this.create_thumb(media_id, search_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 < media_ids.length; ++idx) { let media_id = media_ids[idx]; let search_page = media_id_pages[media_id]; let node = this.create_thumb(media_id, search_page); ul.appendChild(node); } // 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 && this.data_source.initial_page > 1) { // Reuse the node if we removed it earlier. if(special == null) special = this.create_thumb("special:previous-page", null); ul.insertAdjacentElement("afterbegin", special); } this.restore_scroll_position(saved_scroll); } // Start loading data pages that we need to display visible thumbs, and start // loading thumbnail data for nearby thumbs. async load_data_source_page() { // 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; if(this.data_source && !this.data_source.is_page_loaded_or_loading(this.data_source.initial_page)) load_page = this.data_source.initial_page; else { // If the last thumb in the list is visible, we need the next page to continue. // Note that since get_nearby_thumbnails returns thumbs before they actually scroll // into view, this will happen before the last thumb is actually visible to the user. let elements = this.get_nearby_thumbnails(); if(elements.length > 0 && elements[elements.length-1].nextElementSibling == null) { let last_element = elements[elements.length-1]; load_page = parseInt(last_element.dataset.searchPage)+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) this.container.querySelector(".no-results").hidden = false; 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; } // Save the current scroll position relative to the first visible thumbnail. // The result can be used with restore_scroll_position. save_scroll_position() { let first_visible_thumb_node = this.get_first_visible_thumb(); if(first_visible_thumb_node == null) return null; return { saved_scroll: helpers.save_scroll_position(this.scroll_container, first_visible_thumb_node), media_id: first_visible_thumb_node.dataset.id, } } // Restore the scroll position from a position saved by save_scroll_position. restore_scroll_position(scroll) { if(scroll == null) return false; // Find the thumbnail for the media_id the scroll position was saved at. let restore_scroll_position_node = this.scroll_container.querySelector(\`[data-id="\${helpers.escape_selector(scroll.media_id)}"]\`); if(restore_scroll_position_node == null) return false; helpers.restore_scroll_position(this.scroll_container, restore_scroll_position_node, scroll.saved_scroll); return true; } update_from_settings() { var thumbnail_mode = settings.get("thumbnail-size"); this.set_visible_thumbs(); this.refresh_images(); document.body.dataset.theme = "dark"; //settings.get("theme"); 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_nearby_thumbnails(); for(var element of elements) { let media_id = element.dataset.id; if(media_id == null) continue; var search_mode = this.data_source.search_mode; let { id: thumb_id, type: thumb_type } = helpers.parse_media_id(media_id); // 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" || thumb_type == "file" || thumb_type == "folder") { // Get thumbnail info. var info = thumbnail_data.singleton().get_one_thumbnail_info(media_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" && thumb_type != "file" && thumb_type != "folder") 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) { // 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); element.classList.add("muted"); 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; element.classList.remove("muted"); // 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.mediaId 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 if(thumb_type == "folder") { // This is a local directory. We only expect to see this while on the local // data source. The folder link retains any search parameters in the URL. let args = helpers.args.location; local_api.get_args_for_id(media_id, args); let link = element.querySelector("a.thumbnail-link"); link.href = args.url; element.querySelector(".page-count-box").hidden = false; } else { link.href = helpers.get_url_for_id(media_id); } link.dataset.mediaId = media_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 || info.illustType == "video") element.querySelector(".ugoira-icon").hidden = false; if(info.pageCount > 1) { var pageCountBox = element.querySelector(".page-count-box"); pageCountBox.hidden = false; element.querySelector(".page-count-box .page-count").textContent = info.pageCount; element.querySelector(".page-count-box .page-count").hidden = false; let args = new helpers.args(link.href); args.hash.set("view", "manga"); pageCountBox.href = args.url; } } 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. let illust_id = helpers.media_id_to_illust_id_and_page(media_id)[0]; 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 if(thumb_type == "folder") { // The ID is based on the filename. Use it to show the directory name in the thumbnail. let parts = media_id.split("/"); let basename = parts[parts.length-1]; let label = element.querySelector(".thumbnail-label"); label.hidden = false; label.querySelector(".label").innerText = basename; } 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 media_id. // // This is used to refresh the bookmark icon when changing a bookmark. refresh_thumbnail(media_id) { var ul = this.container.querySelector(".thumbnails"); let thumbnail_element = ul.querySelector("[data-id=\\"" + helpers.escape_selector(media_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 media_id = thumbnail_element.dataset.id; if(media_id == null) return; // Get thumbnail info. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_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; } // Force all thumbnails to refresh after the mute list changes, to refresh mutes. refresh_after_mute_change = () => { for(let thumb of this.get_loaded_thumbs()) thumb.dataset.pending = true; this.set_visible_thumbs(); // Refresh the user ID-dependant UI so we refresh the mute/unmute button. this.refresh_ui_for_user_id(); } // 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_nearby_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_loaded_thumbs() { return this.container.querySelectorAll(\`.thumbnails > [data-id]:not([data-special])\`); } // Create a thumb placeholder. This doesn't load the image yet. // // media_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(media_id, search_page) { let entry = null; if(media_id == "special:previous-page") { entry = this.create_template({ name: "load-previous-results", html: \` \`}); } else { entry = this.create_template({ name: "template-thumbnail", html: \` \`}); } // If this is a non-thumb entry, mark it so we ignore it for "nearby thumb" handling, etc. if(media_id == "special:previous-page") entry.dataset.special = 1; // Mark that this thumb hasn't been filled in yet. entry.dataset.pending = true; entry.dataset.id = media_id; if(search_page != null) entry.dataset.searchPage = search_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 media_id if it's available. This is called when we display the thumbnail view // after coming from an illustration. scroll_to_media_id(media_id) { var thumb = this.container.querySelector("[data-id='" + helpers.escape_selector(media_id) + "']"); if(thumb == null) return false; // If the item isn't visible, center it. let scroll_pos = this.scroll_container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.scroll_container.offsetHeight) this.scroll_container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.scroll_container.offsetHeight/2; return true; }; pulse_thumbnail(media_id) { let thumb = this.container.querySelector("[data-id='" + helpers.escape_selector(media_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); } handle_onkeydown(e) { // Pressing ^F while on the local search focuses the search box. if(this.data_source.name == "vview" && e.key.toUpperCase() == "F" && e.ctrlKey) { this.container.querySelector(".local-tag-search-box input").focus(); e.preventDefault(); e.stopPropagation(); } } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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); this.scroll_container = this.container.querySelector(".search-results"); this.scroll_container.addEventListener("scroll", (e) => { this.schedule_store_scroll_position(); }, { passive: true, }); // 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, }); 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); // Zoom the thumbnails on ctrl-mousewheel: this.container.addEventListener("wheel", (e) => { if(!e.ctrlKey) return; e.preventDefault(); e.stopImmediatePropagation(); settings.adjust_zoom("manga-thumbnail-size", e.deltaY > 0); }, { passive: false }); this.container.addEventListener("keydown", (e) => { let zoom = helpers.is_zoom_hotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); settings.adjust_zoom("manga-thumbnail-size", zoom < 0); } }); // 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, { media_id, old_media_id }) { if(this._active == active && this.media_id == media_id) return; let was_active = this._active; this._active = active; if(this.media_id != media_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.media_id = media_id; this.illust_info = null; this.ui.media_id = media_id; // Refresh even if media_id is null, so we quickly clear the screen. await this.refresh_ui(); } if(this._active && !active) { // 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.media_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(old_media_id); // Focus the container, so it receives keyboard events like home/end. this.container.querySelector(".search-results").focus(); } async async_set_image(old_media_id) { console.log("Loading manga screen for:", this.media_id); // Load image info. let media_id = this.media_id; var illust_info = await image_data.singleton().get_media_info(this.media_id); if(media_id != this.media_id) return; this.illust_info = illust_info; await this.refresh_ui(); this.restore_scroll_pos(old_media_id) } 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); let [css, column_count] = 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, }); this.thumbnail_dimensions_style.textContent = css; 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); 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_media_id() { return this.media_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]; let id = helpers.parse_media_id(this.media_id); id.page = page_idx; let page_media_id = helpers.encode_media_id(id); let link = element.querySelector("a.thumbnail-link"); link.dataset.mediaId = page_media_id; element.dataset.id = page_media_id; link.href = helpers.get_url_for_id(page_media_id, page_idx+1); // 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; return element; } restore_scroll_pos(old_media_id) { // Handle scrolling for the new state. if(old_media_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. if(this.scroll_to_media_id(old_media_id)) { console.log("Restored scroll position to:", old_media_id); return; } } let args = helpers.args.location; if(args.state.scroll?.scroll_position == null) { console.log("Scroll to top for new search"); this.container.scrollTop = 0; return; } // Restore the scroll position from history. this.scroll_container.scrollTop = args.state.scroll.scroll_position; } // Schedule storing the scroll position, resetting the timer if it's already running. schedule_store_scroll_position() { if(this.scroll_position_timer != -1) { clearTimeout(this.scroll_position_timer); this.scroll_position_timer = -1; } this.scroll_position_timer = setTimeout(() => { this.store_scroll_position(); }, 100); } // Save the current scroll position, so it can be restored from history. store_scroll_position() { let args = helpers.args.location; args.state.scroll = { scroll_position: this.scroll_container.scrollTop, }; console.log(args.state); helpers.set_page_url(args, false, "viewing-page", { send_popstate: false }); } scroll_to_media_id(media_id) { if(media_id == null) return false; let thumb = this.container.querySelector(\`[data-id="\${helpers.escape_selector(media_id)}"]\`); if(thumb == null) return false; // If the item isn't visible, center it. let scroll_pos = this.scroll_container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.scroll_container.offsetHeight) this.scroll_container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.scroll_container.offsetHeight/2; return true; } handle_onkeydown(e) { this.ui.handle_onkeydown(e); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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({ contents: 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, contents: 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, contents: this.container.querySelector(".button-like"), }); this.like_count_widget = new like_count_widget({ parent: this, contents: this.container.querySelector(".button-like .count"), }); this.bookmark_count_widget = new bookmark_count_widget({ parent: this, contents: this.container.querySelector(".button-bookmark .count"), }); // The bookmark buttons, and clicks in the tag dropdown: this.bookmark_buttons = []; for(let a of this.container.querySelectorAll("[data-bookmark-type]")) this.bookmark_buttons.push(new bookmark_button_widget({ parent: this, contents: a, bookmark_type: a.dataset.bookmarkType, 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)); this.container.querySelector(".preferences-button").addEventListener("click", (e) => { new ppixiv.settings_dialog({ container: document.body }); }); } 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 media_id() { return this._media_id; } set media_id(media_id) { if(this._media_id == media_id) return; this._media_id = media_id; this.illust_data = null; this.refresh(); } get displayed_page() { return helpers.parse_media_id(this._media_id).page; } 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_media_id(this._media_id); this.bookmark_tag_widget.set_media_id(this._media_id); this.toggle_tag_widget.set_media_id(this._media_id); this.like_count_widget.set_media_id(this._media_id); this.bookmark_count_widget.set_media_id(this._media_id); for(let button of this.bookmark_buttons) button.set_media_id(this._media_id); this.illust_data = null; if(this._media_id == null) return; // We need image info to update. let media_id = this._media_id; let illust_info = await image_data.singleton().get_media_info(this._media_id); // Check if anything changed while we were loading. if(illust_info == null || media_id != this._media_id || !this.visible) return; this.illust_data = illust_info; let [illust_id] = helpers.media_id_to_illust_id_and_page(this._media_id); 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 = helpers.get_url_for_id(this._media_id); // If the author name is empty, hide it instead of leaving it empty. this.container.querySelector(".author-block").hidden = illust_info.userName == ""; var element_author = this.container.querySelector(".author"); if(illust_info.userName != "") 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)); 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.ugoiraMetadata) { 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.ugoiraMetadata? (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); } 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._media_id, this.progress_bar.controller(), download_type, this.displayed_page); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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(".input-field-container > input"); this.dropdown_widget = new tag_search_dropdown_widget({ container: this.container, input_element: this.container, }); this.edit_widget = new tag_search_edit_widget({ container: this.container, input_element: this.container, }); this.container.addEventListener("mouseenter", this.container_onmouseenter); this.container.addEventListener("mouseleave", this.container_onmouseleave); this.input_element.addEventListener("focus", this.input_onfocus); this.input_element.addEventListener("blur", this.input_onblur); let edit_button = this.container.querySelector(".edit-search-button"); if(edit_button) { edit_button.addEventListener("click", (e) => { // Toggle the edit widget, hiding the search history dropdown if it's shown. if(this.dropdown_widget.shown) this.hide(); if(this.edit_widget.shown) this.hide(); else this.show_edit(); }); } // Search submission: helpers.input_handler(this.input_element, this.submit_search); this.container.querySelector(".search-submit-button").addEventListener("click", this.submit_search); // Hide the dropdowns on navigation. new view_hidden_listener(this.input_element, (e) => { this.hide(); }); } async show_history() { // Don't show history if search editing is already open. if(this.edit_widget.shown) return; this.dropdown_widget.show(); } show_edit() { // Don't show search editing if history is already open. if(this.dropdown_widget.shown) return; this.edit_widget.show(); // Disable adding searches to search history while the edit dropdown is open. Otherwise, // every time a tag is toggled, that combination of tags is added to search history by // data_source_search, which makes a mess. helpers.disable_adding_search_tags(true); } hide() { helpers.disable_adding_search_tags(false); this.dropdown_widget.hide(); this.edit_widget.hide(); } container_onmouseenter(e) { this.mouse_over_parent = true; } container_onmouseleave(e) { this.mouse_over_parent = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } // Show the dropdown when the input is focused. Hide it when the input is both // unfocused and this.container isn't being hovered. This way, the input focus // can leave the input box to manipulate the dropdown without it being hidden, // but we don't rely on hovering to keep the dropdown open. input_onfocus(e) { this.input_focused = true; if(!this.dropdown_widget.shown && !this.edit_widget.shown) this.show_history(); } input_onblur(e) { this.input_focused = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } submit_search(e) { // This can be sent to either the search page search box or the one in the // navigation dropdown. Figure out which one we're on. var search_box = e.target.closest(".search-box"); var tags = this.input_element.value.trim(); if(tags.length == 0) return; // Add this tag to the recent search list. helpers.add_recent_search_tag(tags); // If we're submitting by pressing enter on an input element, unfocus it and // close any widgets inside it (tag dropdowns). if(e.target instanceof HTMLInputElement) { e.target.blur(); view_hidden_listener.send_viewhidden(e.target); } // Run the search. helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(tags, ppixiv.location), true); } } ppixiv.tag_search_dropdown_widget = class 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); // Find the . this.input_element = input_element.querySelector("input"); 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; helpers.set_max_height(this.input_dropdown); } 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.querySelector("input"); // 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/r126/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(media_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 media_id of media_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(media_id); if(thumb_info == null) continue; data[media_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 media_ids for recently viewed illusts, most recent first. async get_recent_media_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 media IDs if we have it. async get_thumbnail_info(media_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 media_id of media_ids) promises[media_id] = key_storage.async_store_get(store, media_id); await Promise.all(Object.values(promises)); let results = []; for(let media_id of media_ids) { let entry = await promises[media_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; } // Clear history. async clear() { return await this.db.clear(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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="en") { 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/r126/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 media_ids have been loaded, or are currently loading. // // We won't start fetching IDs that aren't loaded. are_all_media_ids_loaded_or_loading(media_ids) { for(let media_id of media_ids) { media_id = helpers.get_media_id_first_page(media_id); if(this.thumbnail_data[media_id] == null && !this.loading_ids[media_id]) return false; } return true; } is_media_id_loaded_or_loading(media_id) { media_id = helpers.get_media_id_first_page(media_id); if(helpers.is_media_id_local(media_id) && local_api.is_media_id_loading(media_id)) return true; return this.thumbnail_data[media_id] != null || this.loading_ids[media_id]; } // Return thumbnail data for media_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(media_id) { media_id = helpers.get_media_id_first_page(media_id); return this.thumbnail_data[media_id]; } // Return thumbnail data for media_ids, and start loading any requested IDs that aren't // already loaded. get_thumbnail_info(media_ids) { var result = {}; var needed_media_ids = []; let local_media_ids = []; for(let media_id of media_ids) { media_id = helpers.get_media_id_first_page(media_id); let data = this.thumbnail_data[media_id]; if(data == null) { // Only load illust IDs. let { type } = helpers.parse_media_id(media_id); if(helpers.is_media_id_local(media_id)) { local_media_ids.push(media_id); continue; } if(type != "illust") continue; needed_media_ids.push(media_id); continue; } result[media_id] = data; } // If any of these are local IDs, load them with local_api. if(local_media_ids.length) local_api.load_media_ids(local_media_ids); // Load any thumbnail data that we didn't have. if(needed_media_ids.length) this.load_thumbnail_info(needed_media_ids); return result; } // Load thumbnail info for the given list of IDs. async load_thumbnail_info(media_ids) { // Make a list of IDs that we're not already loading. let illust_ids_to_load = []; for(let media_id of media_ids) { media_id = helpers.get_media_id_first_page(media_id); if(this.loading_ids[media_id] != null) continue; illust_ids_to_load.push(helpers.parse_media_id(media_id).id); this.loading_ids[media_id] = true; } if(illust_ids_to_load.length == 0) return; // 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: illust_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); let media_id = helpers.illust_id_to_media_id(thumb_info.id); delete this.loading_ids[media_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) { let media_id = helpers.illust_id_to_media_id(thumb_info.id); this.thumbnail_data[media_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(source_data, source) { let data = null; let id = source_data.userId; if(source == "following") { data = { userId: source_data.userId, userName: source_data.userName, profileImageUrl: source_data.profileImageUrl, }; } else if(source == "recommendations") { data = { userId: source_data.userId, userName: source_data.name, profileImageUrl: source_data.imageBig, }; } else if(source == "users_bookmarking_illust" || source == "user_search") { data = { userId: source_data.user_id, userName: source_data.user_name, profileImageUrl: source_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 illust info or thumbnail data, whicihever is available. If we don't have // either, read full illust info. If we have both, return illust info. // // 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. async get_or_load_illust_data(media_id) { // First, see if we have full illust info. Prefer to use it over thumbnail info // if we have it, so full info is available. If we don't, see if we have thumbnail // info. let data = image_data.singleton().get_media_info_sync(media_id); if(data == null) data = thumbnail_data.singleton().get_one_thumbnail_info(media_id); // If we don't have either, load the image info. if(data == null) data = await image_data.singleton().get_media_info(media_id); this._check_illust_data(data); return data; } // A sync version of get_or_load_illust_data. This doesn't load data if it // isn't available. get_illust_data_sync(media_id) { // First, see if we have full illust info. Prefer to use it over thumbnail info // if we have it, so full info is available. If we don't, see if we have thumbnail // info. let data = image_data.singleton().get_media_info_sync(media_id); if(data == null) data = thumbnail_data.singleton().get_one_thumbnail_info(media_id); this._check_illust_data(data); return data; } // Check the result of get_or_load_illust_data. We always expect all keys in // thumbnail_info_keys to be included, regardless of where the data came from. _check_illust_data(illust_data) { if(illust_data == null) return; for(let key of this.thumbnail_info_keys) { if(!(key in illust_data)) { console.warn(\`Missing key \${key} for early data\`, illust_data); continue; } } } // 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(media_id, data) { media_id = helpers.get_media_id_first_page(media_id); 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(media_id); if(thumb_data) update_data(thumb_data, this.thumbnail_info_keys); let illust_info = image_data.singleton().get_media_info_sync(media_id); if(illust_info != null) update_data(illust_info, this.thumbnail_info_keys); image_data.singleton().call_illust_modified_callbacks(media_id); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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; let media_id = helpers.illust_id_to_media_id(this.illust_info.illustId, new_page); main_controller.singleton.show_media(media_id); /* 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); let media_id = helpers.illust_id_to_media_id(new_illust_id, new_page); main_controller.singleton.show_media(media_id); 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/r126/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 if(url.pathname.startsWith("/request/complete")) return data_sources.completed_requests; else if(url.pathname.startsWith(local_api.path)) return data_sources.vview; 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(ppixiv.native) return true; // 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.is_ppixiv_url(ppixiv.location) && 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/r126/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/r126/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 media_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. // 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; console.assert(url); } // Start the fetch. This should only be called once. async start() { if(this.url == null) return; 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; console.assert(url); } async start() { if(this.url == null) return; 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 media_id the user is currently viewing. If media_id is null, the user isn't // viewing an image (eg. currently viewing thumbnails). async set_current_image(media_id) { if(this.current_media_id == media_id) return; this.current_media_id = media_id; this.current_illust_info = null; await this.guess_preload(media_id); if(this.current_media_id == null) return; // Get the image data. This will often already be available. let illust_info = await image_data.singleton().get_media_info(this.current_media_id); // Stop if the illust was changed while we were loading. if(this.current_media_id != media_id) return; // Store the illust_info for current_media_id. this.current_illust_info = illust_info; this.check_fetch_queue(); } // Set the media_id we want to speculatively load, which is the next or previous image in // the current search. If media_id is null, we don't want to speculatively load anything. async set_speculative_image(media_id) { if(this.speculative_media_id == media_id) return; this.speculative_media_id = media_id; this.speculative_illust_info = null; if(this.speculative_media_id == null) return; // Get the image data. This will often already be available. let illust_info = await image_data.singleton().get_media_info(this.speculative_media_id); if(this.speculative_media_id != media_id) return; // Stop if the illust was changed while we were loading. if(this.speculative_media_id != media_id) return; // Store the illust_info for current_media_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_media_id)); if(this.speculative_illust_info != null) wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info, this.speculative_media_id)); // 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, media_id) { // 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 an animation, preload the ZIP. if(illust_data.illustType == 2 && !helpers.is_media_id_local(media_id)) { 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 this is a video, preload the poster. if(illust_data.illustType == "video") return [new img_preloader(illust_data.mangaPages[0].urls.poster) ]; // 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. let page = helpers.parse_media_id(media_id).page; if(page < illust_data.mangaPages.length) results.push(new img_preloader(illust_data.mangaPages[page].urls.original)); // Preload the remaining pages. for(let p = 0; p < illust_data.mangaPages.length; ++p) { if(p == page) continue; results.push(new img_preloader(illust_data.mangaPages[p].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 media_id is null, stop any running guessed preload. async guess_preload(media_id) { // 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(media_id != null) { guessed_url = await guess_image_url.get.guess_url(media_id); 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(media_id); }); this.guessed_preload.start(); } } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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: 126, text: \` Muted tags and users can now be edited from the preferences menu.

Any number of tags can be muted. If you don't have Premium, mutes will be saved to the browser instead of to your Pixiv account. \`, }, { version: 123, text: \` Added support for viewing completed requests.

Disabled light mode for now. It's a pain to maintain two color schemes and everyone is probably using dark mode anyway. If you really want it, let me know on GitHub. \`, }, { version: 121, text: \` Added a slideshow mode. Click wallpaper at the top.

Added an option to pan images as they're viewed.

Double-clicking images now toggles fullscreen.

The background is now fully black when viewing an image, for better contrast. Other screens are still dark grey.

Added an option to bookmark privately by default, such as when bookmarking by selecting a bookmark tag.

Reworked the animation UI. \`, }, { 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.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; }); // 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/r126/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 messages = new EventTarget(); 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(media_id, 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(media_id); let illust_data = image_data.singleton().get_media_info_sync(media_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, media_id: media_id, 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; let event = new Event(data.message); event.message = data; this.messages.dispatchEvent(event); // 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_media(data.media_id, { 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 media_id = screen? screen.displayed_media_id:null; let thumbnail_info = media_id? thumbnail_data.singleton().get_one_thumbnail_info(media_id):null; let illust_data = media_id? image_data.singleton().get_media_info_sync(media_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, media_id: media_id, // 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: \` \`}); // 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; }); // Refresh the "unlink all tabs" button when the linked tab list changes. settings.changes.addEventListener("linked_tabs", this.send_link_tab_message.bind(this), { signal: this.shutdown_signal.signal }); // The other tab will send these messages when the link and unlink buttons // are clicked. SendImage.messages.addEventListener("link-this-tab", (e) => { let message = e.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(); }, { signal: this.shutdown_signal.signal }); SendImage.messages.addEventListener("unlink-this-tab", (e) => { let message = e.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; } send_link_tab_message = () => { 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.media_id, [tab_id], "display"); this.visible = false; }); this.visible = false; } show_for_illust(media_id) { this.media_id = media_id; 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/r126/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(window?.GM_info?.scriptHandler == "Greasemonkey") { console.info("ppixiv doesn't work with GreaseMonkey. GreaseMonkey hasn't been updated in a long time, try TamperMonkey instead."); return; } // 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(); new ppixiv.global_key_listener; // If we're running natively, set the initial URL. await local_api.set_initial_url(); // 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 UI when running native or not native. helpers.set_class(document.body, "native", ppixiv.native); helpers.set_class(document.body, "pixiv", !ppixiv.native); // 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; // 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(!ppixiv.native && !helpers.is_ppixiv_url(ppixiv.location)) { // 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; // Load image resources into blobs. await this.load_resource_blobs(); // Add the blobs for binary resources as CSS variables. helpers.add_style("image-styles", \` body { --dark-noise: url("\${resources['resources/noise.png']}"); --light-noise: url("\${resources['resources/noise-light.png']}"); } \`); // Add the main CSS style. helpers.add_style("main", resources['resources/main.scss']); // Create the page from our HTML resource. let font_link = document.createElement("link"); font_link.href = "https://fonts.googleapis.com/icon?family=Material+Icons"; document.head.appendChild(font_link); font_link.rel = "stylesheet"; 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({ contents: this.container.querySelector(".screen-search-container") }); this.screen_illust = new screen_illust({ contents: this.container.querySelector(".screen-illust-container") }); this.screen_manga = new screen_manga({ contents: 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) { // 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); // Screens store their scroll position in args.state.scroll. On refresh, clear it // so we scroll to the top when we refresh. let args = helpers.args.location; delete args.state.scroll; helpers.set_page_url(args, false, "refresh-data-source", { send_popstate: false }); 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_media_id = old_screen? old_screen.displayed_media_id: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 wanted_page = this.data_source.get_start_page(helpers.args.location); if(!data_source.can_load_page(wanted_page)) { // This works the same as refresh_current_data_source above. console.log("Resetting data source because it can't load the requested page", wanted_page); data_source = page_manager.singleton().create_data_source_for_url(ppixiv.location, true); } } // 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"); // If the data source is changing, set it up. if(this.data_source != data_source) { console.log("New data source. Screen:", new_screen_name, "Cause:", cause); 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(); } else console.log("Same data source. Screen:", new_screen_name, "Cause:", cause); // Update the media ID with the current manga page, if any. let media_id = data_source.get_current_media_id(); let id = helpers.parse_media_id(media_id); id.page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0; media_id = helpers.encode_media_id(id); // If we're on search, we don't care what image is current. Clear media_id so we // tell context_menu that we're not viewing anything, so it disables bookmarking. if(new_screen_name == "search") media_id = null; // 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_media_id(media_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; // Dismiss any message when toggling between screens. if(screen_changing) message_widget.singleton.hide(); // 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, media_id: media_id, // Let the screen know what ID we were previously viewing, if any. old_media_id: old_media_id, restore_history: restore_history, }); } } 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_media(media_id, {add_to_history=false, screen="illust", temp_view=false, source=""}={}) { console.assert(media_id != null, "Invalid illust_id", media_id); let args = helpers.args.location; // Check if this is a local ID. if(helpers.is_media_id_local(media_id)) { // If we're told to show a folder: ID, always go to the search page, not the illust page. if(helpers.parse_media_id(media_id).type == "folder") screen = "search"; } // Update the URL to display this media_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_media_id(media_id, args); // Remove any leftover page from the current illust. We'll load the default. let [illust_id, page] = helpers.media_id_to_illust_id_and_page(media_id); 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) { // If this is the default, just remove it. if(screen == this.data_source.default_screen) args.hash.delete("view"); else 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; // We're taking the place of the default behavior. If somebody called preventDefault(), // stop. if(e.defaultPrevented) 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); if(!helpers.is_ppixiv_url(url)) return; // Stop all handling for this link. e.preventDefault(); e.stopImmediatePropagation(); // If this is a link to an image (usually /artworks/#), navigate to the image directly. // This way, we actually use the URL for the illustration on this data source instead of // switching to /artworks. This also applies to local image IDs, but not folders. var url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); let illust = this.get_illust_at_element(a); if(illust?.media_id) { let media_id = illust.media_id; let args = new helpers.args(a.href); let screen = args.hash.has("view")? args.hash.get("view"):"illust"; this.show_media(media_id, { screen: screen, 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() { console.assert(!ppixiv.native); // Doing this sync works better, because it console.log("Reloading page to get init data"); // /local is used as a placeholder path for the local API, and it's a 404 // on the actual page. It doesn't have global data, so load some other arbitrary // page to get it. let url = document.location; if(url.pathname.startsWith('/local')) url = new URL("/discovery", url); // 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(url.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) { // When running locally, just load stub data, since this isn't used. if(ppixiv.native) { this.init_global_data("no token", "no id", true, [], 2); return true; } // 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.pixiv_muted_tags = muted_tags; muting.singleton.pixiv_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.key == "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 media ID and the user ID on it. let media_element = element.closest("[data-media-id]"); if(media_element) result.media_id = media_element.dataset.mediaId; let user_element = element.closest("[data-user-id]"); if(user_element) result.user_id = 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); this.refresh_disabled_ui(disabled_ui); document.body.appendChild(disabled_ui); // Newer Pixiv pages update the URL without navigating, so refresh our button with the current // URL. We should be able to do this in popstate, but that API has a design error: it isn't // called on pushState, only on user navigation, so there's no way to tell when the URL changes. // This results in the URL changing when it's clicked, but that's better than going to the wrong // page. disabled_ui.addEventListener("focus", (e) => { this.refresh_disabled_ui(disabled_ui); }, true); window.addEventListener("popstate", (e) => { this.refresh_disabled_ui(disabled_ui); }, true); 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); }); } }; refresh_disabled_ui(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. Otherwise, replace the hash with #ppixiv. console.log(ppixiv.location.toString()); if(page_manager.singleton().available_for_url(ppixiv.location)) { let url = ppixiv.location; url.hash = "#ppixiv"; disabled_ui.querySelector("a").href = url; } else disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv"; } }; //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r126/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({});