// ==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 137 // @downloadURL none // ==/UserScript== (function() { const ppixiv = this; with(this) { ppixiv.version = "137"; 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/auth.html"] = `
`; 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"] = `* { box-sizing: border-box; } html { overflow: hidden; --window-height: calc(100%); --window-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom)); --window-width: calc(100%); --window-width: calc(100% + env(safe-area-inset-left) + env(safe-area-inset-right)); height: var(--window-height); width: var(--window-width); } body { font-family: "Helvetica Neue", arial, sans-serif; } a { text-decoration: none; /*color: #fff;*/ color: inherit; } /* Theme colors: */ 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; --box-link-selected-hover-color: #338; /* 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; } body[data-theme="dark"][data-current-view="illust"] { --background-noise: ""; } 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; } ul { padding: 0; margin: 0; } .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; } .screen-illust-container { width: 100%; height: 100%; } .view-container { position: absolute; top: 0; left: 0; width: var(--window-width); height: var(--window-height); user-select: none; cursor: pointer; } .viewer-images { width: 100%; height: 100%; } .viewer-images > .image-box.cropping { overflow: hidden; } .viewer-images > .image-box img { will-change: transform; } .viewer-ugoira, .viewer-video { width: 100%; height: 100%; } .viewer-ugoira > .video-container, .viewer-video > .video-container { width: 100%; height: 100%; } [hidden] { display: none !important; } 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. */ .grecaptcha-badge { display: none !important; } .main-container { position: fixed; top: 0px; left: 0px; width: var(--window-width); height: var(--window-height); overflow: hidden; background-color: var(--main-background-color); background-image: var(--background-noise); } .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; } } .progress-bar.hide { animation: flash-progress-bar 500ms linear 1 forwards; } .loading-progress-bar .progress-bar { z-index: 100; } .video-ui { position: absolute; bottom: 0px; left: 0px; width: 100%; user-select: none; transition: transform .25s, opacity .25s; opacity: 0; } .video-ui .seek-bar { width: 100%; box-sizing: content-box; height: 12px; cursor: pointer; position: relative; } .video-ui .seek-bar[data-position="top"] { padding-top: 25px; } .video-ui .seek-bar[data-position="top"] > .seek-parts { transition: transform .25s; transform: scale(100%, 0%); transform-origin: bottom; } .video-ui .seek-bar[data-position="bottom"] { height: 4px; } .video-ui .seek-bar[data-position="bottom"] > .seek-parts > [data-seek-part="empty"] { background-color: rgba(0, 0, 0, 0.5); } .video-ui .seek-bar.dragging > .seek-parts { transform: scale(100%, 100%) !important; } .video-ui .seek-bar > .seek-parts { width: 100%; height: 100%; } .video-ui .seek-bar > .seek-parts > [data-seek-part] { height: 100%; position: absolute; left: 0; top: 0; } .video-ui .seek-bar > .seek-parts > [data-seek-part="fill"] { background-color: #F00; } .video-ui .seek-bar > .seek-parts > [data-seek-part="loaded"] { background-color: #A00; } .video-ui .seek-bar > .seek-parts > [data-seek-part="empty"] { background-color: rgba(0, 0, 0, 0.25); width: 100%; } .mouse-hidden-box.cursor-active .video-ui, .video-ui.dragging, .video-ui:hover { opacity: 1; } .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%); } .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; } .video-ui > .video-ui-strip .button { cursor: pointer; } .video-ui > .video-ui-strip > .time { font-family: Roboto,Arial,Helvetica,sans-serif; font-size: 1.2em; } .video-ui > .video-ui-strip .volume-slider { width: 100px; height: 100%; display: flex; flex-direction: row; align-items: center; margin-right: -10px; } .video-ui > .video-ui-strip .volume-slider > .volume-line { height: 4px; width: 100px; flex: 1; } .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; } .small-font { font-size: 0.8em; } .hover-message, .search-results > .no-results { width: 100%; position: absolute; bottom: 0px; display: flex; justify-content: center; } .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; } .hover-message { transition: opacity .25s; opacity: 0; pointer-events: none; z-index: 100000; /* over everything */ } .hover-message.show { opacity: 1; } /* The version in the search container is always centered. */ .search-results > .no-results { bottom: 50%; } .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; } .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. */ .hover-box { width: 400px; height: 200px; position: absolute; top: 0; left: 0; pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } .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; } .hover-sphere circle { pointer-events: auto; /* reenable pointer events that are disabled on .ui */ } .hover-sphere > svg { width: 100%; height: 100%; transform: translate(-50%, -50%); } .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; } .ui-box .author { vertical-align: top; } .ui-box:not(.visible-widget) { display: inherit !important; } .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: */ } body:not(.hide-ui) .screen-illust-container .ui-box.visible-widget { opacity: 1; transform: translate(0, 0); pointer-events: auto; } body.force-ui .screen-illust-container .ui-box { opacity: 1; transform: translate(0, 0); pointer-events: inherit; } .ui-box .button > svg { display: block; } .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; } .button-row { display: flex; flex-direction: row; align-items: center; height: 32px; margin-top: 5px; margin-bottom: 4px; } .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. */ .title-with-button-row { display: flex; flex-direction: row; align-items: start; } /* An icon in a button strip. */ .icon-button { display: block; width: 32px; height: auto; /* If this is an icon-button with an svg inside, set the svg to block. */ } .icon-button svg { display: block; } .disable-ui-button:hover > .icon-button { color: #0096FA; } .whats-new-button.updates > svg { color: #cc0; } body[data-theme="light"] .whats-new-button.updates > svg { color: #0aa; /* yellow doesn't work in a light theme */ } .navigate-out-button { cursor: pointer; } .menu-slider input { vertical-align: middle; width: 100%; padding: 0; margin: 0; cursor: pointer; } .popup.avatar-popup:hover:after { left: auto; bottom: auto; top: 60px; right: -10px; } .avatar-widget-follow-container { position: relative; /* For the avatar in the popup menu, use the same size as the other popup menu buttons. */ } .avatar-widget-follow-container .avatar { transition: filter .25s; display: block; position: relative; filter: contrast(1); transition: filter .25s; border-radius: 5px; object-fit: cover; } .avatar-widget-follow-container .avatar.loaded { box-shadow: 0 0 10px 4px #000; } .avatar-widget-follow-container .avatar:hover { filter: contrast(1.3); } .avatar-widget-follow-container:not(.big) .avatar { width: 50px; height: 50px; } .avatar-widget-follow-container.big .avatar { width: 170px; height: 170px; } .avatar-widget-container .avatar-widget-follow-container .avatar { width: 44px; height: 44px; } .avatar-widget-follow-container .follow-box { position: absolute; top: 100%; } .avatar-widget-follow-container:not(.big) .follow-box { top: calc(100% + 10px); } .avatar-widget-follow-container.loading { visibility: hidden; pointer-events: none; } .avatar-widget-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 */ right: 0; } .avatar-widget-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%; } .avatar-widget-follow-container .follow-icon > svg .middle { transition: transform .1s ease-in-out; transform: translate(0px, -2px); } .avatar-widget-follow-container .avatar-link { display: block; } .follow-container { display: flex; flex-direction: column; background-color: var(--frame-bg-color); border: 1px solid var(--frame-border-color); padding: .25em; overflow-y: auto; } .follow-container .separator { width: 100%; background-color: #fff; margin: 2px 0; } .follow-container .separator > div { height: 2px; } .follow-container .material-icons { margin-right: 8px; } .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; } .title-block.popup:hover:after { top: 40px; bottom: auto; } /* When .dot is set, show images with nearest neighbor filtering. */ body.dot img.filtering, body.dot canvas.filtering { image-rendering: crisp-edges; image-rendering: pixelated; } .bulb-button:hover > .icon-button { color: #FF0 !important; /* override grey-icon hover color */ } body[data-theme="light"] .bulb-button:hover > .icon-button { stroke: #000; } .bulb-button > .icon-button { margin-top: -3px; } .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; } .description { border: solid 1px var(--ui-border-color); padding: .35em; background-color: var(--ui-bg-section-color); max-height: 10em; overflow-y: auto; } body[data-theme="light"] .description { border: none; } /* Override obnoxious colors in descriptions. Why would you allow this? */ .description * { color: var(--ui-fg-color); } .popup { position: relative; } .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; } .popup-bottom:hover:after { top: auto; bottom: -2em; } body:not(.premium) .premium-only { display: none; } body:not(.native) .native-only { display: none; } body:not(.pixiv) .pixiv-only { display: none; } body.hide-r18 .r18 { display: none; } body.hide-r18g .r18g { display: none; } .popup-menu-box { position: absolute; overflow-y: auto; 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; } .popup-menu-box.hover-menu-box { visibility: hidden; } .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. */ .hover-area { position: absolute; top: -50%; left: -33%; width: 150%; height: 200%; z-index: -1; } .popup-menu-box .button { padding: .25em; cursor: pointer; width: 100%; } .popup-menu-box .button:hover { background-color: var(--dropdown-menu-hover-color); } .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. */ } 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; } 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. */ body.ui-on-hover .top-ui-box.hover, body.ui-on-hover .top-ui-box.force-open { transform: translateY(100%); } 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. */ body.ui-on-hover .top-ui-box + .top-ui-box-padding { height: 30px; } .search-screen { position: absolute; width: var(--window-width); height: var(--window-height); top: 0; left: 0; color: #fff; display: flex; flex-direction: row; } .search-screen .search-results { position: relative; width: 100%; height: 100%; overflow-x: hidden; overflow-y: scroll; } .search-screen .search-results:focus { outline: none; } .search-results { /* .thumbnails is the actual thumbnail list. */ } .search-results .thumbnail-ui-box-container { width: 50%; min-width: calc(min(800px, 100%)); padding: 0 15px; } .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; } .search-results .thumbnail-ui-box .disable-ui-button { margin-right: 2px; } .search-results .thumbnail-ui-box .disable-ui-button > svg { width: 22px; } .search-results .thumbnail-ui-box .displaying { padding-bottom: 4px; } .search-results .thumbnail-ui-box .displaying .word { padding: 0px 5px; } .search-results .thumbnail-ui-box .displaying .word.paren { font-weight: 400; } .search-results .thumbnail-ui-box .displaying .word:first-child { padding-left: 0px; /* remove left padding from the first item */ } .search-results .thumbnail-ui-box .displaying .word.or { font-size: 12px; padding: 0; color: #bbb; } .search-results .thumbnail-ui-box .clear-local-search .icon { transform: scale(-1, 1); } .search-results .thumbnail-ui-box .user-links .default-icon { transform: translate(0, 2px); } .search-results .thumbnail-ui-box .user-links .twitch-icon { margin: 4px 4px 0 4px; } .search-results .thumbnail-ui-box .user-links .bookmarks-link, .search-results .thumbnail-ui-box .user-links .following-link { width: 32px; height: 32px; } .search-results .thumbnail-ui-box .user-links .contact-link { width: 31px; height: 31px; margin: 0 3px; } .search-results .thumbnail-ui-box .user-links .webpage-link { margin: 0 2px; width: 26px; height: 26px; } .search-results .thumbnail-ui-box .user-links .circlems-icon { margin: 2px 0 0 0; } .search-results .thumbnail-ui-box .user-links .fanbox-icon [inkscape\\:label="edge"], .search-results .thumbnail-ui-box .user-links .fanbox-icon [inkscape\\:label="ear-left"], .search-results .thumbnail-ui-box .user-links .fanbox-icon [inkscape\\:label="ear-right"], .search-results .thumbnail-ui-box .user-links .fanbox-icon [inkscape\\:label="hand-right"] { fill: currentColor !important; } .search-results .thumbnails { user-select: none; padding: 0; text-align: center; gap: var(--thumb-padding); max-width: var(--container-width); } .search-results .thumbnails { display: flex; flex-wrap: wrap; justify-content: center; margin: 0 auto; /* center */ } .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%); } } .search-results .last-viewed-image-marker { position: absolute; left: 0; top: 0; pointer-events: none; height: auto; width: calc(var(--thumb-width)/4); } .search-results .thumbnail-box:not(.flash) .last-viewed-image-marker { display: none; } .thumbnail-load-previous { width: 100%; } .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; } .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); } .thumbnail-load-previous > .load-previous-buttons > .load-previous-button:hover { background-color: var(--box-link-hover-color); } .thumbnail-box.expanded-thumb a.thumbnail-link { border-bottom: 10px solid #a0a; border-bottom-width: 10px; border-bottom-style: solid; } .thumbnail-box.expanded-thumb a.thumbnail-link.first-page { border-bottom-left-radius: 30px; } .thumbnail-box.expanded-thumb a.thumbnail-link.last-page { border-bottom-right-radius: 30px; } .screen-search-container .thumbnail-box.expanded-manga-post:not(:hover):not(.first-manga-page) .manga-info-box { display: none; } .screen-search-container .thumbnail-box:not(.first-manga-page) .show-manga-pages-button { display: none !important; } .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; } .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; } .screen-search-container .thumbnail-box .bottom-row .bottom-right-icon { justify-content: end; } .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; } .screen-search-container .thumbnail-box .bottom-row .thumbnail-label .thumbnail-ellipsis-box { text-overflow: ellipsis; overflow: hidden; } .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; } .screen-search-container .thumbnail-box .bottom-row .thumbnail-label .ugoira-icon { color: #fff; transition: opacity .5s; } .screen-search-container .thumbnail-box .bottom-row .heart { width: 32px; height: 32px; } .screen-search-container .thumbnail-box .bottom-row .heart > svg { transition: opacity .5s; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box { display: inline-flex; align-items: center; gap: 4px; padding: 0 4px; background-color: rgba(0, 0, 0, 0.6); border-radius: 6px; transition: opacity .5s; white-space: nowrap; pointer-events: auto; border-radius: 6px; overflow: hidden; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .show-manga-pages-button { display: inline-flex; padding: 4px 0px; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .show-manga-pages-button:hover { background-color: rgba(0, 20, 120, 0.8); } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button { display: inline-flex; padding: 4px 0px; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button .page-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button:hover { background-color: rgba(0, 20, 120, 0.8); } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button:hover .regular { display: none; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button:not(:hover) .hover { display: none; } .screen-search-container .thumbnail-box .bottom-row .manga-info-box .expand-button .page-count { vertical-align: middle; padding-left: 2px; margin-bottom: -4px; } .screen-search-container .thumbnail-box:hover .heart > svg { opacity: 0.5; } .screen-search-container .thumbnail-box:hover .ugoira-icon { opacity: 0.5; } .screen-search-container [data-type="order-shuffle"] .icon { font-size: 24px; } .thumbnail-box { position: relative; width: var(--thumb-width); height: var(--thumb-height); contain-intrinsic-size: var(--thumb-width) var(--thumb-height); /* 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. */ } .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%; } .thumbnail-box[data-pending] { visibility: hidden; } .thumbnail-box:not([data-nearby]) { content-visibility: hidden; } .thumbnail-box a.thumbnail-link { display: block; width: 100%; height: 100%; border-radius: 4px; overflow: hidden; position: relative; text-decoration: none; color: #fff; } .thumbnail-box .thumb { transition: transform .5s; transform: scale(1, 1); } body:not(.disable-thumbnail-zooming) .thumbnail-box:not(:hover) .thumb, body:not(.disable-thumbnail-zooming).pause-thumbnail-animation .thumbnail-box .thumb { transform: scale(1.25, 1.25); } .thumbnail-box.vertical-panning .thumb, .thumbnail-box.horizontal-panning .thumb { animation-duration: 4s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; } .thumbnail-box:not(:hover) .thumb, body.pause-thumbnail-animation .thumbnail-box .thumb { animation-play-state: paused; } 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; } 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%; } } .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. */ } .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; } .thumbnail-box.muted .thumb { filter: blur(10px); transform: scale(1.25, 1.25); } body:not(.disable-thumbnail-zooming) .thumbnail-box.muted .thumb:hover { transform: scale(1, 1); } .thumbnail-box:not(.muted) .muted-text { display: none; } .screen-search-container .following-tag { text-decoration: none; } .box-link-row { display: flex; flex-direction: row; align-items: center; gap: 0.5em; } .box-link-row > .box-link { padding-left: 0.5em; padding-right: 0.5em; } .box-button-row { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; row-gap: 0.25em; } .box-button-row > .box-link { margin: 0 0.25em; padding: 0 .5em; } .box-button-row > .box-link > * { padding: .25em 0; } .vertical-list > .box-link { padding: 0 .5em; } .vertical-list > .box-link { display: flex; flex-direction: row; align-items: center; margin-top: 0; margin-bottom: 0; } .box-link { display: inline-flex; cursor: pointer; text-decoration: none; margin: 0; padding: 0 .75em; align-content: center; align-items: center; height: 2em; border-radius: 2px; color: var(--box-link-fg-color); user-select: none; white-space: nowrap; background-color: var(--box-link-bg-color); } .box-link .label { vertical-align: middle; white-space: nowrap; } .box-link.selected { background-color: var(--box-link-selected-color); } .box-link:hover:not(.disabled) { background-color: var(--box-link-hover-color); } .box-link:hover:not(.disabled).selected { background-color: var(--box-link-selected-hover-color); } .box-link.disabled { color: var(--box-link-disabled-color); cursor: auto; pointer-events: none; } .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; } .box-link .icon { display: inline-block; font-size: inherit; vertical-align: middle; } .box-link .icon.with-text { margin-right: 0.25em; width: 1ch; } .box-link .icon:not(.with-text) { font-size: 150%; } a.box-link, span.box-link { padding-top: 0.5em; padding-bottom: 0.5em; } .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. */ .tag-search-box { display: inline-block; position: relative; } .input-field-container { background-color: white; padding: 6px 10px; display: inline-flex; gap: 0.25em; width: 300px; } .input-field-container > input { background: none; border: none; font-size: 1.2em; vertical-align: middle; flex: 1; min-width: 0; } .input-field-container > .right-side-button { display: inline-block; vertical-align: middle; cursor: pointer; user-select: none; color: black; } .input-field-container > .right-side-button > svg { vertical-align: middle; } .search-submit-button { /* Work around HTML's stupid whitespace handling */ font-size: 0; display: inline-block; } /* Search box in the menu: */ .navigation-search-box .search-submit-button { vertical-align: middle; margin-left: -30px; /* overlap the search box */ } .navigation-search-box input.search-tags { width: 100%; padding-right: 30px; /* extra space for the submit button */ } .thumbnail-ui-box .avatar-container { float: right; position: relative; margin-left: 25px; } .image-for-suggestions { float: right; margin-left: 25px; } .image-for-suggestions > img { display: block; height: 150px; width: 150px; object-fit: cover; border-radius: 5px; /* matches the avatar display */ } .grey-icon { color: var(--button-color); cursor: pointer; user-select: none; /* If a grey-icon is directly inside a visible popup menu, eg. the navigation icon: */ } .grey-icon:hover, :hover > .grey-icon.parent-highlight { color: var(--button-highlight-color); } .grey-icon.highlighted { color: var(--button-highlight-color); } .popup-visible > .grey-icon { color: var(--button-highlight-color); } .mute-display .muted-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(20px); opacity: .75; } .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. */ .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; } .member-tags-box .post-tag-list .tag-entry:hover:after, .search-tags-box .related-tag-list .tag-entry:hover:after, .bookmark-tags-box .bookmark-tag-list .tag-entry:hover:after, .local-bookmark-tags-box .local-bookmark-tag-list .tag-entry:hover:after { left: auto; right: 0px; } /* These affect both the search edit and search history boxes. */ .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. */ } .search-history > .input-dropdown > .input-dropdown-list { display: flex; flex-direction: column; white-space: normal; } .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; } .input-dropdown .input-dropdown-list > .entry .search { color: #000; flex: 1; padding-left: 7px; height: 100%; } .input-dropdown .input-dropdown-list > .entry .search .word { display: inline-flex; align-items: center; height: 100%; padding: 0px 5px; } .input-dropdown .input-dropdown-list > .entry .search .word.or { font-size: 12px; padding: 0; color: #333; } .search-history > .input-dropdown > .input-dropdown-list { /* Hide the button to remove history entries from non-history entries. */ } .search-history > .input-dropdown > .input-dropdown-list > .entry .suggestion-icon { margin: 2px -2px 0 2px; } .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.autocomplete) .suggestion-icon { display: none; } .search-history > .input-dropdown > .input-dropdown-list > .entry.selected { background-color: #ffa; } .search-history > .input-dropdown > .input-dropdown-list > .entry:hover { background-color: #ddd; } .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; } .search-history > .input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry { display: none; } .search-history > .input-dropdown > .input-dropdown-list > .entry:hover .remove-history-entry { visibility: visible; } .search-history > .input-dropdown > .input-dropdown-list .remove-history-entry:hover { color: #000; background-color: #c0c0c0; } .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. */ } .edit-search > .input-dropdown > .input-dropdown-list { white-space: normal; max-width: 100%; } .edit-search > .input-dropdown > .input-dropdown-list > .entry { display: inline-flex; } .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight { background-color: #eeee00; } .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag:hover { background-color: #0099FF; } .edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight:hover { background-color: #00CCFF; } .widget:not(.visible-widget) { display: none; } /* The right click context menu for the image view: */ .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. */ } .popup-context-menu:not(.visible-widget) { display: inherit; opacity: 0; pointer-events: none; transform: scale(0.85); } .popup-context-menu.visible-widget { opacity: 1; } .popup-context-menu > * { transform-origin: unset; } .popup-context-menu .popup:hover:after { display: none; } .popup-context-menu .tooltip-display { display: flex; align-items: stretch; padding: 10px 0 0 8px; pointer-events: none; } .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; } .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. */ } .popup-context-menu .button-strip > .button-block { display: inline-block; background-color: var(--frame-bg-color); padding: 12px; } .popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; } .popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; } .popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; } .popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; } .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 */ } .popup-context-menu .button-strip .button:not(.enabled) { cursor: inherit; color: var(--toggle-button-fg-disabled-color); } .popup-context-menu .button-strip .button > * { min-width: 32px; } .popup-context-menu .button-strip .button > svg { width: 32px; height: 32px; } .popup-context-menu .button-strip .button.enabled:hover { color: var(--toggle-button-fg-color); } .popup-context-menu .button-strip .button.enabled.selected { background-color: var(--toggle-button-bg-color); color: var(--toggle-button-fg-color); } .popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; } .popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; } .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; } body[data-theme="light"] .popup-context-menu .button-strip .button .tag-dropdown-arrow { border-top-color: #ccc; } .popup-context-menu .button-strip > .button-block.shift-left { margin-left: -56px; } .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; } .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; } .popup-context-menu .popup-bookmark-tag-dropdown { right: -100%; } .popup-more-options-container .button-send-image svg .arrow { transition: transform ease-in-out .15s; } .popup-more-options-container .button-send-image:not(.disabled):hover svg .arrow { transform: translate(2px, -2px); } .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: */ } .popup-context-menu .popup-bookmark-tag-dropdown, .popup-context-menu .popup-more-options-dropdown { top: calc(100% - 4px); } .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; } .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; } .popup-bookmark-tag-dropdown > .tag-right-button-strip .tag-button, .popup-more-options-dropdown > .tag-right-button-strip .tag-button { cursor: pointer; } .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; background-color: var(--box-link-bg-color); } .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry > .tag-name, .popup-more-options-dropdown .popup-bookmark-tag-entry > .tag-name { flex: 1; } .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry.selected, .popup-more-options-dropdown .popup-bookmark-tag-entry.selected { background-color: var(--box-link-selected-color); } .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:hover, .popup-more-options-dropdown .popup-bookmark-tag-entry:hover { background-color: var(--box-link-hover-color); } .popup-bookmark-tag-dropdown .popup-bookmark-tag-entry:hover.selected, .popup-more-options-dropdown .popup-bookmark-tag-entry:hover.selected { background-color: var(--box-link-selected-hover-color); } .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. */ .popup-context-menu .button.button-bookmark.public.has-like-count > svg { margin-top: -10px; } .popup-context-menu .button.button-like > svg { margin-top: -2px; } /* Bookmark buttons. These appear in image_ui and the popup menu. */ .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. */ svg.heart-image .delete { display: none; } /* These are !important to override the default white coloring in the context * menu. */ .button-bookmark { color: #400 !important; } .button-bookmark.enabled { color: #800 !important; stroke: none; } .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. */ .screen-search-container .thumbnails .button-bookmark svg > .heart { stroke: #000; stroke-width: .5px; } .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. */ } .button.button-like > svg { color: var(--like-button-color); } .button.button-like.liked > svg { color: var(--like-button-liked-color); } .button.button-like.enabled:hover > svg { color: var(--like-button-hover-color); } .button.button-browser-back .arrow { transition: transform ease-in-out .15s; transform: translate(-2px, 0px); } .button.button-browser-back:hover .arrow { transform: translate(1px, 0px); } .button.button-like > svg > * { transition: transform ease-in-out .15s; transform: translate(0, 0px); } .button.button-like > svg > .mouth { transform: scale(1, 0.75); } .button.button-like.liked > svg > * { transform: translate(0, -3px); } .button.button-like.liked > svg > .mouth { transform: scale(1, 1.1) translate(0, -3px); } .button.button-like.enabled:hover > svg > * { transform: translate(0, -2px); } .button.button-like.enabled:hover > svg > .mouth { transform: scale(1, 0.9) translate(0, -3px); } .button-bookmark.public svg.heart-image .lock { display: none; } .button-bookmark svg.heart-image .lock { stroke: #888; } .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; } .dialog .content { font-size: 18px; max-width: 800px; background-color: var(--ui-bg-color); color: var(--ui-fg-color); border-radius: 5px; position: relative; } .dialog .content > .scroll { width: 100%; height: 100%; overflow-y: auto; padding: 1em; } .dialog .header { font-size: 40px; margin-bottom: 20px; } .whats-new-box .content { width: 80%; height: 80%; } .whats-new-box .content .rev { display: inline-block; color: var(--box-link-fg-color); background-color: var(--box-link-bg-color); padding: 5px 10px; } .whats-new-box .content .text { margin: 1em 0; padding: 0 20px; /* inset horizontally a bit */ } .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; } .close-button:hover { color: var(--button-highlight-color); } .close-button > svg { display: block; } .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. */ } .screen-illust-container .page-change-indicator[data-side="left"] { margin-left: 20px; left: 0; } .screen-illust-container .page-change-indicator[data-side="right"] { margin-right: 20px; right: 0; } .screen-illust-container .page-change-indicator[data-side="right"] svg { transform-origin: center center; transform: scale(-1, 1); } .screen-illust-container .page-change-indicator[data-icon="last-page"] svg .bar { display: none; } .screen-illust-container .page-change-indicator svg { opacity: 0; } .screen-illust-container .page-change-indicator.flash svg { animation: flash-page-change-opacity 400ms ease-out 1 forwards; } .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); } } .link-tab-popup .explanation { max-width: 25em; width: 100%; text-align: center; margin: 0 auto; } .link-tab-popup .button { display: inline-block; cursor: pointer; background-color: #000; padding: .5em 1em; margin: .5em; border-radius: 5px; } .link-tab-popup .content { width: 400px; padding: 1em; } .link-tab-popup .buttons { display: flex; } .link-tab-popup .tutorial-monitor { width: 290px; height: 125px; margin-bottom: -20px; } .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); } } .send-image-popup .content { padding: 1em; } .link-this-tab-popup > .box, .send-image-here-popup > .box { border: 1px solid black; background-color: #000; color: #fff; padding: 1em; } .tag-entry-popup { z-index: 1000; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); } .tag-entry-popup > .strip { display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: center; } .tag-entry-popup > .strip > .box { background-color: #222; padding: 10px; color: #eee; position: relative; } body[data-theme="light"] .tag-entry-popup > .strip > .box { background-color: #ddd; color: #222; } .tag-entry-popup .close-button { top: 0px; right: 0px; } .tag-entry-popup .input-box { position: relative; display: flex; align-items: center; margin-top: .5em; } .tag-entry-popup .input-box > input { flex: 1; padding: 4px; } .tag-entry-popup .input-box > .submit-button { cursor: pointer; display: inline-block; width: 20px; text-align: center; margin-left: 6px; border: 1px solid white; } body[data-theme="light"] .tag-entry-popup .input-box > .submit-button { border-color: #444; } .tag-entry-popup .input-box > .submit-button:hover { background-color: #444; } body[data-theme="light"] .tag-entry-popup .input-box > .submit-button:hover { background-color: #aaa; } .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. */ } .years-ago > a { padding: 4px 10px; background-color: #444; } body[data-theme="light"] .years-ago > a { background-color: #ccc; } .tree { user-select: none; overflow-x: hidden; overflow-y: auto; flex: 1; } .tree .tree-item { position: relative; contain-intrinsic-height: 32px; } .tree .tree-item.allow-content-visibility { content-visibility: auto; } .tree .tree-item:not(.root) > .items { margin-left: 1em; } .tree .tree-item.selected > .self > .label { background-color: #003088; } .tree .tree-item > .self { display: flex; flex-direction: row; align-items: center; height: 2em; } .tree .tree-item > .self:focus { outline: none; } .tree .tree-item > .self > .label { padding: 0.5em; white-space: nowrap; } .tree .tree-item > .self.root { display: none; } .tree .tree-item > .self > .expander { display: flex; justify-content: center; align-items: center; font-size: 50%; width: 3em; height: 100%; } .tree .tree-item > .self > .expander > .expander-button { display: none; width: 3em; text-align: center; vertical-align: middle; } .tree .tree-item > .self > .expander[data-mode="loading"] > .loading { display: block; } .tree .tree-item > .self > .expander[data-mode="none"] > .none { display: block; } .tree .tree-item > .self > .expander[data-pending="true"] > .expand { opacity: 0.5; } .tree .tree-item > .self > .expander[data-mode="expandable"] > .expand, .tree .tree-item > .self > .expander[data-mode="expanded"] > .expand { display: block; } .tree .tree-item > .self > .expander .expand { transform: rotate(0deg); transition: transform .25s; } .tree .tree-item > .self > .expander[data-mode="expanded"] > .expand { transform: rotate(90deg); } .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; } .local-navigation-box[hidden], .local-navigation-box[data-disabled] { display: block !important; opacity: 0; pointer-events: none; position: fixed; transform: translate(-50%, 0); } .tree-popup { background-color: #222; color: #fff; position: fixed; pointer-events: none; outline-style: dotted; outline-width: 1px; outline-color: #aaa; } .tree-popup > .label { white-space: nowrap; } .thumb-popup { position: fixed; pointer-events: none; margin-left: 10px; width: 25%; height: 40%; max-height: 400px; max-width: 400px; } .thumb-popup > img { object-fit: contain; width: 100%; height: 100%; } .settings-dialog .content { width: 80%; min-height: 30em; min-width: calc(min(800px, 95%)); padding: 1em; } .settings-dialog .content .box { display: flex; flex-direction: row; } .settings-dialog .content .box .sections { white-space: nowrap; display: flex; flex-direction: column; } .settings-dialog .content .box .sections > .box-link { padding: 0.5em; cursor: pointer; } .settings-dialog .content .box .sections > .box-link:not(.selected) { opacity: 0.65; } .settings-dialog .content .box .sections > .box-link:not(.active) { background: none; } .settings-dialog .content .box .sections > .box-link:hover { background-color: var(--box-link-hover-color); } .settings-dialog .content .box .items { flex: 1; } .settings-dialog .content .box .settings-list { display: flex; flex-direction: column; } .settings-dialog .content .box .settings-list .settings-row { padding: 0.5em 1em; gap: 1em; } .settings-dialog .content .box .settings-list .box-link { height: auto; background-color: inherit; } .settings-dialog .content .box .settings-list .box-link > .buttons > .box-link { padding: 0.35em .75em; } .settings-dialog .content .box .settings-list .box-link.button { background-color: var(--box-link-bg-color); } .settings-dialog .content .box .settings-list .box-link.clickable:hover:not(.disabled) { background-color: var(--box-link-hover-color); } .settings-dialog .content .box .settings-list .box-link:not(.clickable):hover { cursor: inherit; background: none; } .settings-dialog .content .box .settings-list .box-link .label-box { margin-left: .25em; flex: 1; display: flex; flex-direction: column; gap: .25em; } .settings-dialog .content .box .settings-list .box-link .label-box .label { padding-top: .25em; margin: 0; } .settings-dialog .content .box .settings-list .box-link .label-box .explanation { font-size: 16px; opacity: 0.75; } .edit-post-mute-dialog > .content { width: 80%; min-height: 10em; min-width: 800px; padding: 1em; } .muted-tags-popup { padding: 0.5em 1em; display: flex; flex-direction: column; gap: 0.5em; max-height: 440px; overflow-y: auto; } .edit-post-mute-dialog .mute-warning, .muted-tags-popup .mute-warning { border: solid 2px black; border-radius: 15px; background-color: #000; padding: 1em; } .edit-post-mute-dialog .non-premium-mute-warning, .muted-tags-popup .non-premium-mute-warning { margin-right: 40px; } .edit-post-mute-dialog .non-premium-mute-warning .icon, .muted-tags-popup .non-premium-mute-warning .icon { font-size: 24px; color: #ffff00; } .edit-post-mute-dialog .post-mute-list, .muted-tags-popup .post-mute-list { display: flex; flex-direction: column; gap: 4px; } .edit-post-mute-dialog .post-mute-list .entry, .muted-tags-popup .post-mute-list .entry { display: flex; align-items: center; gap: 0.5em; } .edit-post-mute-dialog .post-mute-list .entry.muted .tag-name, .muted-tags-popup .post-mute-list .entry.muted .tag-name { color: #ffaaaa; } .image-editor { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none; } .image-editor .save-edits.dirty { color: #0f0; } .image-editor .spinner .icon { animation: spin 1000ms linear infinite forwards; } .image-editor .image-editor-buttons { position: absolute; display: flex; flex-direction: column; font-size: 150%; width: 100%; justify-content: center; align-items: center; padding-top: 0.5em; } body:not(.focused) .image-editor .image-editor-buttons.top { display: none; } .image-editor .image-editor-buttons.top { top: 0; } .image-editor .image-editor-buttons.bottom { bottom: 0; } .image-editor .image-editor-buttons .image-editor-button-row { pointer-events: auto; margin-bottom: 0.5em; } @keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } } .crop-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .crop-editor-overlay .crop-box { position: relative; --overlap: 1vh; } .crop-editor-overlay .crop-box [data-crop="all"] { box-shadow: 0px 0px 0px 10000px #00000080; } .crop-editor-overlay .crop-box[data-mode="crop"] [data-crop="all"] { outline: 3px solid #fff; outline-style: ridge; } .crop-editor-overlay .crop-box[data-mode="safe_zone"] [data-crop="all"] { outline: 1px solid #fff; outline-offset: 1px; pointer-events: none; } .crop-editor-overlay .crop-box .handle { position: absolute; } .crop-editor-overlay .crop-box .handle[data-crop="top"] { cursor: n-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="left"] { cursor: w-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="right"] { cursor: e-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="bottom"] { cursor: s-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="topleft"] { cursor: nw-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="topright"] { cursor: ne-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="bottomleft"] { cursor: sw-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="bottomright"] { cursor: se-resize !important; } .crop-editor-overlay .crop-box .handle[data-crop="all"] { cursor: move !important; } .crop-editor-overlay .crop-box .handle[data-crop="top"] { width: 100%; height: 10000px; bottom: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="left"] { width: 10000px; height: 100%; right: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="right"] { width: 10000px; height: 100%; left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="bottom"] { width: 100%; height: 10000px; top: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="topleft"] { width: 10000px; height: 10000px; right: calc(100% - var(--overlap)); bottom: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="topright"] { width: 10000px; height: 10000px; bottom: calc(100% - var(--overlap)); left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="bottomleft"] { width: 10000px; height: 10000px; top: calc(100% - var(--overlap)); right: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="bottomright"] { width: 10000px; height: 10000px; top: calc(100% - var(--overlap)); left: calc(100% - var(--overlap)); } .crop-editor-overlay .crop-box .handle[data-crop="all"] { width: 100%; height: 100%; left: 0; } .inpaint-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .inpaint-editor-overlay.creating-lines { cursor: crosshair !important; } .inpaint-editor-overlay .inpaint-segment { pointer-events: auto; } .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; } .inpaint-editor-overlay .inpaint-segment:hover { pointer-events: all; } .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { stroke: #000; } .inpaint-editor-overlay .inpaint-segment .inpaint-handle { opacity: 0; } .inpaint-editor-overlay .inpaint-segment.selected .inpaint-handle, .inpaint-editor-overlay .inpaint-segment:hover .inpaint-handle { opacity: 1; } .inpaint-editor-overlay .inpaint-segment .inpaint-handle { fill: none; opacity: 0.25; stroke: #000; pointer-events: all; } .pan-editor-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .pan-editor-overlay .handle { overflow: visible; } .pan-editor-overlay .pan-editor-crop-region { width: 100%; height: 100%; position: relative; } .pan-editor-overlay .monitor-preview-box { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform-origin: 0 0; } .pan-editor-overlay .monitor-preview-box > .box { box-shadow: 0px 0px 0px 100000px #00000080; outline: 1px dashed #fff; width: 100%; height: 100%; } /*# sourceMappingURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/output/main.scss.map */`; ppixiv.resources["resources/multi-monitor.svg"] = ` `; 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/pan-editor-marker.svg"] = ` `; 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/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["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); }; }); } }; }; } }; // Install early polyfills. These can be needed before other scripts run, so they're installed // immediately rather than waiting for install_polyfills. (() => { // iOS doesn't have BroadcastChannel. It's annoying to have to check for this early, since // these are often created statically, so install a dummy. if(window.BroadcastChannel == null) { window.BroadcastChannel = class extends EventTarget { // This allows us to tell that this isn't a real implementation. static fake = true; postMessage() { } }; } })(); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/polyfills.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. image_data.singleton().update_media_info(media_id, { bookmarkData: { id: new_bookmark_id, private: !!request.restrict, }, }); // Broadcast that this illust was bookmarked. This is for my own external // helper scripts. let e = new Event("bookmarked"); e.illust_id = illust_id; window.dispatchEvent(e); // 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"); image_data.singleton().update_media_info(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. image_data.singleton().update_media_info(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: active_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"); } } // Follow user_id with the given privacy and tag list. // // The follow editing API has a bunch of quirks. You can call bookmark_add on a user // you're already following, but it'll only update privacy and not tags. Editing tags // is done with following_user_tag_add/following_user_tag_delete (and can only be done // one at a time). // // A tag can only be set with this call if the caller knows we're not already following // the user, eg. if the user clicks a tag in the follow dropdown for an unfollowed user. // If we're editing an existing follow's tag, use change_follow_tags below. We do handle // changing privacy here. static async follow(user_id, follow_privately, { tag=null }={}) { if(user_id == -1) return; // We need to do this differently depending on whether we were already following the user. let user_info = await image_data.singleton().get_user_info_full(user_id); if(user_info.isFollowed) { // If we were already following, we're just updating privacy. We don't update follow // tags for existing follows this way. console.assert(tag == null); return await actions.change_follow_privacy(user_id, follow_privately); } // This is a new follow. // // If bookmark_privately_by_default is enabled and private wasn't specified // explicitly, set it to true. if(follow_privately == null && settings.get("bookmark_privately_by_default")) follow_privately = true; // This doesn't return any data (not even an error flag). await helpers.rpc_post_request("/bookmark_add.php", { mode: "add", type: "user", user_id, tag: tag ?? "", restrict: follow_privately? 1:0, format: "json", }); // Cache follow info for this new follow. Since we weren't followed before, we know // we can just create a new entry. let tag_set = new Set(); if(tag != null) { tag_set.add(tag); image_data.singleton().add_to_cached_all_user_follow_tags(tag); } let info = { tags: tag_set, following_privately: follow_privately, }; image_data.singleton().update_cached_follow_info(user_id, true, info); var message = "Followed " + user_info.name; if(follow_privately) message += " privately"; message_widget.singleton.show(message); } // Change the privacy status of a user we're already following. static async change_follow_privacy(user_id, follow_privately) { let data = await helpers.rpc_post_request("/rpc/index.php", { mode: "following_user_restrict_change", user_id: user_id, restrict: follow_privately? 1:0, }); if(data.error) { console.log(\`Error editing follow tags: \${data.message}\`); return; } // If we had cached follow info, update it with the new privacy. let info = image_data.singleton().get_user_follow_info_sync(user_id); if(info != null) { console.log("Updating cached follow privacy"); info.following_privately = follow_privately; image_data.singleton().update_cached_follow_info(user_id, true, info); } let user_info = await image_data.singleton().get_user_info(user_id); let message = \`Now following \${user_info.name} \${follow_privately? "privately":"publically"}\`; message_widget.singleton.show(message); } // Add or remove a follow tag for a user we're already following. The API only allows // editing one tag per call. static async change_follow_tags(user_id, {tag, add}) { let data = await helpers.rpc_post_request("/rpc/index.php", { mode: add? "following_user_tag_add":"following_user_tag_delete", user_id: user_id, tag, }); if(data.error) { console.log(\`Error editing follow tags: \${data.message}\`); return; } let user_info = await image_data.singleton().get_user_info(user_id); let message = \`\${add? "Added":"Removed"} the tag "\${tag}" from \${user_info.name}\`; message_widget.singleton.show(message); // Get follow info so we can update the tag list. This will usually already be loaded, // since the caller will have had to load it to show the UI in the first place. let follow_info = await image_data.singleton().get_user_follow_info(user_id); if(follow_info == null) { console.log("Error retrieving follow info to update tags"); return; } if(add) { follow_info.tags.add(tag); // Make sure the tag is in the full tag list too. image_data.singleton().add_to_cached_all_user_follow_tags(tag); } else follow_info.tags.delete(tag); image_data.singleton().update_cached_follow_info(user_id, true, follow_info); } 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, }); let user_data = await image_data.singleton().get_user_info(user_id); // Record that we're no longer following and refresh the UI. image_data.singleton().update_cached_follow_info(user_id, false); 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); let filename = user_info.name + " - " + illust_data.id; // 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.id + " - " + 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/r137/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/r137/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 a general-purpose box link. create_box_link({ label, link=null, classes="", icon=null, popup=null, // If set, this is an extra explanation line underneath the label. explanation=null, // By default, return HTML as text, which is used to add these into templates, which // is the more common usage. If as_element is true, an element will be returned instead. as_element=false, // Helpers for screen_search: dataset={}, data_type=null, }) { // XXX: We prefix material icon names with "mat:", so in the future we can have a custom // icon font if we want using a different prefix. if(icon && icon.startsWith && icon.startsWith("mat:")) icon = icon.substr(4); // We always create an anchor, even if we don't have a link. Browsers just treat it as // a span when there's no href attribute. // // label-box encloses the icon and label, so they're aligned to each other with text spacing, // which is needed to get text to align with font icons. The resulting box is then spaced as // a unit within box-link's flexbox. let html = \`
\`; let template = document.createElement("template"); template.innerHTML = html; let node = helpers.create_from_template(template); if(label != null) { node.querySelector(".label").hidden = false; node.querySelector(".label").innerText = label; } if(link) node.href = link; for(let className of classes || []) node.classList.add(className); if(popup) { node.classList.add("popup"); node.dataset.popup = popup; } if(icon != null) { node.querySelector(".icon").hidden = false; node.querySelector(".icon").innerText = icon; // .with.text is set for icons that have text next to them, to enable padding // and spacing. if(label != null) node.querySelector(".icon").classList.add("with-text"); } if(explanation != null) { let explanation_node = node.querySelector(".explanation"); explanation_node.hidden = false; explanation_node.innerText = explanation; } if(data_type != null) node.dataset.type = data_type; for(let [key, value] of Object.entries(dataset)) node.dataset[key] = value; if(as_element) return node; else return node.outerHTML; }, 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(() => { window.URL.revokeObjectURL(blobUrl); a.parentNode.removeChild(a); }, 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 }); } }, // Like Promise.all, but takes a dictionary of {key: promise}, returning a // dictionary of {key: result}. async await_map(map) { Promise.all(Object.values(map)); let results = {}; for(let [key, promise] of Object.entries(map)) results[key] = await promise; return results; }, // 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, { ignore_missing=false }={}) { // 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__ && obj.__proto__[name]? obj.__proto__[name]:obj[name]; if(!orig_func) { if(!ignore_missing) 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; } // Delete owned properties on an object. This removes wrappers around class functions // like document.addEventListener, so it goes back to the browser implementation, and // freezes the object to prevent them from being added in the future. function delete_overrides(obj) { for(let prop of Object.getOwnPropertyNames(obj)) { try { delete obj[prop]; } catch(e) { // A couple properties like document.location are normal and can't be deleted. } } try { Object.freeze(obj); } catch(e) { console.error(\`Error freezing \${obj}: \${e}\`); } } try { 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); // Delete wrappers on window.history set by the site, and freeze it so they can't // be added. delete_overrides(unsafeWindow.history); delete_overrides(unsafeWindow.document); // 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 name of Object.keys(window.console)) unwrap_func(console, name, { ignore_missing: true }); 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 = () => { }; try { window.addEventListener = Window.prototype.addEventListener.bind(unsafeWindow); window.removeEventListener = Window.prototype.removeEventListener.bind(unsafeWindow); } catch(e) { // This fails on iOS. That's OK, since Pixiv's mobile site doesn't mess // with these (and since we can't write to these, it wouldn't be able to either). } // 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.bind(unsafeWindow); 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]; }, // Input elements have no way to tell when edits begin or end. The input event tells // us when the user changes something, but it doesn't tell us when drags begin and end. // This is important for things like undo: you want to save undo the first time a slider // value changes during a drag, but not every time, or if the user clicks the slider but // doesn't actually move it. // // This adds events: // // editbegin // edit // editend // // edit events are always surrounded by editbegin and editend. If the user makes multiple // edits in one action (eg. moving an input slider), they'll be sent in the same begin/end // block. // // This is only currently used for sliders, and doesn't handle things like keyboard navigation // since that gets overridden by other UI anyway. // // signal can be an AbortSignal to remove these event listeners. watch_edits: function(input, { signal }={}) { let dragging = false; let inside_edit = false; input.addEventListener("mousedown", (e) => { if(e.button != 0 || dragging) return; dragging = true; }, { signal }); input.addEventListener("mouseup", (e) => { if(e.button != 0 || !dragging) return; dragging = false; if(inside_edit) { inside_edit = false; input.dispatchEvent(new Event("editend")); } }, { signal }); input.addEventListener("input", (e) => { // Send an editbegin event if we haven't yet. let send_editend = false; if(!inside_edit) { inside_edit = true; input.dispatchEvent(new Event("editbegin")); // If we're not dragging, this is an isolated edit, so send editend immediately. send_editend = !dragging; } // The edit event is like input, but surrounded by editbegin/editend. input.dispatchEvent(new Event("edit")); if(send_editend) { inside_edit = false; input.dispatchEvent(new Event("editend")); } }, { signal }); }, 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. if(window.cloneInto) 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; } // Pixiv returns completely different data when it thinks you're on mobile, and uses a completely // different set of APIs. Set a fake desktop referer to prevent this from happening. if(ppixiv.ios) options.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36'; 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(100); 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 || !tag) 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); 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(" ", " "); // Make sure there's a single space around parentheses, so parentheses are treated as their own item. // This makes it easier to translate tags inside parentheses, and style parentheses separately. search = search.replace(/ *([\\(\\)]) */g, " \$1 "); // Remove repeated spaces. search = search.replace(/ +/g, " "); 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" || !e.target.hasAttribute("href")) 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\`; }, 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; }, // Clamp value between min and max. clamp(value, min, max) { if(min > max) [min, max] = [max, min]; return Math.min(Math.max(value, min), max); }, // Scale x from [l1,h2] to [l2,h2], clamping to l2,h2. scale_clamp(x, l1, h1, l2, h2) { return helpers.clamp(helpers.scale(x, l1, h1, l2, h2), l2, h2); }, // 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 }); } }); }, // 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. Return the corresponding CSS style attributes. // // container is the containing block (eg. ul.thumbnails). make_thumbnail_sizing_style(container, 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); return {columns: best_columns, padding, max_width, max_height, container_width}; }, // Given a list of manga info, return the aspect ratio to use to display them. // This can be passed as the "ratio" option to make_thumbnail_sizing_style. get_manga_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. let total = 0; for(let manga_page of manga_info) total += manga_page.width / manga_page.height; let average_aspect_ratio = total / manga_info.length; let illusts_far_from_average = 0; for(var manga_page of manga_info) { let 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; }, // 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) { return helpers.get_media_id_for_page(media_id, 0); }, get_media_id_for_page(media_id, page=0) { if(media_id == null) return null; let id = helpers.parse_media_id(media_id); id.page = page; 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, remove_from_end=0) { let parts = path.split('/'); parts = parts.splice(0, parts.length - remove_from_end); parts = parts.splice(parts.length-count); // take the last count 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; }, }; // 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.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); // 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; helpers.set_class(this.canvas, "loaded", false); 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); helpers.set_class(this.canvas, "loaded", true); } } // 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); }); } async delete(key) { return await this.db_op(async (db) => { let store = this.get_store(db); return key_storage.await_request(store.delete(key)); }); } // 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.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); } // Allow editing the rect as a pair of x1,y1/x2,y2 coordinates, which is more natural // than x,y and width,height. x1 and y1 can be greater than x2 and y2 if the rect is // inverted (width or height are negative). get x1() { return this.x; } get y1() { return this.y; } get x2() { return this.x + this.width; } get y2() { return this.y + this.height; } set x1(value) { this.width += this.x - value; this.x = value; } set y1(value) { this.height += this.y - value; this.y = value; } set x2(value) { this.width = value - super.x; } set y2(value) { this.height = value - super.y; } get middleHorizontal() { return (super.right + super.left) / 2; } get middleVertical() { return (super.top + super.bottom) / 2; } // Return a new FixedDOMRect with the edges pushed outwards by value. extendOutwards(value) { return new FixedDOMRect( this.left - value, this.top - value, this.right + value, this.bottom + value ) } // Crop this rect to fit within outer. cropTo(outer) { return new FixedDOMRect( helpers.clamp(this.x1, outer.x1, outer.x2), helpers.clamp(this.y1, outer.y1, outer.y2), helpers.clamp(this.x2, outer.x1, outer.x2), helpers.clamp(this.y2, outer.y1, outer.y2), ); } } // 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/r137/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, 7); 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("slideshow_duration", { default_value: 15 }); ppixiv.settings.configure("auto_pan_duration", { default_value: 3 }); ppixiv.settings.configure("extra_mutes", { default_value: [] }); ppixiv.settings.configure("slideshow_skips_manga", { default_value: false }); ppixiv.settings.configure("expand_manga_thumbnails", { default_value: false }); //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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.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/r137/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.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.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\`; helpers.set_max_height(this.box, { bottom_padding: 10 }); } // 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; } }; ppixiv.checkbox_widget = class extends ppixiv.widget { constructor({ value=false, ...options}) { super({...options, template: \` \`}); this._checked = true; }; set checked(value) { if(this._checked == value) return; this._checked = value; this.refresh(); } get checked() { return this._checked; } async refresh() { this.container.innerText = this.checked? "check_box":"check_box_outline_blank"; } }; // A pointless creepy eye. Looks away from the mouse cursor when hovering over // the unfollow button. ppixiv.creepy_eye_widget = class { constructor(eye) { 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"; 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"); let avatar_link = this.container.querySelector(".avatar-link"); let box = this.container.querySelector(".follow-box"); this.follow_widget = new ppixiv.follow_widget({ container: box, parent: this, open_button: avatar_link, }); avatar_link.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.follow_widget.visible = !this.follow_widget.visible; }, { // Hack: capture this event so we get clicks even over the eye widget. We can't // set it to pointer-events: none since it reacts to mouse movement. capture: true, }); // Clicking the avatar used to go to the user page, but now it opens the follow dropdown. // Allow doubleclicking it instead, to keep it quick to go to the user. avatar_link.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); let args = new helpers.args(\`/users/\${this.user_id}/artworks#ppixiv\`); helpers.set_page_url(args, true /* add_to_history */, "navigation"); }); // 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.img = document.createElement("img"); this.base_filter = new image_canvas_filter(this.img, element_author_avatar); 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", (e) => { helpers.set_class(avatar_popup, "popup-visible", true); }); avatar_popup.addEventListener("mouseout", (e) => { helpers.set_class(avatar_popup, "popup-visible", false); }); } new creepy_eye_widget(this.container.querySelector(".follow-icon .eye-image")); } 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.follow_widget.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.classList.remove("loading"); this.container.querySelector(".follow-icon").hidden = true; let user_data = await image_data.singleton().get_user_info(this.user_id); this.user_data = user_data; if(user_data == null) return; this.container.querySelector(".follow-icon").hidden = !this.user_data.isFollowed; this.container.querySelector(".avatar").dataset.popup = this.user_data.name; // 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; } }; ppixiv.follow_widget = class extends widget { constructor({ // The button used to open this widget. We close on clicks outside of our box, but // we won't close if this button is clicked, so toggling the widget works properly. open_button=null, ...options }) { super({ visible: false, ...options, template: \` \`}); this.open_button = open_button; this._user_id = null; this.container.querySelector(".follow-button-public").addEventListener("click", (e) => { this.clicked_follow(false); }); this.container.querySelector(".follow-button-private").addEventListener("click", (e) => { this.clicked_follow(true); }); this.container.querySelector(".toggle-follow-button-public").addEventListener("click", (e) => { this.clicked_follow(false); }); this.container.querySelector(".toggle-follow-button-private").addEventListener("click", (e) => { this.clicked_follow(true); }); this.container.querySelector(".unfollow-button").addEventListener("click", (e) => { this.clicked_unfollow(); }); this.container.querySelector(".add-follow-tag").addEventListener("click", (e) => { this.add_follow_tag(); }); // Refresh if the user we're displaying changes. image_data.singleton().user_modified_callbacks.register(this.user_changed); // Close if our container closes. new view_hidden_listener(this.container, (e) => { this.visible = false; }); } user_changed = (user_id) => { if(!this.visible || user_id != this.user_id) return; this.refresh(); }; set user_id(value) { if(this._user_id == value) return; this._user_id = value; if(value == null) this.visible = false; } get user_id() { return this._user_id; } visibility_changed() { super.visibility_changed(); if(this.visible) { this.refresh(); // Close on clicks outside of our menu. Include our parent's button which opens // us, so we don't close when it's going to toggle us. this.click_outside_listener = new click_outside_listener([this.container, this.open_button], () => { this.visible = false; }); } else { if(this.click_outside_listener) { this.click_outside_listener.shutdown(); this.click_outside_listener = null; } } } async refresh() { if(!this.visible) return; // Fit the tag scroll box within however much space we have available. helpers.set_max_height(this.container, { max_height: 400, bottom_padding: 10 }); if(this.refreshing) { console.error("Already refreshing"); return; } this.refreshing = true; try { if(this._user_id == null) { console.log("Follow widget has no user ID"); return; } // Refresh with no data. this.refresh_with_data(); // Refresh with whether we're followed or not, so the follow/unfollow UI is // displayed as early as possible. let user_info = await image_data.singleton().get_user_info(this.user_id); if(!this.visible) return; this.refresh_with_data({ user_info, following: user_info.isFollowed }); if(!user_info.isFollowed) { // We're not following, so just load the follow tag list. let all_tags = await image_data.singleton().load_all_user_follow_tags(); this.refresh_with_data({ user_info, following: user_info.isFollowed, all_tags, selected_tags: new Set() }); return; } // Get full follow info to find out if the follow is public or private, and which // tags are selected. let follow_info = await image_data.singleton().get_user_follow_info(this.user_id); let all_tags = await image_data.singleton().load_all_user_follow_tags(); this.refresh_with_data({user_info, following: true, following_privately: follow_info?.following_privately, all_tags, selected_tags: follow_info?.tags}); } finally { this.refreshing = false; } } // Refresh the UI with as much data as we have. This data comes in a bunch of little pieces, // so we get it incrementally. refresh_with_data({user_info=null, following=null, following_privately=null, all_tags=null, selected_tags=null}={}) { if(!this.visible) return; this.container.querySelector(".follow-button-public").hidden = true; this.container.querySelector(".follow-button-private").hidden = true; this.container.querySelector(".toggle-follow-button-public").hidden = true; this.container.querySelector(".toggle-follow-button-private").hidden = true; this.container.querySelector(".unfollow-button").hidden = true; this.container.querySelector(".add-follow-tag").hidden = true; this.container.querySelector(".separator").hidden = true; let view_text = user_info != null? \`View \${user_info.name}'s posts\`:\`View posts\`; this.container.querySelector(".view-posts .label").innerText = view_text; this.container.querySelector(".view-posts").href = \`/users/\${this._user_id}/artworks#ppixiv\`; // If following is null, we're still waiting for the initial user data request // and we don't have any data yet. if(following == null) return; if(following) { // If we know whether we're following privately or publically, we can show the // button to change the follow mode. If we don't have that yet, we can only show // unfollow. if(following_privately != null) { this.container.querySelector(".toggle-follow-button-public").hidden = !following_privately; this.container.querySelector(".toggle-follow-button-private").hidden = following_privately; } this.container.querySelector(".unfollow-button").hidden = false; } else { this.container.querySelector(".follow-button-public").hidden = false; this.container.querySelector(".follow-button-private").hidden = false; } // If we've loaded follow tags, fill in the list. let follow_tags = this.container.querySelectorAll(".follow-tag"); for(let element of follow_tags) element.remove(); if(all_tags != null) { // Show the separator and "add tag" button once we have the tag list. this.container.querySelector(".add-follow-tag").hidden = false; this.container.querySelector(".separator").hidden = false; for(let tag of all_tags) { let button = helpers.create_box_link({ label: tag, classes: ["follow-tag"], icon: "bookmark", as_element: true, }); // True if the user is bookmarked with this tag. let selected = selected_tags.has(tag); helpers.set_class(button, "selected", selected); this.container.appendChild(button); button.addEventListener("click", (e) => { this.toggle_follow_tag(tag); }); } } } async clicked_follow(follow_privately) { await actions.follow(this._user_id, follow_privately); } async clicked_unfollow() { await actions.unfollow(this._user_id); } async add_follow_tag() { let prompt = new text_prompt({ title: "New folder:" }); let folder = await prompt.result; if(folder == null) return; // cancelled await this.toggle_follow_tag(folder); } async toggle_follow_tag(tag) { // Make a copy of user_id, in case it changes while we're async. let user_id = this.user_id; // If the user isn't followed, the first tag is added by following. let user_data = await image_data.singleton().get_user_info(user_id); if(!user_data.isFollowed) { // We're not following, so follow the user with default privacy and the // selected tag. await actions.follow(user_id, null, { tag }); return; } // We're already following, so update the existing tags. let follow_info = await image_data.singleton().get_user_follow_info(user_id); if(follow_info == null) { console.log("Error retrieving follow info to update tags"); return; } let tag_was_selected = follow_info.tags.has(tag); actions.change_follow_tags(user_id, {tag: tag, add: !tag_was_selected}); } }; // 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 translated_tag = tag; if(translated_tags[tag]) translated_tag = translated_tags[tag]; let a = helpers.create_box_link({ label: translated_tag, classes: ["tag-entry"], link: this.format_tag_link(tag), as_element: true, }); this.container.appendChild(a); a.dataset.tag = 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: \`
+
\`}); 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, 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("selected")) 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, "selected", 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. clicked_bookmark_tag = async(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, "selected", !a.classList.contains("selected")); } } 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: "mat:lightbulb", 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: "mat:lightbulb", 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: "mat:lightbulb", 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"); } }); }, edit_mutes: () => { return new menu_option_button({ ...shared_options, label: "Edit mutes", // Only show this entry if we have at least a media ID or a user ID. requires: ({media_id, user_id}) => { return media_id != null || user_id != null; }, icon: "mat:block", onclick: async () => { this.parent.hide(); new muted_tags_for_post_popup({ container: document.body, media_id: this.media_id, user_id: this.user_id, }); } }); }, refresh_image: () => { return new menu_option_button({ ...shared_options, label: "Refresh image", requires_image: true, icon: "mat:refresh", onclick: async () => { this.parent.hide(); image_data.singleton().refresh_media_info(this.media_id); } }); }, // XXX: hook into progress bar download_image: () => { return new menu_option_button({ ...shared_options, label: "Download image", icon: "mat:download", 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: "mat:download", 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: "mat:download", 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: "mat:open_in_new", 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_toggle({ ...shared_options, label: "Slideshow", icon: "mat:wallpaper", requires_image: true, checked: helpers.args.location.hash.get("slideshow") == "1", onclick: () => { // Add or remove slideshow=1 from the hash. If we're not on the illust view, use // the URL of the image the user clicked, otherwise modify the current URL. let args = helpers.args.location; let viewing_illust = main_controller.singleton.current_screen_name == "illust"; if(viewing_illust) args = helpers.args.location; else args = main_controller.singleton.get_media_url(this.media_id); let enabled = args.hash.get("slideshow") == "1"; if(enabled) args.hash.delete("slideshow"); else args.hash.set("slideshow", "1"); this.value = enabled; // If we're on the illust view this replaces the current URL since it's just a // settings change, otherwise this is a navigation. helpers.set_page_url(args, !viewing_illust, "toggle slideshow"); } }); }, linked_tabs: () => { return new menu_option_toggle_setting({ container: option_box, parent: this, label: "Linked tabs", setting: "linked_tabs_enabled", icon: "mat:link", buttons: [ new menu_option_button({ container: option_box, parent: this, label: "Edit", classes: ["small-font"], onclick: (e) => { e.stopPropagation(); new ppixiv.settings_dialog({ container: document.body, show_page: "linked_tabs", }); this.parent.hide(); return true; }, }), ], }); }, image_editing: () => { return new menu_option_toggle_setting({ ...shared_options, label: "Image editing", icon: "mat:brush", setting: "image_editing", requires_image: true, onclick: () => { // When editing is turned off, clear the editing mode too. let enabled = settings.get("image_editing"); if(!enabled) settings.set("image_editing_mode", null); }, }); }, exit: () => { return new menu_option_button({ ...shared_options, label: "Return to Pixiv", icon: "mat:logout", 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.download_image()); this.menu_options.push(menu_options.download_manga()); this.menu_options.push(menu_options.download_video()); this.menu_options.push(menu_options.edit_mutes()); } 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.image_editing()); this.menu_options.push(menu_options.refresh_image()); 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); 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. clicked_bookmark = async(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/r137/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, reason: "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); await 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); await 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); image_data.singleton().update_media_info(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; image_data.singleton().update_media_info(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\`; } // We always enable bookmark searching if that's all we're allowed to do. else if(args.hash.has("bookmarks") || local_api.local_info.bookmark_tag_searches_only) { 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 == null) title = "Search"; 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"); } // Load access info. We always reload when this changes, eg. due to logging in // or out, so we cache this at startup. static async load_local_info() { if(ppixiv.local_api.local_url == null) return; this._cached_api_info = await local_api.local_post_request(\`/api/info\`); } static get local_info() { let info = this._cached_api_info; if(ppixiv.local_api.local_url == null) info = { success: false, code: "disabled" }; return { // True if the local API is enabled at all. enabled: ppixiv.local_api.local_url != null, // True if we're running on localhost. If we're local, we're always logged // in and we won't show the login/logout buttons. local: info.success && info.local, // True if we're logged in as a non-guest user. logged_in: info.success && info.username != "guest", // True if we're logged out and guest access is disabled, so we need to log // in to continue. login_required: !info.success && info.code == 'access-denied', // True if we can only do bookmark tag searches. bookmark_tag_searches_only: info.tags != null, } } // Return true if we're running on localhost. If we're local, we're always logged // in and we won't show the login/logout buttons. static async is_local() { let info = await local_api.local_post_request(\`/api/info\`); return info.local; } // Return true if we're logged out and guest access is disabled, so we need to log // in to continue. static async login_required() { // If we're not logged in and guest access is disabled, all API calls will // fail with access-denied. Call api/info to check this. let info = await local_api.local_post_request(\`/api/info\`); return !info.success && info.code == 'access-denied'; } // Return true if we're logged in as a non-guest user. static async logged_in() { let info = await local_api.local_post_request(\`/api/info\`); console.log(info); return info.success && info.username != "guest"; } // Log out if we're logged in, and redirect to the login page. static redirect_to_login() { //document.cookie = \`auth_token=; max-age=0; path=/\`; let query = new URLSearchParams(); query.set("url", document.location.href); let login_url = "/client/resources/auth.html?" + query.toString(); console.log(login_url); // Replace the current history entry. This pushes any history state to the // login page. It'll preserve it after logging in and redirecting back here, // so we'll try to retain it. window.history.replaceState(history.state, "", login_url.toString()); document.location.reload(); } // Log out and reload the page. static logout() { document.cookie = \`auth_token=; max-age=0; path=/\`; document.location.reload(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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.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: \`
▶ ⌛
\`}); // 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; } ondblclick = async(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_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.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; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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; } // Don't fire the event if nothing is actually changing. This happens a lot when new tabs // are opened and they broadcast current mutes. if(JSON.stringify(this.pixiv_muted_tags) == JSON.stringify(data.pixiv_muted_tags) && JSON.stringify(this.pixiv_muted_user_ids) == JSON.stringify(data.pixiv_muted_user_ids)) return; 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: \`
\${ helpers.create_box_link({label: "Note", icon: "warning", classes: ["mute-warning-button"] }) }
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.
\${ helpers.create_box_link({label: "Add", icon: "add", classes: ["add-muted-tag"] }) }
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(); } } // A popup for editing mutes related for a post (the user and the post's tags). ppixiv.muted_tags_for_post_popup = class extends ppixiv.dialog_widget { constructor({ media_id, user_id, ...options}) { super({...options, visible: true, template: \`
Edit mutes \${ helpers.create_box_link({label: "Note", icon: "warning", classes: ["mute-warning-button", "clickable"] }) }
\`}); this.media_id = media_id; this.user_id = user_id; this.container.querySelector(".close-button").addEventListener("click", (e) => { this.shutdown(); }, { signal: this.shutdown_signal.signal }); // 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 }); 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.refresh(); } refresh = async() => { if(this.media_id != null) { // We have a media ID. Load its info to get the tag list, and use the user ID and // username from it. let illust_data = await thumbnail_data.singleton().get_or_load_illust_data(this.media_id); let tags = []; for(let tag of illust_data.tags.tags) tags.push(tag.tag); await this.refresh_for_data(tags, illust_data.userId, illust_data.userName); } else { // We only have a user ID, so look up the user to get the username. Don't display // any tags. let user_info = await image_data.singleton().get_user_info(this.user_id); await this.refresh_for_data([], this.user_id, user_info.name); } } async refresh_for_data(tags, user_id, username) { // Do a batch lookup of muted tag translations. let translated_tags = await tag_translations.get().get_translations(tags); let create_entry = (label, is_muted) => { let entry = this.create_template({name: "muted-tag-or-user-entry", html: \`
\${ helpers.create_box_link({label: "Mute", classes: ["toggle-mute"] }) }
\`}); helpers.set_class(entry, "muted", is_muted); entry.querySelector(".toggle-mute .label").innerText = is_muted? "Muted":"Mute"; entry.querySelector(".tag-name").innerText = label; muted_list.appendChild(entry); return entry; }; let muted_list = this.container.querySelector(".post-mute-list"); helpers.remove_elements(muted_list); // Add an entry for the user. { let is_muted = muting.singleton.is_muted_user_id(user_id); let entry = create_entry(\`User: \${username}\`, is_muted); entry.querySelector(".toggle-mute").addEventListener("click", async (e) => { if(is_muted) { actions.remove_extra_mute(user_id, {type: "user"}); await actions.remove_pixiv_mute(user_id, {type: "user"}); } else { await actions.add_mute(user_id, username, {type: "user"}); } this.refresh(); }); } // Add each tag on the image. for(let tag of tags) { let is_muted = muting.singleton.any_tag_muted([tag]); let label = tag; let tag_translation = translated_tags[tag]; if(tag_translation) label = \`\${tag_translation} (\${tag})\`; let entry = create_entry(label, is_muted); entry.querySelector(".toggle-mute").addEventListener("click", async (e) => { if(is_muted) { actions.remove_extra_mute(tag, {type: "tag"}); await actions.remove_pixiv_mute(tag, {type: "tag"}); } else { await actions.add_mute(tag, tag, {type: "tag"}); } this.refresh(); }); } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/muting.js `; ppixiv.resources["src/editing.js"] = `"use strict"; ppixiv.ImageEditor = class extends ppixiv.illust_widget { constructor({onvisibilitychanged, ...options}) { super({...options, template: \`
\${ helpers.create_box_link({icon: "save", popup: "Save", classes: ["save-edits", "popup-bottom"] }) } \${ helpers.create_box_link({icon: "refresh", popup: "Saving...", classes: ["spinner"] }) } \${ helpers.create_box_link({icon: "crop", popup: "Crop", classes: ["show-crop", "popup-bottom"] }) } \${ helpers.create_box_link({icon: "wallpaper",popup: "Edit panning", classes: ["show-pan", "popup-bottom"] }) } \${ helpers.create_box_link({icon: "brush", popup: "Inpainting", classes: ["show-inpaint", "popup-bottom"] }) } \${ helpers.create_box_link({icon: "close", popup: "Stop editing", classes: ["close-editor", "popup-bottom"] }) }
\`}); this.container.querySelector(".spinner").hidden = true; let crop_editor = new ppixiv.CropEditor({ container: this.container, parent: this, mode: "crop", }); let pan_editor = new ppixiv.PanEditor({ container: this.container, parent: this, }); let inpaint_editor = new ppixiv.InpaintEditor({ container: this.container, parent: this, }); this.editors = { inpaint: inpaint_editor, crop: crop_editor, pan: pan_editor, }; 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) => { this.active_editor_name = this.active_editor_name == "crop"? null:"crop"; }); this.show_pan = this.container.querySelector(".show-pan"); this.show_pan.addEventListener("click", (e) => { this.active_editor_name = this.active_editor_name == "pan"? null:"pan"; }); this.show_inpaint = this.container.querySelector(".show-inpaint"); this.show_inpaint.hidden = true; this.show_inpaint.addEventListener("click", (e) => { this.active_editor_name = this.active_editor_name == "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 }); this.close_editor = this.container.querySelector(".close-editor"); this.close_editor.addEventListener("click", async (e) => { settings.set("image_editing", null); settings.set("image_editing_mode", null); }, { 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. let inpaint_buttons = this.editors.inpaint.container.querySelector(".image-editor-button-row"); inpaint_buttons.remove(); this.container.querySelector(".image-editor-buttons.bottom").appendChild(inpaint_buttons); let pan_buttons = this.editors.pan.container.querySelector(".image-editor-button-row"); pan_buttons.remove(); this.container.querySelector(".image-editor-buttons.bottom").appendChild(pan_buttons); } // Return true if the crop editor is active. get editing_crop() { return settings.get("image_editing", false) && this.active_editor_name == "crop"; } shutdown() { for(let editor of Object.values(this.editors)) editor.shutdown(); super.shutdown(); } visibility_changed() { settings.set("image_editing", this.visible); // Refresh to update editor visibility. this.refresh(); this.onvisibilitychanged(); super.visibility_changed(); } // In principle we could refresh from thumbnail data if this is the first manga page, since // all we need is the image dimensions. However, the editing container is only displayed // by on_click_viewer after we have full image data anyway since it's treated as part of the // main image, so we won't be displayed until then anyway. async refresh_internal({ media_id, illust_data }) { // We can get the media ID before we have illust_data. Ignore it until we have both. if(illust_data == null) media_id = null; let editor_is_open = this.open_editor != null; let media_id_changing = media_id != this.editing_media_id; this.editing_media_id = media_id; // Only tell the editor to replace its own data if we're changing images, or the // editor is closed. If the editor is open and we're not changing images, don't // clobber ongoing edits. let replace_editor_data = media_id_changing || !editor_is_open; // For local images, editing data is simply stored as a field on the illust data, which // we can save to the server. // // For Pixiv images, we store editing data locally in IndexedDB. All pages are stored on // the data for the first page, as an extraData dictionary with page media IDs as keys. // // Pull out the dictionary containing editing data for this image to give to the editor. let { width, height } = image_data.get_dimensions(illust_data, media_id); let extra_data = image_data.get_extra_data(illust_data, media_id); // Give the editors the new illust data. for(let editor of Object.values(this.editors)) editor.set_illust_data({ media_id, extra_data, width, height, replace_editor_data }); // If no editor is open, make sure the undo stack is cleared and clear dirty. if(!editor_is_open) { // Otherwise, just make sure the undo stack is cleared. this.undo_stack = []; this.redo_stack = []; this.dirty = false; } } get open_editor() { for(let editor of Object.values(this.editors)) { if(editor.visible) return editor; } return null; } // This is called when the ImageEditingOverlayContainer changes. set overlay_container(overlay_container) { for(let editor of Object.values(this.editors)) 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 is_local = helpers.is_media_id_local(this.media_id); if(this.media_id != null) this.show_inpaint.hidden = !is_local; this.show_pan.hidden = !settings.get("experimental", false); let showing_crop = this.active_editor_name == "crop" && this.visible; this.editors.crop.visible = showing_crop; helpers.set_class(this.show_crop, "selected", showing_crop); let showing_pan = this.active_editor_name == "pan" && this.visible; this.editors.pan.visible = showing_pan; helpers.set_class(this.show_pan, "selected", showing_pan); let showing_inpaint = is_local && this.active_editor_name == "inpaint" && this.visible; this.editors.inpaint.visible = 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.editors.inpaint.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() { let result = {}; for(let [name, editor] of Object.entries(this.editors)) result[name] = editor.get_state(); return result; } set_state(state) { for(let [name, editor] of Object.entries(this.editors)) editor.set_state(state[name]); } 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.container.querySelector(".spinner"); this.save_edits.hidden = true; spinner.hidden = false; try { // Get data from each editor, so we can save them together. let edits = { }; for(let editor of Object.values(this.editors)) { for(let [key, value] of Object.entries(editor.get_data_to_save())) edits[key] = value; } let result; if(helpers.is_media_id_local(this.media_id)) { 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; } result = result.illust; image_data.singleton().update_media_info(this.media_id, result); } else { // Save data for Pixiv images to image_data. result = await image_data.singleton().save_extra_image_data(this.media_id, edits); } // Let the widgets know that we saved. let current_editor = this.active_editor; if(current_editor?.after_save) current_editor.after_save(result); } finally { this.save_edits.hidden = false; spinner.hidden = true; } } get active_editor_name() { return settings.get("image_editing_mode", null); } set active_editor_name(editor_name) { if(editor_name != null && this.editors[editor_name] == null) throw new Error(\`Invalid editor name \${editor_name}\`); settings.set("image_editing_mode", editor_name); } get active_editor() { let current_editor = this.active_editor_name; if(current_editor == null) return null; else return this.editors[current_editor]; } 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.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 = helpers.create_style(\` .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/r137/src/editing.js `; ppixiv.resources["src/editing_crop.js"] = `"use strict"; ppixiv.CropEditor = class extends ppixiv.widget { constructor({...options}) { super({...options, template: \`
\`}); 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, { signal: this.shutdown_signal.signal }); this.pointer_listener = new ppixiv.pointer_listener({ element: this.editor_overlay, callback: this.pointerevent, signal: this.shutdown_signal.signal, }); this.box = this.editor_overlay.querySelector(".crop-box"); this.refresh(); } // Clear the crop on double-click. ondblclick = (e) => { e.preventDefault(); e.stopPropagation(); this.parent.save_undo(); this.current_crop = null; this.refresh(); } pointerevent = (e) => { if(!e.pressed) { e.preventDefault(); e.stopPropagation(); window.removeEventListener("pointermove", this.pointermove); // If the crop was inverted, fix it up now. this.current_crop = this.effective_crop; return; } let clicked_handle = null; if(this.current_crop == null) { let {x,y} = this.client_to_container_pos({ x: e.clientX, y: e.clientY }); this.current_crop = new FixedDOMRect(x, y, x, y); clicked_handle = "bottomright"; } else clicked_handle = e.target.dataset.crop; if(clicked_handle == null) return; e.preventDefault(); e.stopPropagation(); this.parent.save_undo(); // Which dimensions each handle moves: let drag_parts = { all: "move", topleft: {y: "y1", x: "x1"}, top: {y: "y1"}, topright: {y: "y1", x: "x2"}, left: {x: "x1"}, right: {x: "x2"}, bottomleft: {y: "y2", x: "x1"}, bottom: { y: "y2" }, bottomright: { x: "x2", y: "y2" }, } window.addEventListener("pointermove", this.pointermove); this.dragging = drag_parts[clicked_handle]; this.drag_pos = this.client_to_container_pos({ x: e.clientX, y: e.clientY }); this.refresh(); } client_to_container_pos({x, y}) { let {width, height, top, left} = this.editor_overlay.getBoundingClientRect(); x -= left; y -= top; // Scale movement from client coordinates to the size of the container. x *= this.width / width; y *= this.height / height; return {x, 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 pos = this.client_to_container_pos({ x: e.clientX, y: e.clientY }); let delta = { x: pos.x - this.drag_pos.x, y: pos.y - this.drag_pos.y }; this.drag_pos = pos; // 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 { let dragging = this.dragging; if(dragging.x != null) this.current_crop[dragging.x] += delta.x; if(dragging.y != null) this.current_crop[dragging.y] += delta.y; } this.refresh(); } // Return the current crop. If we're dragging, clean up the rectangle, making sure it // has a minimum size and isn't inverted. get effective_crop() { // If we're not dragging, just return the current crop rectangle. if(this.dragging == null) return this.current_crop; let crop = new FixedDOMRect( this.current_crop.x1, this.current_crop.y1, this.current_crop.x2, this.current_crop.y2, ); // Keep the rect from being too small. If the width is too small, push the horizontal // edge we're dragging away from the other side. if(this.dragging != "move") { let opposites = { x1: "x2", x2: "x1", y1: "y2", y2: "y1", } let min_size = 5; if(this.dragging.x != null && Math.abs(crop.width) < min_size) { let opposite_x = opposites[this.dragging.x]; if(crop[this.dragging.x] < crop[opposite_x]) crop[this.dragging.x] = crop[opposite_x] - min_size; else crop[this.dragging.x] = crop[opposite_x] + min_size; } if(this.dragging.y != null && Math.abs(crop.height) < min_size) { let opposite_y = opposites[this.dragging.y]; if(crop[this.dragging.y] < crop[opposite_y]) crop[this.dragging.y] = crop[opposite_y] - min_size; else crop[this.dragging.y] = crop[opposite_y] + min_size; } } // If we've dragged across the opposite edge, flip the sides back around. crop = new FixedDOMRect(crop.left, crop.top, crop.right, crop.bottom); // Clamp to the image bounds. crop = new FixedDOMRect( Math.max(crop.left, 0), Math.max(crop.top, 0), Math.min(crop.right, this.width), Math.min(crop.bottom, this.height), ); return crop; } refresh() { let box = this.editor_overlay.querySelector(".crop-box"); box.hidden = this.current_crop == null; if(this.current_crop == null) return; let crop = this.effective_crop; box.style.width = \`\${100 * crop.width / this.width}%\`; box.style.height = \`\${100 * crop.height / this.height}%\`; box.style.left = \`\${100 * crop.left / this.width}%\`; box.style.top = \`\${100 * 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({replace_editor_data, extra_data, width, height}) { if(extra_data == null) return; this.width = width; this.height = height; this.box.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); if(replace_editor_data) this.set_state(extra_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 state = this.get_state(); return { crop: state, }; } async after_save(illust) { // Disable cropping after saving, so the crop is visible. settings.set("image_editing_mode", null); } get_state() { if(this.current_crop == null) return null; let crop = this.effective_crop; return [ Math.round(crop.left), Math.round(crop.top), Math.round(crop.right), Math.round(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/r137/src/editing_crop.js `; ppixiv.resources["src/editing_inpaint.js"] = `"use strict"; ppixiv.InpaintEditor = class extends ppixiv.widget { constructor(options) { super({...options, template: \`
\${ helpers.create_box_link({label: "View", classes: ["view-inpaint"] }) } \${ helpers.create_box_link({label: "Create lines", classes: ["create-lines"] }) }
\`}); 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; this.ui = this.container.querySelector(".image-editor-button-row"); // 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, 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, { 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; this.ui.hidden = !this.visible; } set_illust_data({replace_editor_data, extra_data, width, height}) { // Scale the thickness slider to the size of the image. let size = Math.min(width, height); this.line_width_slider.max = size / 25; if(replace_editor_data) { this.clear(); this.set_state(extra_data.inpaint); } if(extra_data == null) return; // Match the size of the image. this.set_size(width, height); // If there's no data at all, load the user's defaults. if(extra_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_state({for_saving: true}), } } async after_save(illust) { if(illust.urls == null) return; 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. image_data.singleton().update_media_info(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_state({for_saving=false}={}) { if(for_saving && this.lines.length == 0) return null; 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_state(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 && !e.pressed) { // 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/r137/src/editing_inpaint.js `; ppixiv.resources["src/editing_pan.js"] = `"use strict"; // This allows editing simple pan animations, to give finer control over slideshows. ppixiv.PanEditor = class extends ppixiv.widget { constructor(options) { super({...options, template: \`
\${ helpers.create_box_link({popup: "Edit start", icon: "first_page", classes: ["edit-start-button"] }) } \${ helpers.create_box_link({popup: "Swap start and end", icon: "swap_horiz", classes: ["swap-button"] }) } \${ helpers.create_box_link({popup: "Edit end", icon: "last_page", classes: ["edit-end-button"] }) } \${ helpers.create_box_link({popup: "Edit anchor", icon: "anchor", classes: ["edit-anchor"] }) } \${ helpers.create_box_link({popup: "Portrait/landscape", icon: "panorama", classes: ["rotate-aspect-ratio"] }) } \${ helpers.create_box_link({popup: "Clear animation", icon: "delete", classes: ["reset-button"] }) }
\`}); this.shutdown_signal = new AbortController(); this.width = this.height = 100; this.dragging = false; this.drag_start = null; this.anchor = new FixedDOMRect(0.5, 0.5, 0.5, 0.5); this.aspect_ratios = [ [21, 9], [16, 9], [16, 10], [4, 3], ]; // is_set is false if we've had no edits and we're displaying the defaults, or true if we // have data that can be saved. this.is_set = false; this.zoom_level = [1,1]; // start, end this.displayed_aspect_ratio = 1; this.displayed_aspect_ratio_portrait = false; this.editing = "start"; // "start" or "end" this.editing_anchor = false; this.ui = this.container.querySelector(".image-editor-button-row"); this.monitor_preview_box = this.container.querySelector(".monitor-preview-box"); // Remove .pan-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(".pan-editor-overlay"); this.editor_crop_region = this.container.querySelector(".pan-editor-crop-region"); this.editor_overlay.remove(); this.editor_overlay.slot = "crop-editor"; // XXX merge these this.handle = this.editor_overlay.querySelector(".handle"); // The real zoom value is the amount the image will be zoomed onscreen: if it's set // to 2, the image is twice as big. The zoom slider is inverted: a slider value of // 1/2 gives a zoom of 2. This makes the zoom slider scale the size of the monitor // box linearly and feels more natural. this.zoom_slider = this.ui.querySelector(".zoom-slider"); // Use watch_edits to save undo at the start of inputs being dragged. helpers.watch_edits(this.zoom_slider, { signal: this.shutdown_signal.signal }); this.zoom_slider.addEventListener("editbegin", (e) => { this.parent.save_undo(); this.is_set = true; }); this.zoom_slider.addEventListener("edit", (e) => { // console.log(e); let value = parseInt(this.zoom_slider.value) / 100; value = 1 / value; this.zoom_level[this.editing_index] = value; this.refresh(); }); // The preview size slider changes the monitor aspect ratio that we're previewing. this.aspect_ratio_slider = this.ui.querySelector(".aspect-ratio-slider input"); this.aspect_ratio_slider.addEventListener("input", (e) => { this.displayed_aspect_ratio = parseInt(this.aspect_ratio_slider.value); this.refresh(); }); this.aspect_ratio_switch_button = this.container.querySelector(".rotate-aspect-ratio"); this.aspect_ratio_switch_button.addEventListener("click", (e) => { this.displayed_aspect_ratio_portrait = !this.displayed_aspect_ratio_portrait; this.refresh(); }); this.ui.querySelector(".edit-start-button").addEventListener("click", (e) => { this.editing = "start"; this.refresh(); }); this.ui.querySelector(".edit-end-button").addEventListener("click", (e) => { this.editing = "end"; this.refresh(); }); this.ui.querySelector(".edit-anchor").addEventListener("click", (e) => { this.editing_anchor = !this.editing_anchor; this.refresh(); }); this.ui.querySelector(".reset-button").addEventListener("click", (e) => { this.clear(); }); this.ui.querySelector(".swap-button").addEventListener("click", (e) => { this.swap(); }); this.pointer_listener = new ppixiv.pointer_listener({ element: this.editor_overlay, callback: this.pointerevent, signal: this.shutdown_signal.signal, }); // Prevent fullscreening if a UI element is double-clicked. this.editor_overlay.addEventListener("dblclick", this.ondblclick, { signal: this.shutdown_signal.signal }); } // Return 0 if we're editing the start point, or 1 if we're editing the end point. get editing_index() { return this.editing == "start"? 0:1; } get actually_editing_anchor() { return this.editing_anchor ^ this.shift_held; } // 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; } clear() { if(!this.is_set) return; this.parent.save_undo(); this.set_state(null); } // Swap the start and end points. swap() { this.parent.save_undo(); this.is_set = true; this.rect = new FixedDOMRect(this.rect.x2, this.rect.y2, this.rect.x1,this.rect.y1); this.anchor = new FixedDOMRect(this.anchor.x2, this.anchor.y2, this.anchor.x1, this.anchor.y1); this.zoom_level = [this.zoom_level[1], this.zoom_level[0]]; this.refresh(); } get preview_size() { let result = this.aspect_ratios[this.displayed_aspect_ratio]; if(this.displayed_aspect_ratio_portrait) return [result[1], result[0]]; else return result; } refresh() { super.refresh(); if(!this.visible) return; let zoom = this.zoom_level[this.editing_index]; this.zoom_slider.value = 1 / zoom * 100; helpers.set_class(this.ui.querySelector(".edit-start-button"), "selected", this.editing == "start"); helpers.set_class(this.ui.querySelector(".edit-end-button"), "selected", this.editing == "end"); helpers.set_class(this.ui.querySelector(".edit-anchor"), "selected", this.actually_editing_anchor); this.aspect_ratio_switch_button.dataset.popup = this.displayed_aspect_ratio_portrait? "Previewing portrait":"Previewing landscape"; this.aspect_ratio_switch_button.querySelector(".material-icons").innerText = this.displayed_aspect_ratio_portrait? "portrait":"panorama"; this.aspect_ratio_slider.value = this.displayed_aspect_ratio; this.ui.querySelector(".aspect-ratio-slider").dataset.popup = \`Previewing \${this.preview_size[0]}:\${this.preview_size[1]}\`; this.refresh_zoom_preview(); this.refresh_center(); } // Refresh the position of the center handle. refresh_center() { let { x, y } = this.editing == "start"? { x: this.rect.x1, y: this.rect.y1 }: { x: this.rect.x2, y: this.rect.y2 }; x *= this.width; y *= this.height; this.handle.querySelector(".crosshair").setAttribute("transform", \`translate(\${x} \${y})\`); } visibility_changed() { super.visibility_changed(); this.editor_overlay.hidden = !this.visible; this.ui.hidden = !this.visible; if(this.visible) { // Listen for shift presses while we're visible. new ppixiv.key_listener("Shift", (pressed) => { this.shift_held = pressed; this.refresh(); }, { signal: this.visibility_abort.signal }); this.refresh(); } else { this.shift_held = false; } } set_illust_data({replace_editor_data, extra_data, width, height}) { // Match the size of the image. this.width = width; this.height = height; // Handling crops and pans together is tricky. The pan values are relative to the cropped // area: panning to 0.5x0.5 always goes to the center of the crop region, not the original // image. But, these editors are all positioned and scaled relative to the original image. // This editor wants to be relative to the crop, so we scale and shift our own area relative // to the crop if there is one. if(extra_data?.crop) { let crop = new FixedDOMRect(extra_data.crop[0], extra_data.crop[1], extra_data.crop[2], extra_data.crop[3]); this.width = crop.width; this.height = crop.height; this.editor_crop_region.style.width = \`\${100 * crop.width / width}%\`; this.editor_crop_region.style.height = \`\${100 * crop.height / height}%\`; this.editor_crop_region.style.top = \`\${100 * crop.top / height}%\`; this.editor_crop_region.style.left = \`\${100 * crop.left / width}%\`; } else { this.editor_crop_region.style.width = this.editor_crop_region.style.height = \`\`; this.editor_crop_region.style.top = this.editor_crop_region.style.left = \`\`; } this.handle.setAttribute("viewBox", \`0 0 \${this.width} \${this.height}\`); if(replace_editor_data) this.set_state(extra_data?.pan); this.refresh(); } get_data_to_save() { return { pan: this.get_state() }; } async after_save(illust) { // Update the illust info. // // 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); return true; } // Return data for saving. get_state({force=false}={}) { if(!force && !this.is_set) return null; // These are stored as unit values, so we don't need to know the image dimensions to // set them up. let result = { x1: this.rect.x1, y1: this.rect.y1, x2: this.rect.x2, y2: this.rect.y2, start_zoom: this.zoom_level[0], end_zoom: this.zoom_level[1], }; // Only include the anchor if it's been changed from the default. if(Math.abs(this.anchor.x1 - 0.5) > 0.001 || Math.abs(this.anchor.y1 - 0.5) > 0.001 || Math.abs(this.anchor.x2 - 0.5) > 0.001 || Math.abs(this.anchor.y2 - 0.5) > 0.001) { result.anchor = { left: this.anchor.x1, top: this.anchor.y1, right: this.anchor.x2, bottom: this.anchor.y2, }; } return result; } set_state(data) { this.is_set = data != null; this.anchor = new FixedDOMRect(0.5, 0.5, 0.5, 0.5); if(data == null) { this.rect = new FixedDOMRect(0, 0, 1, 1); this.zoom_level = [1,1]; } else { this.rect = new FixedDOMRect(data.x1, data.y1, data.x2, data.y2); if(data.anchor) this.anchor = new FixedDOMRect(data.anchor.left, data.anchor.top, data.anchor.right, data.anchor.bottom); this.zoom_level = [data.start_zoom, data.end_zoom]; } this.refresh(); } get_current_slideshow({...options}={}) { // this.height/this.width is the size of the image. Scale it to cover preview_width/preview_height, // as if we're on_click_viewer displaying it. If the animation tells us to scale to 1x, it wants // to cover the screen. let [preview_width, preview_height] = this.preview_size; let scale_ratio = Math.max(preview_width/this.width, preview_height/this.height); let scaled_width = this.width * scale_ratio, scaled_height = this.height * scale_ratio; // The minimum zoom is the zoom that will fit the image onscreen. This also matches on_click_viewer. let cover_ratio = Math.min(preview_width/scaled_width, preview_height/scaled_height); let slideshow = new ppixiv.slideshow({ width: scaled_width, height: scaled_height, container_width: preview_width, container_height: preview_height, // The minimum zoom level to allow: minimum_zoom: cover_ratio, // If true, we're being used for slideshow mode, otherwise auto-pan mode. slideshow_enabled: false, // The position is normally clamped to the screen. If we're editing the anchor, disable this to // display the position of the box before it's clamped. clamp_to_window: !this.actually_editing_anchor, ...options }); // Get the animation that we'd currently save, and load it as a slideshow. let pan_animation = this.get_state({force: true}); let animation = slideshow.get_animation_from_pan(pan_animation); return { animation, scaled_width, scaled_height, preview_width, preview_height }; } // Refresh the position and size of the monitor preview box. refresh_zoom_preview() { // Instead of moving the image around inside the monitor, scale the box to the size // of the preview "monitor", and scale/translate it around to show how the image would // fit inside it. let { animation, scaled_width, scaled_height, preview_width, preview_height } = this.get_current_slideshow(); let pan = animation.pan[this.editing_index]; let box = this.monitor_preview_box.querySelector(".box"); box.style.width = \`\${100 * preview_width / scaled_width}%\`; box.style.height = \`\${100 * preview_height / scaled_height}%\`; let tx = 100 * -pan.computed_tx / scaled_width; let ty = 100 * -pan.computed_ty / scaled_height; // Apply the zoom by scaling the box's parent. Scaling inside style.transform makes this simpler, // but makes things like outlines ugly. this.monitor_preview_box.style.width = \`\${100 / pan.computed_zoom}%\`; this.monitor_preview_box.style.height = \`\${100 / pan.computed_zoom}%\`; this.monitor_preview_box.style.transform = \` translateX(\${tx}%) translateY(\${ty}%) \`; } pointerevent = (e) => { if(e.pressed) { e.preventDefault(); e.stopPropagation(); this.dragging = true; this.drag_saved_undo = false; this.drag_pos = [e.clientX, e.clientY]; window.addEventListener("pointermove", this.pointermove_drag_point); return; } else if(this.dragging != -1 && !e.pressed) { // We stopped dragging. this.dragging = false; window.removeEventListener("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 }; } pointermove_drag_point = (e) => { // Save undo for this drag if we haven't yet. if(!this.drag_saved_undo) { this.parent.save_undo(); this.drag_saved_undo = true; } // 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_crop_region.getBoundingClientRect(); delta_x /= width; delta_y /= height; // Check if we're editing the pan position or the anchor. let editing_anchor = this.actually_editing_anchor; if(editing_anchor) { let { animation, scaled_width, scaled_height, preview_width, preview_height } = this.get_current_slideshow(); let pan = animation.pan[this.editing_index]; // If we add 1 to anchor.x1, we'll move the anchor one screen width to the right. // Scale this to the monitor preview that's currently visible. This makes the speed // of dragging the anchor point match the current display. // // Moving the anchor will also move the view, so we also adjust the view position by // the same amount below. This cancels out the movement of the anchor, so the display // position is stationary as we move the anchor. let monitor_width = (preview_width / scaled_width) / pan.computed_zoom; let monitor_height = (preview_height / scaled_height) / pan.computed_zoom; if(this.editing == "start") { this.anchor.x1 += delta_x / monitor_width; this.anchor.y1 += delta_y / monitor_height; } else { this.anchor.x2 += delta_x / monitor_width; this.anchor.y2 += delta_y / monitor_height; } } // Drag the rect. let rect = new FixedDOMRect(this.rect.x1, this.rect.y1, this.rect.x2, this.rect.y2); if(this.editing == "start") { rect.x1 += delta_x; rect.y1 += delta_y; } else { rect.x2 += delta_x; rect.y2 += delta_y; } this.rect = rect; this.is_set = true; this.refresh(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/editing_pan.js `; ppixiv.resources["src/menu_option.js"] = `"use strict"; ppixiv.settings_dialog = class extends ppixiv.dialog_widget { constructor({show_page="thumbnail", ...options}) { super({visible: true, ...options, 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 if the top-level screen changes, so we close if the user exits the screen with browser // navigation but not if the viewed image is changing from something like the slideshow. window.addEventListener("screenchanged", (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_setting({ ...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_setting({ ...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_setting({ ...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_setting({ ...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_setting({ ...global_options, label: "Hover to show search box", setting: "ui-on-hover", refresh: 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_setting({ ...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_setting({ ...global_options, label: "Light mode", setting: "theme", on_value: "light", off_value: "dark", explanation_enabled: "FLASHBANG", }); }, disable_translations: () => { return new menu_option_toggle_setting({ ...global_options, label: "Show tag translations when available", setting: "disable-translations", invert_display: true, }); }, disable_thumbnail_panning: () => { return new menu_option_toggle_setting({ ...global_options, label: "Pan thumbnails while hovering over them", setting: "disable_thumbnail_panning", invert_display: true, }); }, disable_thumbnail_zooming: () => { return new menu_option_toggle_setting({ ...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_setting({ ...global_options, label: "Bookmark and follow 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_setting({ ...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_setting({ ...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. refresh: 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. refresh: 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; }, slideshow_skips_manga: () => { return new menu_option_toggle_setting({ ...global_options, label: "Slideshow skips manga pages", setting: "slideshow_skips_manga", explanation_enabled: "Slideshow mode will only show the first page.", explanation_disabled: "Slideshow mode will show all pages.", }); }, expand_manga_posts: () => { return new menu_option_toggle_setting({ ...global_options, label: "Expand manga posts in search results", setting: "expand_manga_thumbnails", }); }, no_recent_history: () => { return new menu_option_toggle_setting({ ...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_setting({ ...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_setting({ ...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_list: true }); 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(); settings_widgets.expand_manga_posts(); this.create_page("image", "Image viewing", global_options, { settings_list: true }); settings_widgets.auto_pan(); settings_widgets.auto_pan_speed(); settings_widgets.slideshow_speed(); if(!ppixiv.native) // native mode doesn't support manga pages settings_widgets.slideshow_skips_manga(); settings_widgets.view_mode(); settings_widgets.invert_scrolling(); settings_widgets.no_hide_cursor(); if(!ppixiv.native) { 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, { settings_list: true }); 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_list: true }); 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, {settings_list=false}={}) { let page = this.create_template({name: "settings-page", html: \`
\`}); // If settings_list is true, this page is a list of options, like the thumbnail options // page. This class enables styling for these lists. If it's another type of settings // page with its own styling, this is disabled. if(settings_list) page.classList.add("settings-list"); this.items.appendChild(page); global_options.container = page; let page_button = this.create_template({ html: helpers.create_box_link({ label: title, classes: ["settings-page-button"], }), }); 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; if(this.muted_tags) this.muted_tags.visible = this.visible && this.visible_page == this.pages.tag_muting; if(this.muted_users) 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=[], refresh=null, ...options }) { super(options); for(let class_name of classes) this.container.classList.add(class_name); this.onrefresh = refresh; } refresh() { if(this.onrefresh) this.onrefresh(); } } // 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}) { super({...options, template: \` \${helpers.create_box_link({ label: label, // If a font icon was specified, use it. Otherwise, if an image icon: options.icon, link: url, classes: ["menu-toggle"], explanation: "", // create the explanation field })} \`}); // Add a flex block to the right of the label, to push buttons to the right: let flex = document.createElement("div"); flex.style.flex = 1; this.container.appendChild(flex); this.onclick_handler = onclick; this._enabled = true; this.explanation_enabled = explanation_enabled; this.explanation_disabled = explanation_disabled; this.get_label = get_label; // 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(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) { // XXX: check callers // 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: helpers.create_box_link({label: "", classes: ["clickable"] })}); 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({ buttons=[], checked=false, ...options }) { // Add a checkbox_widget to the button list. let checkbox = new checkbox_widget({ }); buttons = [ ...buttons, checkbox, ]; super({ buttons, ...options}); this.checkbox = checkbox; this.checkbox.checked = checked; } } ppixiv.menu_option_toggle_setting = class extends ppixiv.menu_option_toggle { constructor({ setting=null, onclick=null, // 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, onclick: (e) => { if(this.options && this.options.check && !this.options.check()) return; this.value = !this.value; // Call the user's onclick, if any. if(onclick) onclick(e); }, }); this.setting = setting; this.on_value = on_value; this.off_value = off_value; if(this.setting) settings.changes.addEventListener(this.setting, this.refresh.bind(this), { signal: this.shutdown_signal.signal }); } refresh() { super.refresh(); var value = this.value; if(this.options.invert_display) value = !value; this.checkbox.checked = value; // 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.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/r137/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 || current_page > 0) 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, get dimensions from mangaPages from illust data. If we're displaying a // manga post and we don't have illust data yet, we don't have dimensions, so hide it until // it's loaded. var info = ""; let width = null, height = null; if(this._page == 0) { width = illust_data.width; height = illust_data.height; } else if(illust_data.mangaPages) { 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.visible = false; this.hide = this.hide.bind(this); 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); // 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._on_click_viewer = null; this._media_id = null; // Refresh the menu when the view changes. this.mode_observer = new MutationObserver((mutationsList, observer) => { for(var mutation of mutationsList) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-current-view") this.refresh(); } } }); 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-view-manga").addEventListener("click", this.clicked_view_manga); this.container.querySelector(".button-fullscreen").addEventListener("click", this.clicked_fullscreen); this.container.querySelector(".button-zoom").addEventListener("click", this.clicked_zoom_toggle); 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); for(var button of this.container.querySelectorAll(".button-zoom-level")) button.addEventListener("click", this.clicked_zoom_level); 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; } if(e.key.toUpperCase() == "P") { let enable = !ppixiv.settings.get("auto_pan", false); ppixiv.settings.set("auto_pan", enable); message_widget.singleton.show(\`Image panning \${enable? "enabled":"disabled"}\`); 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 with default privacy // 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 = null; if(e.altKey) follow_privately = true; 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() { let button_view_manga = this.container.querySelector(".button-view-manga"); button_view_manga.dataset.popup = "View manga pages"; helpers.set_class(button_view_manga, "enabled", main_controller.singleton.navigate_out_enabled); 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_view_manga = (e) => { 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/r137/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 media_id is on and the index within the page. // // If check_first_page is true and media_id isn't in the list, try the first page // of media_id too, so if we're looking for page 3 of a manga post and the data // source only contains the first page, we'll use that. get_page_for_illust(media_id, { check_first_page=true }={}) { for(let [page, ids] of this.media_ids_by_page) { let idx = ids.indexOf(media_id); if(idx != -1) return { page, idx, media_id }; } if(!check_first_page) return { }; // Try the first page. media_id = helpers.get_media_id_first_page(media_id); for(let [page, ids] of this.media_ids_by_page) { let idx = ids.indexOf(media_id); if(ids.indexOf(media_id) != -1) return { page, idx, media_id }; } return { }; }; // 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); } } } let { page, idx } = this.get_page_for_illust(media_id); if(page == null) return null; let ids = this.media_ids_by_page.get(page); 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 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; } // Returns true if the data source might return manga pages that the user might want to // expand. This is usually true, except for things like user lists and local files. This // just hides the expand/collapse button at the top when it can't do anything. get can_return_manga() { return true; } // 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(); } // If true, "No Results" will be displayed. get no_results() { return this.id_list.get_first_id() == null && !this.any_page_loading; } // 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. let { page: 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; } // The data source can override this to set the aspect ratio to use for thumbnails. get_thumbnail_aspect_ratio() { return null; } // If true, this data source can return individual manga pages. Most data sources only // return the first page of manga posts. The search UI will only allow the user to expand // manga posts if this is false. get includes_manga_pages() { 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); url = helpers.get_url_without_language(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, "selected", !selected_default); helpers.set_class(box, "selected", !selected_default); // Store the original text, so we can restore it when the default is selected. if(button.dataset.originalText == null) { let button_label = button.querySelector(".label"); button.dataset.originalText = button_label.innerText; } let label = button.querySelector(".label"); // If an option is selected, replace the menu button text with the selection's label. if(selected_default) label.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 text = selected_item.dataset.shortLabel; let selected_label = selected_item.querySelector(".label"); label.innerText = text? text:selected_label.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 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={}) { // See if it's already loaded. let new_media_id = this.id_list.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; if(media_id == null) { next_page = this.initial_page; } else { // Return the page we need to load to get the next or previous illustration. This only // makes sense if get_neighboring_illust returns null. let { page } = this.id_list.get_page_for_illust(media_id); // If media_id isn't loaded, then we're trying to navigate from an image that isn't // actually in the search results. This can happen if the page is reloaded, and we're // showing the previous image but don't have any corresponding search results. if(page == null) { console.log("Don't know the next page for illust", media_id); return null; } next_page = page + (next? +1:-1); if(next_page < 1) 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; await 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("[data-type=all]"), "selected", current_mode == "all"); helpers.set_class(container.querySelector("[data-type=safe]"), "selected", current_mode == "safe"); helpers.set_class(container.querySelector("[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; await 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.id}#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; await 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. await 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) { // We'll load translations for all tags if the tag dropdown is opened, but for now // just load the translation for the selected tag, so it's available for the button text. let current_tag = this.current_tag; if(current_tag != null) { this.translated_tags = await tag_translations.get().get_translations([current_tag], "en"); this.call_update_listeners(); } // 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); await 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. await 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(); 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}\`); return; } this.booth_url = booth.body.url; this.call_update_listeners(); } // If we're filtering a follow tag, return it. Otherwise, return null. get current_tag() { let args = new helpers.args(this.url); return args.query.get("tag"); } refresh_thumbnail_ui(container, thumbnail_view) { thumbnail_view.avatar_container.hidden = false; thumbnail_view.avatar_widget.set_user_id(this.viewing_user_id); this.set_path_item(container, "artist-works", 2, "artworks"); this.set_path_item(container, "artist-illust", 2, "illustrations"); this.set_path_item(container, "artist-manga", 2, "manga"); let current_tag = this.current_tag; // Refresh the post tag list. 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 && this.translated_tags[tag]) translated_tag = this.translated_tags[tag]; let url = new URL(this.url); url.hash = "#ppixiv"; if(tag != "All") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); let a = helpers.create_box_link({ label: translated_tag, classes: ["tag-entry"], popup: tag_info?.cnt, link: url.toString(), as_element: true, }); let match_tag = tag != "All"? tag:null; if(match_tag == current_tag) a.classList.add("selected"); if(tag == "All") a.dataset["default"] = 1; 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. // If a tag is selected, fill in just that tag so the button text works. var span = document.createElement("span"); span.innerText = "Loading..."; tag_list.appendChild(span); add_tag_link({ tag: "All" }); if(current_tag != null) add_tag_link({ tag: current_tag }); } this.set_active_popup_highlight(container); } // 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("/"); } }; // /artworks/illust_id?manga - Viewing manga pages for an illustration ppixiv.data_sources.manga = class extends data_source { get name() { return "manga"; } get includes_manga_pages() { return true; } 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); } async load_page_internal(page) { if(page != 1) return; // We need full illust info for get_manga_aspect_ratio, but we can fill out most of the // UI with thumbnail or illust info. Load whichever one we have first and update, so we // display initial info quickly. this.thumbnail_data = await thumbnail_data.singleton().get_or_load_illust_data(this.media_id); this.call_update_listeners(); // Load media info before continuing. this.illust_info = await image_data.singleton().get_media_info(this.media_id); let data = helpers.parse_media_id(this.media_id); let page_media_ids = []; for(let page = 0; page < this.illust_info.pageCount; ++page) { data.page = page; page_media_ids.push(helpers.encode_media_id(data)); } this.add_page(page, page_media_ids); } get page_title() { if(this.thumbnail_data) return this.thumbnail_data.userName + " - " + this.thumbnail_data.illustTitle; else return "Illustrations"; } get_displaying_text() { if(this.thumbnail_data) return this.thumbnail_data.illustTitle + " by " + this.thumbnail_data.userName; else return "Illustrations"; }; // If all pages of the manga post we're viewing have around the same aspect ratio, use it // for thumbnails. get_thumbnail_aspect_ratio() { if(this.illust_info == null) return null; return helpers.get_manga_aspect_ratio(this.illust_info.mangaPages); } refresh_thumbnail_ui(container, thumbnail_view) { thumbnail_view.avatar_container.hidden = false; thumbnail_view.avatar_widget.set_user_id(this.thumbnail_data?.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(".tag-entry")) tag.remove(); var add_tag_link = (tag) => { let tag_name = tag; if(tag_name == null) tag_name = "All bookmarks"; else if(tag_name == "") tag_name = "Untagged"; let url = new URL(this.url); url.searchParams.delete("p"); let a = helpers.create_box_link({ label: tag_name, classes: ["tag-entry"], popup: this.bookmark_tag_counts[tag], link: url.toString(), as_element: true, }); if(tag_name == "All bookmarks") a.dataset.default = 1; 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, "selected", 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. await 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. await 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. await 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.new_works_by_following = class extends data_source { get name() { return "new_works_by_following"; } 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. await 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 tags"; let tag_list = container.querySelector(".new-post-follow-tags .vertical-list"); for(let tag of tag_list.querySelectorAll(".tag-entry")) tag.remove(); let add_tag_link = (tag) => { // Work around Pixiv always returning a follow tag named "null" for some users. if(tag == "null") return; var url = new URL(this.url); if(tag != "All tags") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); let a = helpers.create_box_link({ label: tag, classes: ["tag-entry"], link: url.toString(), as_element: true, }); if(tag == "All tags") a.dataset.default = 1; if(tag == current_tag) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All tags"); for(var tag of this.bookmark_tags) add_tag_link(tag); // If we don't have the tag list yet because we're still loading the page, fill in // the current tag, to reduce flicker as the page loads. if(this.bookmark_tags.length == 0 && current_tag != "All tags") add_tag_link(current_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); // Set the contents of the tag menu button. this.set_active_popup_highlight(container); } }; // /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); // 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 no_results() { // Don't display "No Results" while we're still waiting for the user to enter a tag. if(!this._search_tags) return false; return super.no_results; } 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); } cache_search_title = async() => { 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 if(tag == "(" || tag == ")") span.classList.add("paren"); 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. await 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 ?? "Search works"; }; 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) { // Hide the Related Tags dropdown button until a search tag is entered. container.querySelector(".search-tags-box").hidden = this._search_tags == ""; 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 can_return_manga() { return false; } constructor(url) { super(url); this.follow_tags = []; } 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. await 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"}); let tag_list = container.querySelector(".followed-users-follow-tags .vertical-list"); for(let tag of tag_list.querySelectorAll(".tag-entry")) tag.remove(); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_tag = this.url.searchParams.get("tag") || "All tags"; var add_tag_link = (tag) => { // Work around Pixiv always returning a follow tag named "null" for some users. if(tag == "null") return; 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 tags") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); let a = helpers.create_box_link({ label: tag, classes: ["tag-entry"], link: url.toString(), as_element: true, }); if(tag == "All tags") a.dataset.default = 1; if(tag == current_tag) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All tags"); for(let tag of this.follow_tags) add_tag_link(tag); // If we don't have the tag list yet because we're still loading the page, fill in // the current tag, to reduce flicker as the page loads. if(this.follow_tags.length == 0 && current_tag != "All tags") add_tag_link(current_tag); // Set the contents of the tag menu button. this.set_active_popup_highlight(container); } 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 can_return_manga() { return false; } 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.id}#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 can_return_manga() { return false; } 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; } get username() { return this.url.searchParams.get("nick"); } initial_refresh_thumbnail_ui(container, view) { container.querySelector(".search-users").value = this.username; } get no_results() { // Don't display "No Results" while we're still waiting for the user to enter a search. if(!this.username) return false; return super.no_results; } get page_title() { let search = this.username; 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); await 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"; } get includes_manga_pages() { return true; } 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 media_ids = await ppixiv.recently_seen_illusts.get().get_recent_media_ids(); this.pages = paginate_illust_ids(media_ids, this.estimated_items_per_page); } // Register this page. let media_ids = this.pages[page-1] || []; // 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); await thumbnail_data.singleton().loaded_thumbnail_info(thumbs, "internal"); let known_illust_ids = new Set(); for(let thumb of thumbs) known_illust_ids.add(thumb.id); let found_media_ids = media_ids.filter((media_id) => { let [illust_id] = helpers.media_id_to_illust_id_and_page(media_id); return known_illust_ids.has(illust_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; } } // https://www.pixiv.net/en/#ppixiv/edits // View images that have edits on them // // This views all images that the user has saved crops, etc. for. This isn't currently // shown in the UI. ppixiv.data_sources.edited_images = class extends data_source_fake_pagination { get name() { return "edited"; } get includes_manga_pages() { return true; } async load_all_results() { return await ppixiv.extra_image_data.get.get_all_edited_images(); }; get page_title() { return "Edited"; } get_displaying_text() { return "Edited Images"; } } ppixiv.data_sources.vview = class extends data_source { get name() { return "vview"; } get can_return_manga() { return false; } 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 we have access restrictions (eg. we're guest and can only access certain tags), // this API is disabled, since all listings are bookmark searches. if(search_options == null && !local_api.local_info.bookmark_tag_searches_only) { 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_ids.reason); return; } 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"); } }); let clear_local_search_button = container.querySelector(".clear-local-search"); let search_active = local_api.get_search_options_for_args(helpers.args.location).search_options != null; helpers.set_class(clear_local_search_button, "disabled", !search_active); // If we're only allowed to do bookmark searches, hide the bookmark search button. container.querySelector('[data-type="local-bookmarks-only"]').hidden = local_api.local_info.bookmark_tag_searches_only; 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); } // We're doing a bookmark search if the bookmark filter is enabled, or if // we're restricted to listing tagged bookmarks. get bookmark_search_active() { return helpers.args.location.hash.has("bookmarks") || local_api.local_info.bookmark_tag_searches_only; } 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 = !this.bookmark_search_active; // Stop if we don't have the tag list yet. if(this.bookmark_tag_counts == null) return; let current_tag = helpers.args.location.hash.get("bookmark-tag"); let add_tag_link = (tag) => { let tag_count = this.bookmark_tag_counts[tag]; let tag_name = tag; if(tag_name == null) tag_name = "All bookmarks"; else if(tag_name == "") tag_name = "Untagged"; let args = helpers.args.location; args.hash.delete("path"); if(tag == null) args.hash.delete("bookmark-tag"); else args.hash.set("bookmark-tag", tag); args.query.delete("p"); // Show the bookmark count in the popup. let popup = null; if(tag_count != null) popup = tag_count + (tag_count == 1? " bookmark":" bookmarks"); let a = helpers.create_box_link({ label: tag_name, classes: ["following-tag"], popup, link: args.url.toString(), as_element: true, }); if(tag_name == "All bookmarks") a.dataset.default = 1; if(tag == current_tag) a.classList.add("selected"); 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(!this.bookmark_search_active) 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/r137/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/r137/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.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); window.addEventListener("mouseinactive", this.refresh_cursor_stationary); 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/r137/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.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 = { } this.all_user_follow_tags = null; this.user_follow_info = { }; // Negative cache to remember illusts that don't exist, so we don't try to // load them repeatedly: this.nonexistant_media_ids = { }; // Promises for ongoing requests: this.illust_loads = {}; this.user_info_loads = {}; this.follow_info_loads = {}; this.user_follow_tags_load = null; }; // 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, force=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(!force && 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?.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); // If we have extra data stored for this image, load it. let extra_data = await extra_image_data.get.load_all_pages_for_illust(illust_id); illust_data.extraData = extra_data; // 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; this.call_illust_modified_callbacks(media_id); 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; this.call_illust_modified_callbacks(media_id); 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. // // This will trigger loads for secondary data like manga pages if it's not included in // illust_data. add_illust_data(illust_data) { let media_id = helpers.illust_id_to_media_id(illust_data.id); var load_promise = this.load_media_info(media_id, { illust_data: illust_data, force: true }); this._started_loading_image_info(media_id, load_promise); return 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]; } // Return bookmark tags if they're already loaded, otherwise return null. get_bookmark_details_sync(media_id) { if(helpers.is_media_id_local(media_id)) { let thumb = thumbnail_data.singleton().get_illust_data_sync(media_id); if(thumb && thumb.bookmarkData == null) return []; this.bookmarked_image_tags[media_id] = thumb.bookmarkData.tags; return this.bookmarked_image_tags[media_id]; } else 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); } // Load the follow info for a followed user, which includes follow tags and whether the // follow is public or private. If the user isn't followed, return null. // // This can also fetch the results of load_all_user_follow_tags and will cache it if // available, so if you're calling both get_user_follow_info and load_all_user_follow_tags, // call this first. async get_user_follow_info(user_id, { refresh=false }={}) { // If we request following info for a user we're not following, we'll get a 400. This // isn't great, since it means we have to make an extra API call first to see if we're // following to avoid spamming request errors. let user_data = await this.get_user_info(user_id); if(!user_data.isFollowed) { delete this.user_follow_info[user_id]; return null; } // Stop if this user's follow info is already loaded. if(!refresh && this.user_follow_info[user_id]) return this.user_follow_info[user_id]; // If another request is already running for this user, wait for it to finish and use // its result. if(this.follow_info_loads[user_id]) { await this.follow_info_loads[user_id]; return this.user_follow_info[user_id]; } this.follow_info_loads[user_id] = helpers.rpc_get_request("/rpc/index.php", { mode: "following_user_detail", user_id: user_id, lang: "en", }); let data = await this.follow_info_loads[user_id]; this.follow_info_loads[user_id] = null; if(data.error) { console.log(\`Couldn't request follow info for \${user_id}\`); return null; } // This returns both selected tags and all follow tags, so we can also update // all_user_follow_tags. let all_tags = []; let tags = new Set(); for(let tag_info of data.body.tags) { all_tags.push(tag_info.name); if(tag_info.selected) tags.add(tag_info.name); } this.set_cached_all_user_follow_tags(all_tags); this.user_follow_info[user_id] = { tags, following_privately: data.body.restrict == "1", } return this.user_follow_info[user_id]; } get_user_follow_info_sync(user_id) { return this.user_follow_info[user_id]; } // Load all of the user's follow tags. This is cached unless refresh is true. async load_all_user_follow_tags({ refresh=false }={}) { // Follow tags require premium. if(!window.global_data.premium) return []; if(!refresh && this.all_user_follow_tags != null) return this.all_user_follow_tags; // If another call is already running, wait for it to finish and use its result. if(this.user_follow_tags_load) { await this.user_follow_tags_load; return this.all_user_follow_tags; } // The only ways to get this list seem to be from looking at an already-followed // user, or looking at the follow list. this.user_follow_tags_load = helpers.get_request(\`/ajax/user/\${window.global_data.user_id}/following\`, { offset: 0, limit: 1, rest: "show", }); let result = await this.user_follow_tags_load; this.user_follow_tags_load = null; if(result.error) console.log("Error retrieving follow tags"); else this.set_cached_all_user_follow_tags(result.body.followUserTags); return this.all_user_follow_tags; } set_cached_all_user_follow_tags(tags) { tags.sort(); // Work around a Pixiv bug. If you ever use the follow user API with a tag // of null (instead of ""), it returns an internal error and you end up with // a "null" tag in your tag list that never goes away. It seems like it stores // the actual null value, which then gets coerced to the string "null" in the // API. Remove it, since it's annoying (sorry if you really wanted to tag // people as "null"). let idx = tags.indexOf("null"); if(idx != -1) tags.splice(idx, 1); this.all_user_follow_tags = tags; } // Add a new tag to all_user_follow_tags When the user creates a new one. add_to_cached_all_user_follow_tags(tag) { if(this.all_user_follow_tags == null || this.all_user_follow_tags.indexOf(tag) != -1) return; this.all_user_follow_tags.push(tag); this.all_user_follow_tags.sort(); } // Update the follow info for a user. This is used after updating a follow. update_cached_follow_info(user_id, followed, follow_info) { // If user info isn't loaded, follow info isn't either. let user_info = this.get_user_info_sync(user_id); if(user_info == null) return; user_info.isFollowed = followed; if(!followed) { delete this.user_follow_info[user_id]; } else { this.user_follow_info[user_id] = follow_info; } this.call_user_modified_callbacks(user_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; } // Refresh image data and thumbnail info for the given media ID. // // Only data which is already loaded will be refreshed, so refreshing a search result // where we haven't yet had any reason to load full image data will only refresh thumbnail // data. async refresh_media_info(media_id) { let promises = []; if(this.image_data[media_id] != null) promises.push(this.load_media_info(media_id, { force: true })); if(!helpers.is_media_id_local(media_id) && thumbnail_data.singleton().get_one_thumbnail_info(media_id) != null) promises.push(thumbnail_data.singleton().load_thumbnail_info([media_id], { force: true })); await Promise.all(promises); } // Save data to extra_image_data, and update cached data. Returns the updated extra data. async save_extra_image_data(media_id, edits) { let [illust_id] = helpers.media_id_to_illust_id_and_page(media_id); // Load the current data from the database, in case our cache is out of date. let results = await extra_image_data.get.load_illust_data([media_id]); let data = results[media_id] ?? { illust_id: illust_id }; // Update each key, removing any keys which are null. for(let [key, value] of Object.entries(edits)) data[key] = value; // Delete any null keys. for(let [key, value] of Object.entries(data)) { if(value == null) delete data[key]; } // Update the edited timestamp. data.edited_at = Date.now() / 1000; // Save the new data. If the only fields left are illust_id and edited_at, delete the record. if(Object.keys(data).length == 2) await extra_image_data.get.delete_illust(media_id); else await extra_image_data.get.save_illust(media_id, data); // If the image is loaded in image_data, update illust_data.extraData. let illust_data = this.get_media_info_sync(media_id); if(illust_data != null) illust_data.extraData[media_id] = data; // If the image is loaded in thumbnail_data, update illust_data.extraData. let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(thumbnail_info) thumbnail_info.extraData[media_id] = data; this.call_illust_modified_callbacks(media_id); return data; } // Update cached illust info. // // illust_info can contain any or all illust info keys. We'll only update the keys // that are present. For example, // // update_media_info(media_id, { likeCount: 10 }); // // will update likeCount on the image. // // This updates both thumbnail info and illust info. if illust_info isn't already loaded, // we won't load it here. Only illusts that are already loaded will be updated update_media_info(media_id, keys) { media_id = helpers.get_media_id_first_page(media_id); let image_data = this.image_data[media_id]; if(image_data != null) { for(let [key, value] of Object.entries(keys)) image_data[key] = value; } let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(thumbnail_info) { for(let [key, value] of Object.entries(keys)) { // Ignore data that isn't included in thumbnail info. if(thumbnail_data.singleton().thumbnail_info_keys.indexOf(key) == -1) continue; thumbnail_info[key] = value; } } this.call_illust_modified_callbacks(media_id); } // Helpers // Return the extra info for an image, given its image info. // // For local images, the extra info is simply stored on image_info. This doesn't need // to be used if you know you're working with a local image. // // For Pixiv images, extra info is stored in image_info.extraData, with page media IDs // as keys. static get_extra_data(image_info, media_id, page=null) { if(image_info == null) return { }; if(helpers.is_media_id_local(media_id)) return image_info; // If page is null, media_id is already this page's ID. if(page != null) media_id = helpers.get_media_id_for_page(media_id, page); return image_info.extraData[media_id] ?? {}; } // Get the width and height of media_id from image_info. // // If this is a local image, or this is the first page, the width and height are on // image_info. If this isn't the first page of a manga post, get the dimensions from // mangaPages. If this is the first page, get it directly from image_info, so this // can accept thumbnail data too. static get_dimensions(image_info, media_id, page=null) { if(image_info == null) return { width: 1, height: 1 }; let page_info = image_info; if(!helpers.is_media_id_local(media_id)) { if(page == null) page = helpers.media_id_to_illust_id_and_page(media_id)[1]; if(page > 0) page_info = image_info.mangaPages[page]; } return { width: page_info.width, height: page_info.height }; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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.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]; this.drag_movement = [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 }); // Start or stop panning if the user changes it while we're active, eg. by pressing ^P. settings.changes.addEventListener("auto_pan", this.refresh_autopan.bind(this), { 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.quickviewpointermove, { 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, // If set, this is a FixedDOMRect to crop the image to. crop=null, // If set, this is a pan created by PanEditor. pan=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"; // Wrap the image in an ImageEditingOverlayContainer. This acts like an image as far // as we're concerned. 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 && crop.length == 4? new FixedDOMRect(crop[0], crop[1], crop[2], crop[3]):null; this.pan = pan; 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. Also do this if // we already have animations running, so we update the slideshow/panning if the mode // changes. if(restore_position == "auto" || this.slideshow_enabled || this.animations != null) 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_size.width) > (screen_height/this.cropped_size.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) => { // If we're animating, only start dragging after we pass a drag threshold, so we // don't cancel the animation in quick view. These thresholds match Windows's // default SM_CXDRAG/SM_CYDRAG behavior. this.drag_movement[0] += e.movementX; this.drag_movement[1] += e.movementY; if(this.animations && this.drag_movement[0] < 4 && this.drag_movement[1] < 4) return; this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY}); } quickviewpointermove = (e) => { this.apply_pointer_movement({movementX: e.movementX, movementY: e.movementY}); } apply_pointer_movement({movementX, movementY}) { 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_size.width, screen_height/this.cropped_size.height); } // Return the DOMRect of the cropped size of the image. If we're not cropping, this // is the size of the image itself. get cropped_size() { if(this._cropped_size != null) return this._cropped_size; else return new FixedDOMRect(0, 0, this.original_width, this.original_height); } // Return the width and height of the image when at 1x zoom. get width() { return this.cropped_size.width * this._image_to_screen_ratio; } get height() { return this.cropped_size.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; } // If we were already animating for slideshow and we're now panning instead or // vice versa, stop the animation so we start the new type. if(this.animation_enabled_for_slideshow != this.slideshow_enabled) this.stop_animation(); this.animation_enabled_for_slideshow = this.slideshow_enabled; let slideshow = new ppixiv.slideshow({ // this.width/this.height are the size of the image at 1x zoom, which is to fit // onto the screen. Scale this up by zoom_factor_cover, so the slideshow's default // zoom level is to cover the screen. width: this.width * this._zoom_factor_cover, height: this.height * this._zoom_factor_cover, container_width: this.container_width, container_height: this.container_height, slideshow_enabled: this.slideshow_enabled, // Set the minimum zoom to 1 / cover, so the smallest zoom it allows brings the image // back to fit onscreen, and we don't go any smaller than that. minimum_zoom: 1 / this._zoom_factor_cover, }); // Try to create a vertical or horizontal pan, or load the user's transition if there is one. let animation = this.pan? slideshow.get_animation_from_pan(this.pan): slideshow.get_default_animation(); this.run_animation(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) { 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) { let keyframe = { transform: \`translateX(\${point.computed_tx}px) translateY(\${point.computed_ty}px) scale(\${point.computed_zoom})\`, easing: point.ease ?? "ease-out", 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 = async (e) => { if(!this.slideshow_enabled || !this.onnextimage) { // We're just panning, so clean up the animation and stop. this.stop_animation(); return; } // Tell the caller that we're ready for the next image. Don't call stop_animation yet, // so we don't cancel opacity and cause the image to flash onscreen while the new one // is loading. We'll stop if when onnextimage navigates. let { media_id } = await this.onnextimage(); // onnextimage is normally viewer_images.navigate_to_next(). It'll return the new // media_id if it navigated to one. If it didn't navigate, call stop_animation so // we clean up the animation and make it visible again if it's faded out. This // typically only happens if we only have one image. if(media_id == null) { console.log("The slideshow didn't have a new image. Resetting the slideshow animation"); this.stop_animation(); } }; 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. 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(); // Make sure we don't leave the image faded out if we stopped while in the middle // of a fade. this.image_box.style.opacity = ""; 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/r137/src/on_click_viewer.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/r137/src/progress_bar.js `; ppixiv.resources["src/seek_bar.js"] = `"use strict"; ppixiv.seek_bar = class extends widget { constructor({...options}) { super({...options, template: \`
\` }); 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/r137/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/r137/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.id + " - " + 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/r137/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/r137/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.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; // Tell the inpaint editor about the image. this.image_editor.set_media_id(this.media_id); // 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. We'll still // have any extra_data. let extra_data = image_data.get_extra_data(early_illust_data, this.media_id); this.images = [{ preview_url: early_illust_data.previewUrls[0], width: early_illust_data.width, height: early_illust_data.height, crop: extra_data?.crop, pan: extra_data?.pan, }]; 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 [page, manga_page] of Object.entries(this.illust_data.mangaPages)) { let extra_data = image_data.get_extra_data(this.illust_data, this.media_id, page); 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: extra_data?.crop, pan: extra_data?.pan, }); } 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 pan: current_image.pan, restore_position: this.restore_history? "history":"auto", 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/r137/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/r137/src/viewer_muted.js `; ppixiv.resources["src/viewer_ugoira.js"] = `"use strict"; ppixiv.viewer_ugoira = class extends ppixiv.viewer { constructor({...options}) { super({...options, template: \`
\`}); this.refresh_focus = this.refresh_focus.bind(this); // Create the video UI. this.video_ui = new ppixiv.video_ui({ container: this.container.querySelector(".video-ui-container"), parent: 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); } load = async(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/r137/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.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(); }; clicked_video = async(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.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/r137/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; if(this.reader == null) return null; // 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.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/r137/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 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/r137/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.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, }); this.ui.container.addEventListener("mouseenter", (e) => { this.hovering_over_box = true; this.refresh_overlay_ui_visibility(); }); this.ui.container.addEventListener("mouseleave", (e) => { this.hovering_over_box = false; this.refresh_overlay_ui_visibility(); }); var hover_circle = this.container.querySelector(".ui .hover-circle"); hover_circle.addEventListener("mouseenter", (e) => { this.hovering_over_sphere = true; this.refresh_overlay_ui_visibility(); }); hover_circle.addEventListener("mouseleave", (e) => { this.hovering_over_sphere = false; this.refresh_overlay_ui_visibility(); }); settings.changes.addEventListener("image_editing", () => { this.refresh_overlay_ui_visibility(); }); settings.changes.addEventListener("image_editing_mode", () => { this.refresh_overlay_ui_visibility(); }); this.refresh_overlay_ui_visibility(); 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.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, { }); } refresh_overlay_ui_visibility() { // Hide widgets inside the hover UI when it's hidden. let visible = this.hovering_over_box || this.hovering_over_sphere; // Don't show the hover UI while editing, since it can get in the way of trying to // click the image. let editing = settings.get("image_editing") && settings.get("image_editing_mode") != null; if(editing) visible = false; if(!visible) view_hidden_listener.send_viewhidden(this.ui.container); // Tell the image UI when it's visible. this.ui.visible = visible; // Hide the UI's container too when we're editing, so the hover boxes don't get in // the way. this.container.querySelector(".ui").hidden = editing; } 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.stop_displaying_image(); // We leave editing on when navigating between images, but turn it off when we exit to // the search. settings.set("image_editing_mode", null); 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); // 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.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. // Loop is true, so we loop back to the beginning of the search if we reach // the end in a slideshow. let skip_manga_pages = settings.get("slideshow_skips_manga"); return await this.navigate_to_next(1, { loop: true, flash_at_end: false, skip_manga_pages }); }, }); // 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(); 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. if(settings.get("linked_tabs_enabled")) { 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; } 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 we only have one image, don't loop. We won't actually navigate so things // don't quite work (navigating to the same media ID won't trigger a navigation // at all), and it's not very useful. if(new_media_id == navigate_from_media_id) { console.log("Not looping since we only have one media ID"); return { }; } } 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; } return { media_id: new_media_id }; } // 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, flash_at_end=true }={}) { // 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 } = 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"); if(flash_at_end) this.flash_end_indicator(down, "last-image"); return { reached_end: true }; } // If this.pending_navigation is no longer the same as pending_navigation, we navigated since // we requested this load and this navigation is stale, so stop. if(this.pending_navigation != pending_navigation) { console.error("Aborting stale navigation"); return { stale: true }; } this.pending_navigation = null; // Go to the new illustration. main_controller.singleton.show_media(new_media_id); return { media_id: 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/r137/src/screen_illust.js `; ppixiv.resources["src/screen_search.js"] = `"use strict"; let thumbnail_ui = class extends ppixiv.widget { constructor(options) { super({ ...options, template: \`
wallpaper
\${ helpers.create_box_link({label: "All", link: "?mode=all#ppixiv", popup: "Show all works", data_type: "all" }) } \${ helpers.create_box_link({label: "All ages", link: "?mode=safe#ppixiv", popup: "All ages", data_type: "safe" }) } \${ helpers.create_box_link({label: "R18", link: "?mode=r18#ppixiv", popup: "R18", data_type: "r18", classes: ["r18"] }) }
\${ helpers.create_box_link({label: "Illustrations", popup: "Show illustrations", data_type: "new-illust-type-illust" }) } \${ helpers.create_box_link({label: "Manga", popup: "Show manga only", data_type: "new-illust-type-manga" }) } \${ helpers.create_box_link({label: "All ages", popup: "Show all-ages works", data_type: "new-illust-ages-all" }) } \${ helpers.create_box_link({label: "R18", popup: "Show R18 works", data_type: "new-illust-ages-r18" }) }
\${ helpers.create_box_link({label: "Next day", popup: "Show the next day", data_type: "new-illust-type-illust", classes: ["nav-tomorrow"] }) } \${ helpers.create_box_link({label: "Previous day", popup: "Show the previous day", data_type: "new-illust-type-illust", classes: ["nav-yesterday"] }) }
\${ helpers.create_box_link({label: "Clear", popup: "Clear recent history", data_type: "clear-recents" }) }
\${ helpers.create_box_link({label: "All", popup: "Show all bookmarks", data_type: "all" }) } \${ helpers.create_box_link({label: "Public", popup: "Show public bookmarks", data_type: "public" }) } \${ helpers.create_box_link({label: "Private", popup: "Show private bookmarks", data_type: "private" }) }
\${ helpers.create_box_link({label: "All bookmarks", popup: "Bookmark tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({ popup: "Shuffle", icon: "shuffle", data_type: "order-shuffle" }) }
\${ helpers.create_box_link({label: "Public", popup: "Show publically followed users", data_type: "public-follows" }) } \${ helpers.create_box_link({label: "Private", popup: "Show privately followed users", data_type: "private-follows" }) } \${ helpers.create_box_link({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "All", popup: "Show all works", data_type: "bookmarks-new-illust-all", classes: ["r18"] }) } \${ helpers.create_box_link({label: "R18", popup: "Show R18 works", data_type: "bookmarks-new-illust-ages-r18", classes: ["r18"] }) } \${ helpers.create_box_link({label: "All tags", popup: "Follow tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Works", popup: "Show all works", data_type: "artist-works" }) } \${ helpers.create_box_link({label: "Illusts", popup: "Show illustrations only", data_type: "artist-illust" }) } \${ helpers.create_box_link({label: "Manga", popup: "Show manga only", data_type: "artist-manga" }) }
\${ helpers.create_box_link({label: "Tags", popup: "Tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Related tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Ages", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Sort", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Type", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Search mode", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Image size", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Aspect ratio", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Bookmarks", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Time", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Reset", popup: "Clear all search options", classes: ["reset-search"] }) }
\${ helpers.create_box_link({label: "Latest", popup: "Show latest completed requests", data_type: "completed-requests-latest" }) } \${ helpers.create_box_link({label: "Recommended", popup: "Show recommmended completed requests", data_type: "completed-requests-recommended" }) }
\${ helpers.create_box_link({label: "Illustrations", popup: "Show latest completed requests", data_type: "completed-requests-illust" }) } \${ helpers.create_box_link({label: "Animations", popup: "Show animations only", data_type: "completed-requests-ugoira" }) } \${ helpers.create_box_link({label: "Manga", popup: "Show manga only", data_type: "completed-requests-manga" }) }
\${ helpers.create_box_link({label: "All", popup: "Show all works", data_type: "completed-requests-all" }) } \${ helpers.create_box_link({label: "All ages", popup: "Show all-ages works", data_type: "completed-requests-safe" }) } \${ helpers.create_box_link({label: "R18", popup: "Show R18 works", data_type: "completed-requests-r18", classes: ["r18"] }) }
content_copy \${ helpers.create_box_link({popup: "Close search", icon: "exit_to_app", classes: ["clear-local-search"] }) } \${ helpers.create_box_link({label: "Bookmarks", popup: "Show bookmarks", data_type: "local-bookmarks-only" }) }
\${ helpers.create_box_link({label: "Tags", icon: "bookmark", classes: ["popup-menu-box-button"] }) }
\${ helpers.create_box_link({label: "Type", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Aspect ratio", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Image size", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({label: "Order", classes: ["popup-menu-box-button"] }) } \${ helpers.create_box_link({ popup: "Shuffle", icon: "shuffle", data_type: "local-sort-shuffle" }) }
\` }); } } // The search UI. ppixiv.screen_search = class extends ppixiv.screen { constructor(options) { super(options); this.scroll_container = this.container.querySelector(".search-results"); this.expanded_media_ids = new Map(); 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); // When a bookmark is modified, refresh the heart icon. image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail); this.container.addEventListener("load", (e) => { if(e.target.classList.contains("thumb")) this.thumb_image_load_finished(e.target.closest(".thumbnail-box"), { cause: "onload" }); }, { capture: true } ); new thumbnail_ui({ parent: this, container: this.container.querySelector(".thumbnail-ui-box-container"), }); this.create_main_search_menu(); // 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; var a = e.target.closest("a.thumbnail-link"); if(a == null) return; if(a.dataset.mediaId == null) return; // Only do this for illustrations. let {type} = helpers.parse_media_id(a.dataset.mediaId); if(type != "illust") return; await image_data.singleton().get_media_info(a.dataset.mediaId); }, true); this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search); this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new); this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick); this.container.querySelector(".expand-manga-posts").addEventListener("click", (e) => { this.toggle_expanding_media_ids_by_default(); }); // Set up login/logout buttons for native. if(ppixiv.native) { let { logged_in, local } = local_api.local_info; this.container.querySelector(".login-button").hidden = local || logged_in; this.container.querySelector(".logout-button").hidden = local || !logged_in; this.container.querySelector(".login-button").addEventListener("click", () => { local_api.redirect_to_login(); }); this.container.querySelector(".logout-button").addEventListener("click", () => { local_api.logout(); }); } // 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", () => { this.refresh_images(); }); settings.register_change_callback("manga-thumbnail-size", () => { 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); settings.register_change_callback("expand_manga_thumbnails", 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(); let manga_view = this.data_source?.name == "manga"; settings.adjust_zoom(manga_view? "manga-thumbnail-size":"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(); let manga_view = this.data_source?.name == "manga"; settings.adjust_zoom(manga_view? "manga-thumbnail-size":"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") }); // The search history dropdown for local searches. new local_search_box_widget({ contents: this.container.querySelector(".local-tag-search-box") }); // If the local API is enabled and tags aren't restricted, set up the directory tree sidebar. let local_nav_box = this.container.querySelector(".local-navigation-box"); this.clear_local_search_button = this.container.querySelector(".clear-local-search"); this.clear_local_search_button.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"); }); if(!local_api.local_info.bookmark_tag_searches_only) { this.local_nav_widget = new ppixiv.local_navigation_widget({ parent: this, container: local_nav_box, }); } else { // dataset.disabled does the same thing as setting hidden, but avoids conflicting // with data-datasource. local_nav_box.dataset.disabled = true; } 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: "Search works", icon: "search", url: \`/tags#ppixiv\`, onclick: async() => { // Focus the tag search box. We need to go async to let the navigation happen // so the search box is visible first. await helpers.sleep(0); this.container.querySelector(".tag-search-box input").focus(); } }, { label: "New works by following", icon: "photo_library", url: "/bookmark_new_illust.php#ppixiv" }, { label: "New works by everyone", icon: "groups", url: "/new_illust.php#ppixiv" }, [ { label: "Bookmarks", icon: "bookmark", 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", icon: "visibility", 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", icon: "auto_awesome" /* who names this stuff? */, url: "/ranking.php#ppixiv" }, { label: "Recommended works", icon: "lightbulb", url: "/discovery#ppixiv" }, { label: "Recommended users", icon: "lightbulb", url: "/discovery/users#ppixiv" }, { label: "Completed requests", icon: "request_page", url: "/request/complete/illust#ppixiv" }, { label: "Users", icon: "search", url: "/search_user.php#ppixiv" }, // { label: "Recent history", icon: "", url: "/history.php#ppixiv", classes: ["recent-history-link"] }, { label: "Local search", icon: "", 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, onclick: option.onclick, ...option }) // 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)); } } get_thumbnail_for_media_id(media_id) { return this.container.querySelector(\`[data-id='\${helpers.escape_selector(media_id)}']\`); } 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); } this.load_expanded_media_ids(); } 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; this.avatar_widget.set_user_id(null); // 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(); this.refresh_expand_manga_posts_button(); }; // 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(); // Map from link types to icons: let link_types = { ["default-icon"]: "resources/link-icon.svg", ["shopping-cart"]: "resources/shopping-cart.svg", ["twitter-icon"]: "resources/icon-twitter.svg", ["fanbox-icon"]: "resources/icon-fanbox.svg", ["booth-icon"]: "resources/icon-booth.svg", ["webpage-link"]: "resources/icon-webpage.svg", ["pawoo-icon"]: "resources/icon-pawoo.svg", ["circlems-icon"]: "resources/icon-circlems.svg", ["twitch-icon"]: "resources/logo-twitch.svg", ["contact-link"]: "resources/send-message.svg", ["following-link"]: "resources/followed-users-eye.svg", ["bookmarks-link"]: "resources/icon-bookmarks.svg", ["similar-artists"]: "resources/related-illusts.svg", }; 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); if(type == null) type = "default-icon"; let entry = this.create_template({name: "extra-link", html: \` \`}); let image_name = link_types[type]; let icon = helpers.create_ppixiv_inline(image_name); icon.classList.add(type); entry.querySelector(".extra-link").appendChild(icon); 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 = decodeURIComponent(label); // 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 media ID to *. Leave it alone // if we're on the manga view and just add slideshow=1. let args = helpers.args.location; if(this.data_source.name == "vview") args.hash.set("file", "*"); else if(this.data_source?.name != "manga") 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); // 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); // If nothing's focused, focus the search so keyboard navigation works. Don't do this if // we already have focus, so we don't steal focus from things like the tag search dropdown // and cause them to be closed. let focus = document.querySelector(":focus"); if(focus == null) this.container.querySelector(".search-results").focus(); else console.log("Already focused:", 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; // If the media ID isn't in the list, this might be a manga page beyond the first that // isn't displayed, so try the first page instead. if(old_media_id != null && this.get_thumbnail_for_media_id(old_media_id) == null) old_media_id = helpers.get_media_id_first_page(old_media_id); // 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) { // If this is a multi-page post and manga expansion is enabled, add a thumbnail for // each page. We can only do this if the data source registers thumbnail info from // its results, not if we have to look it up asynchronously, but almost all data sources // do. let media_ids_on_page = this.get_expanded_pages(media_id); if(media_ids_on_page != null) { for(let page_media_id of media_ids_on_page) { media_ids.push(page_media_id); media_id_pages[page_media_id] = page; } continue; } media_ids.push(media_id); media_id_pages[media_id] = page; } } return [media_ids, media_id_pages]; } // If media_id is an expanded multi-page post, return the pages. Otherwise, return null. get_expanded_pages(media_id) { if(!this.is_media_id_expanded(media_id)) return null; let info = thumbnail_data.singleton().get_illust_data_sync(media_id); if(info == null || info.pageCount <= 1) return null; let results = []; let { type, id } = helpers.parse_media_id(media_id); for(let manga_page = 0; manga_page < info.pageCount; ++manga_page) { let page_media_id = helpers.encode_media_id({type, id, page: manga_page}); results.push(page_media_id); } return results; } // 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({all_media_ids, forced_media_id, columns}) { 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(columns != null) start_idx -= start_idx % columns; 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; } 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}={}) => { if(this.data_source == null) return; let manga_view = this.data_source?.name == "manga"; // 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(manga_view? "manga-thumbnail-size":"thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); let {columns, padding, max_width, max_height, container_width} = helpers.make_thumbnail_sizing_style(ul, { wide: true, size: thumbnail_size, ratio: this.data_source.get_thumbnail_aspect_ratio(), // Limit the number of columns on most views, so we don't load too much data at once. // Allow more columns on the manga view, since that never loads more than one image. // Allow unlimited columns for local images. max_columns: manga_view? 15: this.data_source?.name == "vview"? 100:5, // Set a minimum padding to make sure there's room for the popup text to fit between images. min_padding: 15, }); this.container.style.setProperty('--thumb-width', \`\${max_width}px\`); this.container.style.setProperty('--thumb-height', \`\${max_height}px\`); this.container.style.setProperty('--thumb-padding', \`\${padding}px\`); this.container.style.setProperty('--container-width', \`\${container_width}px\`); // 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(); // Get all media IDs from the data source. let [all_media_ids, media_id_pages] = this.get_data_source_media_ids(); // Sanity check: there should never be any duplicate media IDs from the data source. // Refuse to continue if there are duplicates, since it'll break our logic badly and // can cause infinite loops. This is always a bug. if(all_media_ids.length != (new Set(all_media_ids)).size) throw Error("Duplicate media IDs"); // Remove any thumbs that aren't present in all_media_ids, so we only need to // deal with adding thumbs below. For example, this simplifies things when // a manga post is collapsed. { let media_id_set = new Set(all_media_ids); for(let thumb of this.scroll_container.querySelectorAll(\`[data-id]\`)) { let thumb_media_id = thumb.dataset.id; if(!media_id_set.has(thumb_media_id)) thumb.remove(); } } // Get the thumbnail media IDs to display. let media_ids = this.get_media_ids_to_display({ all_media_ids, columns, forced_media_id, }); // 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++; } } // When we remove thumbs, we'll cache them here, so if we end up reusing it we don't have // to recreate it. let removed_nodes = {}; function remove_node(node) { node.remove(); removed_nodes[node.dataset.id] = node; } // If we have a range, delete all items outside of it. Otherwise, just delete everything. while(first_matching_node && first_matching_node.previousElementSibling) remove_node(first_matching_node.previousElementSibling); while(last_matching_node && last_matching_node.nextElementSibling) remove_node(last_matching_node.nextElementSibling); if(!first_matching_node && !last_matching_node) { while(ul.firstElementChild != null) remove_node(ul.firstElementChild); } // 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, { cached_nodes: removed_nodes }); 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, { cached_nodes: removed_nodes }); 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, { cached_nodes: removed_nodes }); 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?.no_results) 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. thumbnail_onclick = async(e) => { let page_count_box = e.target.closest(".expand-button"); if(page_count_box) { e.preventDefault(); e.stopPropagation(); let id_node = page_count_box.closest("[data-id]"); let media_id = id_node.dataset.id; this.set_media_id_expanded(media_id, !this.is_media_id_expanded(media_id)); return; } // 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.get_thumbnail_for_media_id(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; } // Set whether the given thumb is expanded. // // We can store a thumb being explicitly expanded or explicitly collapsed, overriding the // current default. set_media_id_expanded(media_id, new_value) { let page = helpers.media_id_to_illust_id_and_page(media_id)[1]; media_id = helpers.get_media_id_first_page(media_id); this.expanded_media_ids.set(media_id, new_value); this.save_expanded_media_ids(); // This will cause thumbnails to be added or removed, so refresh. this.refresh_images(); // Refresh whether we're showing the expansion border. refresh_images sets this when it's // created, but it doesn't handle refreshing it. let thumb = this.get_thumbnail_for_media_id(media_id); this.refresh_expanded_thumb(thumb); if(!new_value) { media_id = helpers.get_media_id_first_page(media_id); // If we're collapsing a manga post on the first page, we know we don't need to // scroll since the user clicked the first page. Leave it where it is so we don't // move the button he clicked around. If we're collapsing a later page, scroll // the first page onscreen so we don't end up in a random scroll position two pages down. if(page != 0) this.scroll_to_media_id(helpers.get_media_id_first_page(media_id)); } } // Set whether thumbs are expanded or collapsed by default. toggle_expanding_media_ids_by_default() { // If the new setting is the same as the expand_manga_thumbnails setting, just // remove expand-thumbs. Otherwise, set it to the overridden setting. let args = helpers.args.location; let new_value = !this.media_ids_expanded_by_default; if(new_value == settings.get("expand_manga_thumbnails")) args.hash.delete("expand-thumbs"); else args.hash.set("expand-thumbs", new_value? "1":"0"); // Clear manually expanded/unexpanded thumbs, and navigate to the new setting. delete args.state.expanded_media_ids; helpers.set_page_url(args, true, "viewing-page"); } load_expanded_media_ids() { // Load expanded_media_ids. let args = helpers.args.location; let media_ids = args.state.expanded_media_ids ?? {}; this.expanded_media_ids = new Map(Object.entries(media_ids)); // Load media_ids_expanded_by_default. let expand_thumbs = args.hash.get("expand-thumbs"); if(expand_thumbs == null) this.media_ids_expanded_by_default = settings.get("expand_manga_thumbnails"); else this.media_ids_expanded_by_default = expand_thumbs == "1"; } // Store this.expanded_media_ids to history. save_expanded_media_ids() { let args = helpers.args.location; args.state.expanded_media_ids = Object.fromEntries(this.expanded_media_ids); helpers.set_page_url(args, false, "viewing-page", { send_popstate: false }); } is_media_id_expanded(media_id) { // Never expand manga posts on data sources that include manga pages themselves. // This can result in duplicate media IDs. if(this.data_source?.includes_manga_pages) return false; media_id = helpers.get_media_id_first_page(media_id); // Only illust IDs can be expanded. let { type } = helpers.parse_media_id(media_id); if(type != "illust") return false; // Check if the user has manually expanded or collapsed the image. if(this.expanded_media_ids.has(media_id)) return this.expanded_media_ids.get(media_id); // The media ID hasn't been manually expanded or unexpanded. If we're not expanding // by default, it's unexpanded. if(!this.media_ids_expanded_by_default) return false; // If the image is muted, never expand it by default, even if we're set to expand by default. // We'll just show a wall of muted thumbs. let info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(info != null) { let muted_tag = muting.singleton.any_tag_muted(info.tagList); let muted_user = muting.singleton.is_muted_user_id(info.userId); if(muted_tag || muted_user) return false; } // Otherwise, it's expanded by default if it has more than one page. if(info == null || info.pageCount == 1) return false; return true; } // Refresh the expanded-thumb class on thumbnails after expanding or unexpanding a manga post. refresh_expanded_thumb(thumb) { if(thumb == null) return; // Don't set expanded-thumb on the manga view, since it's always expanded. let media_id = thumb.dataset.id; let show_expanded = !this.data_source?.includes_manga_pages && this.is_media_id_expanded(media_id); helpers.set_class(thumb, "expanded-thumb", show_expanded); let info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id); helpers.set_class(thumb, "expanded-manga-post", show_expanded); helpers.set_class(thumb, "first-manga-page", illust_page == 0); // Show the page count if this is a multi-page post (unless we're on the // manga view itself). if(info.pageCount > 1 && this.data_source?.name != "manga") { let pageCountBox = thumb.querySelector(".manga-info-box"); pageCountBox.hidden = false; let text = show_expanded? \`\${illust_page+1}/\${info.pageCount}\`:info.pageCount; thumb.querySelector(".manga-info-box .page-count").textContent = text; thumb.querySelector(".manga-info-box .page-count").hidden = false; let page_count_box2 = thumb.querySelector(".show-manga-pages-button"); page_count_box2.hidden = false; page_count_box2.href = \`/artworks/\${illust_id}#ppixiv?manga=1\`; } } // Refresh all expanded thumbs. This is only needed if the default changes. refresh_expanded_thumb_all() { for(let thumb of this.get_loaded_thumbs()) this.refresh_expanded_thumb(thumb); } // Refresh the highlight for the "expand all posts" button. refresh_expand_manga_posts_button() { let enabled = this.media_ids_expanded_by_default; let button = this.container.querySelector(".expand-manga-posts"); button.dataset.popup = enabled? "Collapse manga posts":"Expand manga posts"; button.querySelector(".material-icons").innerText = enabled? "close_fullscreen":"open_in_full"; // Hide the button if the data source can never return manga posts to be expanded, or // if it's the manga page itself which always expands. button.hidden = !this.data_source?.can_return_manga || this.data_source?.includes_manga_pages; } update_from_settings = () => { this.load_expanded_media_ids(); // in case expand_manga_thumbnails has changed this.set_visible_thumbs(); this.refresh_images(); this.refresh_expanded_thumb_all(); 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); this.refresh_expand_manga_posts_button(); // 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({force=false}={}) { // 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; let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id); 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(!force && !("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; continue; } if(thumb_type != "illust" && thumb_type != "file" && thumb_type != "folder") throw "Unexpected thumb type: " + thumb_type; // Set this thumb. let { page } = helpers.parse_media_id(media_id); let url = info.previewUrls[page]; 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"); // Try to set up the aspect ratio. this.thumb_image_load_finished(element, { cause: "setup" }); } // 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. var link = element.querySelector("a.thumbnail-link"); 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); link.href = args.url; element.querySelector(".manga-info-box").hidden = false; } else { link.href = helpers.get_url_for_id(media_id).url; } link.dataset.mediaId = media_id; link.dataset.userId = info.userId; element.querySelector(".ugoira-icon").hidden = info.illustType != 2 && info.illustType != "video"; helpers.set_class(element, "dot", helpers.tags_contain_dot(info)); // Set expanded-thumb if this is an expanded manga post. This is also updated in // set_media_id_expanded. Set the border to a random-ish value to try to make it // easier to see the boundaries between manga posts. It's hard to guarantee that it // won't be the same color as a neighboring post, but that's rare. Using the illust // ID means the color will always be the same. The saturation is a bit low so these // colors aren't blinding. this.refresh_expanded_thumb(element); helpers.set_class(link, "first-page", illust_page == 0); helpers.set_class(link, "last-page", illust_page == info.pageCount-1); link.style.borderBottomColor = \`hsl(\${illust_id}deg 50% 50%)\`; this.refresh_bookmark_icon(element); // Set the label. This is only actually shown in following views. var label = element.querySelector(".thumbnail-label"); 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; } } } // Set things up based on the image dimensions. We can do this immediately if we know the // thumbnail dimensions already, otherwise we'll do it based on the thumbnail once it loads. thumb_image_load_finished(element, { cause }) { if(element.dataset.thumbLoaded) return; let media_id = element.dataset.id; let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id); let thumb = element.querySelector(".thumb"); // Try to use thumbnail info first. Preferring this makes things more consistent, // since naturalWidth may or may not be loaded depending on browser cache. let width, height; if(illust_page == 0) { let info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(info != null) { width = info.width; height = info.height; } } // If that wasn't available, try to use the dimensions from the image. This is the size // of the thumb rather than the image, but all we care about is the aspect ratio. if(width == null && thumb.naturalWidth != 0) { width = thumb.naturalWidth; height = thumb.naturalHeight; } if(width == null) return; element.dataset.thumbLoaded = "1"; // Set up the thumbnail panning direction, which is based on the image aspect ratio and the // displayed thumbnail aspect ratio. Ths thumbnail aspect ratio is usually 1 for square thumbs, // but it can be different on the manga page. let thumb_aspect_ratio = thumb.offsetWidth / thumb.offsetHeight; // console.log(\`Thumbnail \${media_id} loaded at \${cause}: \${width} \${height} \${thumb.src}\`); helpers.set_thumbnail_panning_direction(element, width, height, thumb_aspect_ratio); } // Refresh the thumbnail for media_id. // // This is used to refresh the bookmark icon when changing a bookmark. refresh_thumbnail = (media_id) => { // If this is a manga post, refresh all thumbs for this media ID, since bookmarking // a manga post is shown on all pages if it's expanded. let thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(media_id); if(thumbnail_info == null) return; for(let page = 0; page < thumbnail_info.pageCount; ++page) { media_id = helpers.get_media_id_for_page(media_id, page); let thumbnail_element = this.get_thumbnail_for_media_id(media_id); if(thumbnail_element != null) 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.name == "manga") 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 = () => { // Force the update to refresh thumbs that have already been created. this.set_visible_thumbs({force: true}); // 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). // // cached_nodes is a dictionary of previously-created nodes that we can reuse. create_thumb(media_id, search_page, { cached_nodes }) { if(cached_nodes[media_id] != null) { let result = cached_nodes[media_id]; delete cached_nodes[media_id]; return result; } 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: \`
Muted:
\`}); } // 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) { let thumb = this.get_thumbnail_for_media_id(media_id); if(thumb == null) return false; this.scroll_container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.scroll_container.offsetHeight/2; return true; }; pulse_thumbnail(media_id) { let thumb = this.get_thumbnail_for_media_id(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/r137/src/screen_search.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.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(".view-manga-button").addEventListener("click", (e) => { main_controller.singleton.navigate_out(); }); 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) { } refresh = async() => { // 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).url; // Show the folder if we're viewing a local image. let folder_text_element = this.container.querySelector(".folder-text"); let show_folder = helpers.is_media_id_local(this._media_id); if(show_folder) { let {id} = helpers.parse_media_id(this.media_id); folder_text_element.innerText = helpers.get_path_suffix(id, 2, 1); // last two parent directories let parent_folder_id = local_api.get_parent_folder(id); let args = new helpers.args("/", ppixiv.location); local_api.get_args_for_id(parent_folder_id, args); folder_text_element.href = args.url; } // If the author name or folder are empty, hide it instead of leaving it empty. this.container.querySelector(".author-block").hidden = illust_info.userName == ""; this.container.querySelector(".folder-block").hidden = !show_folder; 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_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/r137/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_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("focus", this.focus_changed, true); this.container.addEventListener("blur", this.focus_changed, true); 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 visible. if(this.dropdown_widget.visible) { this.dropdown_widget.hide(); this.edit_widget.show(); } else { this.dropdown_widget.show(); this.edit_widget.hide(); } }); } // 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(); }); } hide() { this.dropdown_widget.hide(); this.edit_widget.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) => { } focus_changed = (e) => { if(e.type == "focus") { this.focused = true; } else // blur { // On blur, relatedTarget is the new focus. If the focus is moving to another // element inside the widget, we're still focused. this.focused = helpers.is_above(this.container, e.relatedTarget); } // If we're focused and nothing was visible, show the tag dropdown. If we're not // focused, hide both. if(this.focused && !this.dropdown_widget.visible && !this.edit_widget.visible) this.dropdown_widget.show(); else if(!this.focused && (this.dropdown_widget.visible || this.edit_widget.visible)) 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, visible: false, template: \`
\`}); // 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.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); // Hack: the input focus will have been on the tag entry we just removed. Focus // the nearest focusable item (probably the tag_search_box_widget container), so // the dropdown isn't closed due to losing focus. this.container.closest("[tabindex]").focus(); 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.visible) return; this.visible = 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.visible) return; this.visible = 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, }); // If result is null, we were probably aborted. if(result == null) return; 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. populate_dropdown = async() => { // 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, visible: false, template: \` \`}); 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.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.visible) return; this.visible = 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.visible) return; this.visible = 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(); } visibility_changed() { super.visibility_changed(); // 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(this.visible); } // 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. populate_dropdown = async() => { // 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/r137/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() { settings.register_change_callback("no_recent_history", this.update_from_settings); this.update_from_settings(); } get db() { // If we're not enabled, avoid creating the database in the first place if it doesn't // already exist. if(!this.enabled) return null; this._db = new key_storage("ppixiv-recent-illusts", { db_upgrade: this.db_upgrade }); return this._db; } get enabled() { // Temporarily (?) disabled due to Chrome bugs causing stuck processes return false; 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) { // Stop if we're not enabled. if(!this.enabled) return; // Clean up old illusts. We don't need to wait for this. await this.purge_old_illusts(); 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); } // Return media_ids for recently viewed illusts, most recent first. async get_recent_media_ids() { if(!this.enabled) return []; 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) { if(!this.enabled) return []; 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() { if(!this.enabled) return; 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() { if(!this.enabled) return []; return await this.db.clear(); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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, unprefixed_tag] = helpers.split_tag_prefixes(tag); tag_list.push(unprefixed_tag); } // 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; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/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() { // 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, { force=false }={}) { // 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(!force && 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, }); await 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 = async (thumb_result, source) => { if(thumb_result.error) return; let all_thumb_info = []; for(let 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; let remapped_thumb_info = null; 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; } // 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(remapped_thumb_info.profileImageUrl) { let profile_image_url = remapped_thumb_info.profileImageUrl; profile_image_url = profile_image_url.replace("_50.", "_170."), this.user_profile_urls[remapped_thumb_info.userId] = profile_image_url; delete remapped_thumb_info.profileImageUrl; } all_thumb_info[remapped_thumb_info.id] = remapped_thumb_info; } // Load any extra image data stored for these media IDs. let illust_ids = Object.keys(all_thumb_info); let extra_data = await extra_image_data.get.batch_load_all_pages_for_illust(illust_ids); for(let [illust_id, info] of Object.entries(all_thumb_info)) { // Store extra data for each page. info.extraData = extra_data[illust_id] || {}; // Store the data. this.add_thumbnail_info(info); let media_id = helpers.illust_id_to_media_id(illust_id); delete this.loading_ids[media_id]; } // 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, whichever 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; } } } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/thumbnail_data.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() { 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") { let args = new helpers.args(url); if(args.hash.get("manga")) return data_sources.manga; else 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.new_works_by_following; 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 if(first_part == "") { // Data sources that don't have a corresponding Pixiv page: let args = new helpers.args(url); if(args.hash_path == "/edits") return data_sources.edited_images; else return null; } 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) { let 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); 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 \${canonical_url}\`); let source = new data_source_class(canonical_url); 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/r137/src/page_manager.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/r137/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: 133, text: \` Pressing Ctrl-P now toggles image panning.

Added image cropping for trimming borders from images. Enable settings Image Editing in the context menu to display the editor.

The page number is now shown over expanded manga posts while hovering over the image, so you can collapse long posts without having to scroll back up. \`, }, { version: 132, text: \` Improved following users, allowing changing a follow to public or private and adding support for follow tags. \`, }, { version: 129, text: \` Added a new way of viewing manga posts.

You can now view manga posts in search results. Click the page count in the corner of thumbnails to show all manga pages. You can also click open_in_full in the top menu to expand everything, or turn it on everywhere in settings. \`, }, { 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/r137/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.pending_movement = [0, 0]; this.listeners = {}; window.addEventListener("unload", this.window_onunload); // 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"); } }); image_data.singleton().illust_modified_callbacks.register((media_id) => { this.broadcast_illust_changes(media_id); }); SendImage.send_image_channel.addEventListener("message", this.received_message); 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) => { // If we were sending an image to another tab, cancel it if this tab is closed. SendImage.send_message({ message: "send-image", action: "cancel", to: settings.get("linked_tabs", []), }); // 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 broadcast_illust_changes(media_id) { // Don't do this if this is coming from another tab, so we don't re-broadcast data // we just received. if(this.handling_broadcasted_image_info) return; // Broadcast the new info to other tabs. this.broadcast_image_info(media_id); } // Send image info to other tabs. We do this when we know about modifications to // an image that other tabs might be displaying, such as the like count and crop // info. This isn't done when we simply load image data from the server, so we're // not constantly sending all search results to all tabs. We don't currently update // thumbnail data from image data, so if a tab edits image data while it doesn't have // thumbnail data loaded, other tabs with only thumbnail data loaded won't see it. static broadcast_image_info(media_id) { // 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: "image-info", from: SendImage.tab_id, media_id: media_id, illust_data: illust_data ?? thumbnail_info, bookmark_tags: image_data.singleton().get_bookmark_details_sync(media_id), user_info: user_info, }, false); } static received_message = async(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) await 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 == "image-info") { // update_media_info will trigger illust_modified_callbacks below. Make sure we don't rebroadcast // info that we're receiving here. Note that add_illust_data can trigger loads, and we won't // send any info for changes that happen before those complete since we have to wait // for it to finish, but normally this receives all info for an illust anyway. this.handling_broadcasted_image_info = true; try { // Another tab is broadcasting updated image info. If we have this image loaded, // update it. let illust_data = data.illust_data; if(illust_data != null) image_data.singleton().update_media_info(data.media_id, illust_data); let bookmark_tags = data.bookmark_tags; if(bookmark_tags != null) image_data.singleton().update_cached_bookmark_image_tags(data.media_id, bookmark_tags); let user_info = data.user_info; if(user_info != null) image_data.singleton().add_user_data(user_info); } finally { this.handling_broadcasted_image_info = false; } } 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 on other tabs when the linked tab list changes. settings.changes.addEventListener("linked_tabs", this.send_link_tab_message, { 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 show-link-tab to tell other tabs to display the "link this tab" popup. // This includes the linked tab list, so they know whether to say "link" or "unlink". send_link_tab_message = () => { if(!this.visible) return; 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: \`
\${ helpers.create_box_link({ label: "Click to send image here", classes: ["link-this-tab"]}) }
\`}); 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/r137/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() { try { // GM_info isn't a property on window in all script managers, so we can't check it // safely with window.GM_info?.scriptHandler. Instead, try to check it and catch // the exception if GM_info isn't there for some reason. if(!ppixiv.native && 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; } } catch(e) { console.error(e); } // 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"); // Create the page manager. page_manager.singleton(); // Run any one-time settings migrations. settings.migrate(); // 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); let refresh_focus = () => { helpers.set_class(document.body, "focused", document.hasFocus()); }; window.addEventListener("focus", refresh_focus); window.addEventListener("blur", refresh_focus); refresh_focus(); 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"; // If we're running on Pixiv, remove Pixiv's content from the page and move it into a // dummy document. let html = document.createElement("document"); if(!ppixiv.native) { 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']}"); } \`); // Add the main stylesheet. { let link = document.realCreateElement("link"); link.href = resources['resources/main.scss']; link.rel = "stylesheet"; document.querySelector("head").appendChild(link); } // If enabled, cache local info which tells us what we have access to. await local_api.load_local_info(); // If login is required to do anything, no API calls will succeed. Stop now and // just redirect to login. This is only for the local API. if(local_api.local_info.enabled && local_api.local_info.login_required) { local_api.redirect_to_login(); return; } // 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.screens = { search: this.screen_search, illust: this.screen_illust, }; // 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(old_screen != new_screen) { let e = new Event("screenchanged"); e.newScreen = new_screen_name; window.dispatchEvent(e); } 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. get_media_url(media_id, {screen="illust", temp_view=false}={}) { 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"); } return args; } show_media(media_id, {add_to_history=false, source="", ...options}={}) { let args = this.get_media_url(media_id, options); 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 screen, remove the page and illust ID. if(screen == "search") { args.hash.delete("page"); 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; } get navigate_out_enabled() { if(this.current_screen_name != "illust" || this.data_source == null) return false; let media_id = this.data_source.get_current_media_id(); if(media_id == null) return false; let info = thumbnail_data.singleton().get_illust_data_sync(media_id); if(info == null) return false; return info.pageCount > 1; } navigate_out() { if(!this.navigate_out_enabled) return; let media_id = this.data_source.get_current_media_id(); if(media_id == null) return; let [illust_id, illust_page] = helpers.media_id_to_illust_id_and_page(media_id); let args = new helpers.args(\`/artworks/\${illust_id}#ppixiv?manga=1\`); 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 || !a.hasAttribute("href")) return; // If this isn't a #ppixiv URL, let it run normally. let url = new unsafeWindow.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. 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 helpers.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/r137/src/main.js `; ppixiv.resources["src/slideshow.js"] = `// This handles the nitty slideshow logic for on_click_viewer. ppixiv.slideshow = class { constructor({ // The size of the image being displayed: width, height, // The size of the window: container_width, container_height, // The minimum zoom level to allow: minimum_zoom, // If true, we're being used for slideshow mode, otherwise auto-pan mode. slideshow_enabled, // The slideshow is normally clamped to the window. This can be disabled by the // editor. clamp_to_window=true, }) { this.width = width; this.height = height; this.container_width = container_width; this.container_height = container_height; this.minimum_zoom = minimum_zoom; this.slideshow_enabled = slideshow_enabled; this.clamp_to_window = clamp_to_window; } // Return some parameters that are used by linear animation getters below. _get_parameters() { // The target duration of the animation: let pan_duration = this.slideshow_enabled? ppixiv.settings.get("slideshow_duration"): ppixiv.settings.get("auto_pan_duration"); let ease; if(this.slideshow_enabled) { // In slideshow mode, we always fade through black, so we don't need any easing on the // transition. ease = "linear"; } else { // There's no fading in auto-pan mode. Use an ease-out transition, so we start // quickly and decelerate at the end. We're jumping from another image anyway // so an ease-in doesn't seem needed. // // A standard ease-out is (0, 0, 0.58, 1). We can change the strength of the effect // by changing the third value, becoming completely linear when it reaches 1. Reduce // the ease-out effect as the duration gets longer, since longer animations don't need // the ease-out as much (they're already slow), so we have more even motion. let factor = helpers.scale_clamp(pan_duration, 5, 15, 0.58, 1); ease = \`cubic-bezier(0.0, 0.0, \${factor}, 1.0)\`; } // 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); // Choose a fade duration. This needs to be quicker if the slideshow is very brief. let fade_in = this.slideshow_enabled? Math.min(pan_duration * 0.1, 2.5):0; let fade_out = this.slideshow_enabled? Math.min(pan_duration * 0.1, 2.5):0; return { ease, pan_duration, max_speed, fade_in, fade_out }; } // Create the default animation. get_default_animation() { let animation = this.get_default_pan(); animation = this.prepare_animation(animation); // If the animation didn't go anywhere, the visible area's aspect ratio very closely // matches the screen's, so there's nowhere to pan. Use a pull-in animation instead. // We don't currently use this in pan mode, because zooming the image when in pan mode // and controlling multiple tabs can be annoying. if(animation.total_travel > 0.05 || !this.slideshow_enabled) return animation; console.log(\`Slideshow: pan animation had nowhere to move, using a pull-in instead (total_travel \${animation.total_travel})\`); return this.prepare_animation(this.get_pull_in()); } // Load a saved animation created with PanEditor. get_animation_from_pan(pan) { let { ease, pan_duration, max_speed, fade_in, fade_out } = this._get_parameters(); let animation = { fade_in, fade_out, pan: [{ x: pan.x1, y: pan.y1, zoom: pan.start_zoom ?? 1, anchor_x: pan.anchor?.left ?? 0.5, anchor_y: pan.anchor?.top ?? 0.5, max_speed: true, speed: max_speed, duration: pan_duration, ease, }, { x: pan.x2, y: pan.y2, zoom: pan.end_zoom ?? 1, anchor_x: pan.anchor?.right ?? 0.5, anchor_y: pan.anchor?.bottom ?? 0.5, }], }; return this.prepare_animation(animation); } // This is like the thumbnail animation, which gives a reasonable default for both landscape // and portrait animations. get_default_pan() { let { ease, pan_duration, max_speed, fade_in, fade_out } = this._get_parameters(); return { fade_in, fade_out, pan: [{ x: 0, y: 0, zoom: 1, max_speed: true, speed: max_speed, duration: pan_duration, ease, }, { x: 1, y: 1, zoom: 1, }], }; } // Return a basic pull-in animation. get_pull_in() { let { pan_duration, ease, fade_in, fade_out } = this._get_parameters(); // This zooms from "contain" to a slight zoom over "cover". return { fade_in, fade_out, pan: [{ x: 0.5, y: 0.0, zoom: 0, duration: pan_duration, ease, }, { x: 0.5, y: 0.0, zoom: 1.2, }], }; } // 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; animation.default_width = this.width; animation.default_height = this.height; // Don't let the zoom go below the original 1:1 size. This allows panning to 1:1 // by setting zoom to 0. There's no inherent max zoom. let minimum_zoom = this.minimum_zoom; let maximum_zoom = 999; // Calculate the scale and translate for each point. for(let point of animation.pan) { let zoom = helpers.clamp(point.zoom, minimum_zoom, maximum_zoom); // The screen size the image will have: let zoomed_width = animation.default_width * zoom; let zoomed_height = animation.default_height * zoom; // Initially, the image will be aligned to the top-left of the screen. Shift right and // down to align the anchor the origin. This is usually the center of the image. let { anchor_x=0.5, anchor_y=0.5 } = point; let move_x = screen_width * anchor_x; let move_y = screen_height * anchor_y; // Then shift up and left to center the point: move_x -= point.x*zoomed_width; move_y -= point.y*zoomed_height; if(this.clamp_to_window) { // Clamp the translation to keep the image in the window. This is inverted, since // move_x and move_y are transitions and not the image position. let max_x = zoomed_width - screen_width, max_y = zoomed_height - screen_height; move_x = helpers.clamp(move_x, 0, -max_x); move_y = helpers.clamp(move_y, 0, -max_y); // 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 total distance the animation travelled. animation.total_travel = 0; for(let point of animation.pan) { if(point.actual_speed == null) continue; animation.total_travel += point.actual_speed * point.duration; } return animation; } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/slideshow.js `; ppixiv.resources["src/extra_image_data.js"] = `"use strict"; // This database is used to store extra metadata for Pixiv images. It's similar // to the metadata files in the local database. // // Data is stored by media ID, with a separate record for each manga page. We // have an index on the illust ID, so we can fetch all pages for an illust ID quickly. ppixiv.extra_image_data = class { // Return the singleton, creating it if needed. static get get() { if(extra_image_data._singleton == null) extra_image_data._singleton = new extra_image_data(); return extra_image_data._singleton; }; constructor() { // This is only needed for storing data for Pixiv images. We don't need it if // we're native. if(ppixiv.native) return; this.db = new key_storage("ppixiv-image-data", { db_upgrade: this.db_upgrade }); } db_upgrade = (e) => { // Create our object store with an index on illust_id. let db = e.target.result; let store = db.createObjectStore("ppixiv-image-data"); store.createIndex("illust_id", "illust_id"); store.createIndex("edited_at", "edited_at"); } async save_illust(media_id, data) { if(this.db == null) return; await this.db.set(media_id, data); } async delete_illust(media_id) { if(this.db == null) return; await this.db.delete(media_id); } // Return extra data for the given media IDs if we have it, as a media_id: data dictionary. async load_illust_data(media_ids) { if(this.db == null) return {}; return await this.db.db_op(async (db) => { let store = this.db.get_store(db); // Load data in bulk. let promises = {}; for(let media_id of media_ids) promises[media_id] = key_storage.async_store_get(store, media_id); return await helpers.await_map(promises); }); } // Return data for all pages of illust_id. async load_all_pages_for_illust(illust_id) { if(this.db == null) return {}; return await this.db.db_op(async (db) => { let store = this.db.get_store(db); let index = store.index("illust_id"); let query = IDBKeyRange.only(illust_id); let cursor = index.openCursor(query); let results = {}; for await (let entry of cursor) { let media_id = entry.primaryKey; results[media_id] = entry.value; } return results; }); } // Batch load a list of illust_ids. The results are returned mapped by illust_id. async batch_load_all_pages_for_illust(illust_ids) { if(this.db == null) return {}; return await this.db.db_op(async (db) => { let store = this.db.get_store(db); let index = store.index("illust_id"); let promises = {}; for(let illust_id of illust_ids) { let query = IDBKeyRange.only(illust_id); let cursor = index.openCursor(query); promises[illust_id] = (async() => { let results = {}; for await (let entry of cursor) { let media_id = entry.primaryKey; results[media_id] = entry.value; } return results; })(); } return await helpers.await_map(promises); }); } // Return the media ID of all illust IDs. // // Note that we don't use an async iterator for this, since it might not be closed // until it's GC'd and we need to close the database consistently. async get_all_edited_images({sort="time"}={}) { console.assert(sort == "time" || sort == "id"); if(this.db == null) return []; return await this.db.db_op(async (db) => { let store = this.db.get_store(db); let index = sort == "time"? store.index("edited_at"):store; let cursor = index.openKeyCursor(null, sort == "time"? "prev":"next"); // descending for time let results = []; for await (let entry of cursor) { let media_id = entry.primaryKey; results.push(media_id); } return results; }); } } //# sourceURL=https://raw.githubusercontent.com/ppixiv/ppixiv/r137/src/extra_image_data.js `; ppixiv.resources["setup.js"] = { "source_files": [ "src/polyfills.js", "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/editing_pan.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/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/image_ui.js", "src/tag_search_dropdown_widget.js", "src/recently_seen_illusts.js", "src/tag_translations.js", "src/thumbnail_data.js", "src/page_manager.js", "src/image_preloading.js", "src/whats_new.js", "src/send_image.js", "src/main.js", "src/slideshow.js", "src/extra_image_data.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. // 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; // Work around quoid/userscripts not defining unsafeWindow. try { unsafeWindow.x; } catch(e) { window.unsafeWindow = window; } // Make sure that we're not loaded more than once. This can happen if we're installed in // multiple script managers, or if the release and debug versions are enabled simultaneously. if(unsafeWindow.loaded_ppixiv) { console.error("ppixiv has been loaded twice. Is it loaded in multiple script managers?"); return; } unsafeWindow.loaded_ppixiv = true; console.log(`ppixiv r${env.version} bootstrap`); let setup = env.resources["setup.js"]; let source_list = setup.source_files; // This is just for development, so we can access ourself in the console. unsafeWindow.ppixiv = env; env.native = false; env.ios = navigator.platform.indexOf('iPhone') != -1 || navigator.platform.indexOf('iPad') != -1; // Load each source file. for(let path of source_list) { let source = env.resources[path]; if(!source) { console.error("Source file missing:", path); continue; } _load_source_file(env, source); } // Load the stylesheet into a URL. This is just so we behave the same // as bootstrap_native. for(let [name, data] of Object.entries(env.resources)) { if(!name.endsWith(".scss")) continue; let blob = new Blob([data]); let blobURL = URL.createObjectURL(blob); env.resources[name] = blobURL; } // Create the main controller. env.main_controller.launch(); } }(this); })(); } }).call({});