// ==UserScript== // @name ppixiv for Pixiv // @author ppixiv // @description Better Pixiv viewing | Fullscreen images | Faster searching | Bigger thumbnails | Download ugoira MKV | Ugoira seek bar | Download manga ZIP | One-click like, bookmark, follow | One-click zoom and pan | Light and dark themes // @include http://*.pixiv.net/* // @include https://*.pixiv.net/* // @run-at document-start // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect pixiv.net // @connect i.pximg.net // @connect self // @version 93 // @namespace ppixiv // @downloadURL none // ==/UserScript== (function() { var resources = { "disabled.html": "
\r\n \r\n \r\n\r\n \r\n
\r\n", "main.css": "* { box-sizing: border-box; }\r\nhtml {\r\n overflow: hidden;\r\n}\r\nbody {\r\n font-family: \"Helvetica Neue\", arial, sans-serif;\r\n}\r\n\r\na {\r\n text-decoration: none;\r\n /*color: #fff;*/\r\n color: inherit;\r\n}\r\n\r\n/* Work around a bad Firefox browser style. It sets transform-origin to 0 0\r\n * on :not(svg), which is wrong because it includes things *inside* svgs, breaking\r\n * our s. */\r\n:not(svg), :not(foreignObject) > svg {\r\n transform-origin: inherit;\r\n}\r\n\r\n/* Theme colors: */\r\nbody {\r\n --button-color: #888;\r\n --button-highlight-color: #eee;\r\n\r\n /* Colors for major UI boxes */\r\n --ui-bg-color: #222;\r\n --ui-fg-color: #fff;\r\n --ui-border-color: #000;\r\n --ui-shadow-color: #000; /* the shadow around some major UI elements */\r\n --ui-bg-section-color: #555; /* color for sections within UI, like the description box */\r\n\r\n --toggle-button-fg-disabled-color: #666;\r\n --toggle-button-fg-dim-color: #888;\r\n --toggle-button-fg-color: #fff;\r\n --toggle-button-bg-dim-color: #222;\r\n --toggle-button-bg-color: #444;\r\n\r\n /* Color for frames like popup menus */\r\n --frame-bg-color: #000;\r\n --frame-fg-color: #fff;\r\n --frame-border-color: #444;\r\n\r\n --dropdown-menu-hover-color: #444;\r\n\r\n /* Box links used for selection in the search UI: */\r\n --box-link-fg-color: var(--frame-fg-color);\r\n --box-link-bg-color: var(--frame-bg-color);\r\n --box-link-disabled-color: #888;\r\n --box-link-hover-color: #443;\r\n --box-link-selected-color: #008;\r\n\r\n /* Color for the minor text style, eg. the bookmark and like counts.\r\n * This is smaller text, with a text border applied to make it readable. */\r\n --minor-text-fg-color: #aaa;\r\n --minor-text-shadow-color: #000;\r\n\r\n --title-fg-color: #fff; /* title strip in image-ui */\r\n --title-bg-color: #444;\r\n\r\n --like-button-color: #888;\r\n --like-button-liked-color: #ccc;\r\n --like-button-hover-color: #fff;\r\n}\r\n\r\nbody.light {\r\n --ui-bg-color: #eee;\r\n --ui-fg-color: #222;\r\n --ui-border-color: #ccc;\r\n --ui-shadow-color: #fff;\r\n --ui-bg-section-color: #ccc; /* color for subsections */\r\n\r\n --button-color: #666;\r\n --button-highlight-color: #222;\r\n\r\n --toggle-button-fg-dim-color: #222;\r\n --toggle-button-fg-color: #000;\r\n --toggle-button-bg-dim-color: #eee;\r\n --toggle-button-bg-color: #ccc;\r\n\r\n --frame-bg-color: #fff;\r\n --frame-fg-color: #222;\r\n\r\n --dropdown-menu-hover-color: #ccc;\r\n\r\n --box-link-hover-color: #ddc;\r\n --box-link-selected-color: #ffc;\r\n\r\n --minor-text-fg-color: #555; /* 555 */\r\n --minor-text-shadow-color: #fff; /* fff */\r\n\r\n --title-fg-color: #fff;\r\n --title-bg-color: #888;\r\n\r\n --like-button-liked-color: #222;\r\n --like-button-hover-color: #000;\r\n}\r\nul {\r\n padding: 0;\r\n margin: 0;\r\n}\r\n.view:focus {\r\n /* Views have tabindex: -1 set. This causes Chrome to put a blue outline around them\r\n * when they're focused, which just puts a weird border around the whole window. Remove\r\n * it. */\r\n outline: none;\r\n}\r\n.view-illust-container {\r\n width: 100%;\r\n height: 100%;\r\n}\r\n\r\n.image-container, .preview-container {\r\n position: absolute;\r\n top: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 100%;\r\n user-select: none;\r\n -moz-user-select: none;\r\n cursor: pointer;\r\n}\r\n\r\n/* Let the browser know about our dynamic zooming and panning. This prevents Chrome from baking the\r\n * resize when it doesn't change for a while, which causes a big hitch the next time we zoom. */\r\n.image-container > img {\r\n will-change: width, height, transform;\r\n}\r\n\r\nimg.low-res-preview {\r\n filter: brightness(80%);\r\n}\r\n\r\n[hidden] {\r\n display: none !important;\r\n}\r\n\r\ntextarea:focus, input:focus, a:focus {\r\n outline: none;\r\n}\r\n\r\n.hide-cursor { cursor: none !important; }\r\n.hide-cursor * { cursor: inherit !important; }\r\n\r\n.main-container {\r\n position: fixed;\r\n top: 0px;\r\n left: 0px;\r\n width: 100%;\r\n height: 100%;\r\n overflow: hidden;\r\n}\r\n.progress-bar {\r\n position: absolute;\r\n pointer-events: none;\r\n background-color: #00F;\r\n bottom: 0px;\r\n left: 0px;\r\n width: 100%;\r\n height: 2px;\r\n}\r\n@keyframes flash-progress-bar { to { opacity: 0; } }\r\n.progress-bar.hide {\r\n animation: flash-progress-bar 500ms linear 1 forwards;\r\n}\r\n\r\n.loading-progress-bar .progress-bar {\r\n z-index: 100;\r\n}\r\n\r\n/* .seek-bar is the outer seek bar area, which is what can be dragged. */\r\n.seek-bar {\r\n position: absolute;\r\n bottom: 0px;\r\n left: 0px;\r\n width: 100%;\r\n\r\n box-sizing: content-box;\r\n height: 12px;\r\n padding-top: 25px;\r\n\r\n cursor: pointer;\r\n}\r\n\r\n.seek-bar .seek-empty {\r\n height: 100%;\r\n background-color: rgba(0,0,0,0.25);\r\n}\r\n\r\n.seek-bar .seek-fill {\r\n background-color: #F00;\r\n height: 100%;\r\n}\r\n\r\n.seek-bar .seek-empty {\r\n transition: transform .25s;\r\n transform: translate(0, 12px);\r\n}\r\n\r\n.seek-bar.visible .seek-empty {\r\n transform: translate(0, 6px);\r\n}\r\n.seek-bar.dragging .seek-empty {\r\n transform: translate(0, 0);\r\n}\r\n\r\n.title-font {\r\n font-weight: 700;\r\n font-size: 20px;\r\n font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,\r\n Droid Sans, Helvetica Neue, Hiragino Kaku Gothic ProN, Meiryo, sans-serif;\r\n}\r\n\r\n.small-font {\r\n font-size: 0.8em;\r\n}\r\n\r\n.hover-message,\r\n.view-search-container > .no-results {\r\n width: 100%;\r\n position: absolute;\r\n bottom: 0px;\r\n display: flex;\r\n justify-content: center; \r\n}\r\n\r\n.hover-message {\r\n transition: opacity .25s;\r\n opacity: 0;\r\n pointer-events: none;\r\n z-index: 100;\r\n}\r\n\r\n.hover-message.show {\r\n opacity: 1;\r\n}\r\n\r\n/* The version in the search container is always centered. */\r\n.view-search-container > .no-results {\r\n bottom: 50%;\r\n}\r\n\r\n.hover-message > .message,\r\n.view-search-container > .no-results > .message {\r\n background-color: var(--frame-bg-color);\r\n color: var(--frame-fg-color);\r\n font-size: 1.4em;\r\n padding: 6px 15px;\r\n margin: 4px;\r\n max-width: 600px;\r\n text-align: center;\r\n border-radius: 5px;\r\n box-shadow: 0 0 10px 5px #aaa;\r\n}\r\n\r\n.view-illust-container .ui {\r\n position: absolute;\r\n top: 0px;\r\n left: 0px;\r\n min-width: 450px;\r\n max-height: 500px;\r\n width: 30%;\r\n height: auto;\r\n\r\n /* Disable events on the top-level container, so it doesn't block clicks on the\r\n * image when the UI isn't visible. We'll reenable events on the hover-box and ui-box\r\n * below it where we actually want pointer events. */\r\n pointer-events: none;\r\n}\r\n.view-illust-container .ui-box {\r\n pointer-events: none;\r\n}\r\n.view-illust-container .ui.disabled {\r\n display: none;\r\n}\r\n\r\n/*\r\n * This is the box that triggers the UI to be displayed. We use this rather than\r\n * ui-box for this so we can give it a fixed size. That way, the UI box won't suddenly\r\n * appear when changing to another image because a longer description caused the box\r\n * to become bigger.\r\n *\r\n * This is a little tricky. Hovering over either hover-box or the UI makes it visible.\r\n * When the UI is hidden, it's set to pointer-events: none, so it can't be hovered,\r\n * but once you hover over hover-box and cause the UI to be visible, pointer events\r\n * are reenabled so hovering over anywhere in the UI keeps it visible. The UI is\r\n * over hover-box in the Z order, so we don't need to disable pointer events on hover-box\r\n * to prevent it from blocking the UI.\r\n *\r\n * We also disable pointer-events on the UI until it's visible, so it doesn't receive\r\n * clicks until it's visible.\r\n */\r\n.hover-box {\r\n width: 400px;\r\n height: 200px;\r\n position: absolute;\r\n top: 0;\r\n left: 0;\r\n pointer-events: auto; /* reenable pointer events that are disabled on .ui */\r\n}\r\n.hover-sphere {\r\n width: 500px;\r\n height: 500px;\r\n\r\n /* Clamp the sphere to a percentage of the viewport width, so it gets smaller for\r\n * small windows. */\r\n max-width: 30vw;\r\n max-height: 30vw;\r\n position: absolute;\r\n top: 0;\r\n left: 0;\r\n}\r\n.hover-sphere circle {\r\n pointer-events: auto; /* reenable pointer events that are disabled on .ui */\r\n}\r\n.hover-sphere > svg {\r\n width: 100%;\r\n height: 100%;\r\n transform: translate(-50%,-50%)\r\n}\r\n.ui-box {\r\n background-color: var(--ui-bg-color);\r\n color: var(--ui-fg-color);\r\n border: solid 2px var(--ui-border-color);\r\n padding: 1em;\r\n border-radius: 8px;\r\n position: relative;\r\n}\r\n\r\n.view-illust-container .ui-box {\r\n margin: .5em;\r\n}\r\n\r\n.view-manga-container .ui-container {\r\n width: 600px;\r\n max-width: 90%;\r\n pointer-events: auto;\r\n}\r\n\r\n/* Since the UI isn't a popup on the manga page, hide the description and\r\n * tag list to make it smaller. These can be viewed while viewing a page. */\r\n.view-manga-container .ui-box > .description,\r\n.view-manga-container .ui-box > .tag-list\r\n{\r\n display: none;\r\n}\r\n\r\n.view-illust-container .ui-box {\r\n transition: transform .25s, opacity .25s;\r\n opacity: 0;\r\n transform: translate(-50px, 0);\r\n pointer-events: none;\r\n}\r\n\r\n/* Debugging: */\r\nbody.force-ui .view-illust-container .ui > .ui-box {\r\n opacity: 1;\r\n transform: translate(0, 0);\r\n pointer-events: inherit;\r\n}\r\n\r\n/* Show the UI on hover when hide-ui isn't set. */\r\nbody:not(.hide-ui) .view-illust-container .ui-box.hovering-over-box,\r\nbody:not(.hide-ui) .view-illust-container .ui-box.hovering-over-sphere {\r\n opacity: 1;\r\n transform: translate(0, 0);\r\n pointer-events: auto;\r\n}\r\n\r\n.button-row {\r\n display: flex;\r\n flex-direction: row;\r\n align-items: center;\r\n height: 32px;\r\n margin-top: 5px;\r\n margin-bottom: 4px;\r\n}\r\n.button-row .button.enabled {\r\n cursor: pointer;\r\n}\r\n\r\n/* The row with the search title, with buttons aligned to the right. The buttons\r\n * are always aligned to the top if the title is long. */\r\n.title-with-button-row {\r\n display: flex;\r\n flex-direction: row;\r\n align-items: start;\r\n}\r\n\r\n/* An icon in a button strip. */\r\n.icon-button {\r\n display: block;\r\n width: 32px;\r\n height: auto;\r\n}\r\n\r\n/* If this is an icon-button with an svg inside, set the svg to block. */\r\n.icon-button > svg {\r\n display: block;\r\n}\r\n.disable-ui-button:hover > .icon-button {\r\n color: #0096FA;\r\n}\r\n.whats-new-button.updates > svg {\r\n color: #cc0;\r\n}\r\nbody.light .whats-new-button.updates > svg {\r\n color: #0aa; /* yellow doesn't work in a light theme */\r\n}\r\n\r\n.navigate-out-button {\r\n cursor: pointer;\r\n}\r\n\r\n.popup-menu-box .menu-toggle {\r\n display: block;\r\n}\r\n.menu-slider input {\r\n vertical-align: middle;\r\n width: 100%;\r\n padding: 0;\r\n margin: 0;\r\n}\r\n\r\n.popup.avatar-popup:hover:after {\r\n left: auto;\r\n bottom: auto;\r\n top: 60px;\r\n right: -10px;\r\n}\r\n.follow-container .avatar {\r\n transition: filter .25s;\r\n display: block;\r\n position: relative;\r\n}\r\n/* .avatar contains an image, and a canvas overlaid on top for hover effects. */\r\n.follow-container .avatar > canvas {\r\n border-radius: 5px;\r\n object-fit: cover;\r\n width: 100%;\r\n height: 100%;\r\n position: absolute;\r\n top: 0;\r\n left: 0;\r\n}\r\n.follow-container .avatar > canvas.highlight {\r\n opacity: 0;\r\n transition: opacity .25s;\r\n}\r\n.follow-container .avatar:hover > canvas.highlight {\r\n opacity: 1;\r\n}\r\n.follow-container:not(.big) .avatar {\r\n width: 50px;\r\n height: 50px;\r\n}\r\n/* For the avatar in the popup menu, use the same size as the other popup menu buttons. */\r\n\r\n.avatar-widget-container .follow-container .avatar {\r\n width: 44px;\r\n height: 44px;\r\n}\r\n.follow-container.big .avatar {\r\n width: 170px;\r\n height: 170px;\r\n}\r\n\r\n/* Hide the avatar while we're waiting for user data to load, since the follow icons aren't\r\n * updated until then. */\r\n.follow-container.loading {\r\n visibility: hidden;\r\n pointer-events: none;\r\n}\r\n\r\n/* The API doesn't tell us whether a follow is private or not, so we can't show\r\n * it. The lock is only used to distinguish the \"follow\" and \"follow privately\"\r\n * buttons. */\r\n.follow-icon .lock {\r\n stroke: #888;\r\n}\r\n.follow-icon:not(.private) .lock {\r\n display: none !important;\r\n}\r\n.follow-container .follow-icon:not(:hover) .outline1 {\r\n stroke: none !important;\r\n}\r\n\r\n/* Hide the following icon if we're not following. */\r\n.follow-container:not(.followed) .follow-icon.following-icon {\r\n display: none;\r\n}\r\n/* Hide the follow buttons if we're already following. */\r\n.follow-container.followed .follow-icon.follow-button {\r\n display: none;\r\n}\r\n\r\n/* Only show the follow buttons on hover (but always show the following icon). */\r\n.follow-container:not(:hover) .follow-icon.follow-button {\r\n display: none;\r\n}\r\n/* If use-dropdown is set, this avatar is using the dropdown UI and doesn't show the\r\n * follow/unfollow overlay buttons. */\r\n.follow-container[data-mode=\"dropdown\"] .follow-icon.follow-button {\r\n display: none;\r\n}\r\n/* Don't show follow buttons or the follow popup for the user. */\r\n.follow-container.self .follow-icon,\r\n.follow-container.self .follow-popup\r\n{\r\n display: none;\r\n}\r\n\r\n.follow-container .follow-icon {\r\n position: absolute;\r\n bottom: 0;\r\n text-align: center;\r\n height: auto;\r\n}\r\n.follow-container .follow-icon {\r\n width: 50%; /* half the size of the container */\r\n max-width: 50px; /* limit the size for larger avatar displays */\r\n}\r\n\r\n.follow-container .follow-icon.bottom-left {\r\n left: 0;\r\n}\r\n.follow-container .follow-icon.bottom-right {\r\n right: 0;\r\n}\r\n\r\n/* In small avatar buttons, nudge the follow buttons down off of the\r\n * avatar, so they don't appear right under the cursor when you're trying\r\n * to click the avatar itself. Only do this with the follow buttons that\r\n * appears on hover, not the following icon (unfollow button), and don't\r\n * do it with the big avatars. */\r\n.follow-container:not(.big) .follow-button {\r\n top: calc(100% - 5px);\r\n}\r\n\r\n.follow-container .follow-icon > svg {\r\n display: block;\r\n width: 100%;\r\n height: auto;\r\n transition: opacity .25s;\r\n\r\n /* Move the icon down, so the bottom of the eye is along the bottom of the\r\n * container and the lock (if visible) overlaps. */\r\n margin-bottom: -20%;\r\n}\r\n.follow-container:not(:hover) .follow-icon > svg {\r\n opacity: 0.5;\r\n}\r\n.follow-container .follow-icon > svg .middle {\r\n transition: transform .1s ease-in-out;\r\n transform: translate(0px, -2px);\r\n}\r\n.follow-container .follow-icon.unfollow-button > svg .middle {\r\n transform: translate(-2px, -5px);\r\n}\r\n.follow-container .follow-icon.unfollow-button:hover > svg .middle {\r\n transform: translate(2px, 5px);\r\n}\r\n/* Don't fade the icons in the context menu, since it's too small and it makes\r\n * it too hard to see at a glance. */\r\n.popup-context-menu .follow-container .follow-icon > svg {\r\n opacity: 1;\r\n}\r\n\r\n.follow-popup {\r\n margin-top: 10px;\r\n right: 0px;\r\n}\r\n.follow-container .hover-area {\r\n top: -12px;\r\n}\r\n.follow-container .avatar-link {\r\n display: block;\r\n}\r\n.follow-popup .folder {\r\n display: block;\r\n}\r\n\r\n.follow-container.followed .follow-popup .not-following { display: none; }\r\n.follow-container:not(.followed) .follow-popup .following { display: none; }\r\n\r\n/* Hide the follow dropdown when following, since there's nothing in it. */\r\n.follow-container.followed.popup-visible .popup-menu-box.hover-menu-box {\r\n visibility: hidden;\r\n}\r\n\r\n.title-block {\r\n display: inline-block;\r\n padding: 0 10px;\r\n color: var(--title-fg-color);\r\n background-color: var(--title-bg-color);\r\n margin-right: 1em;\r\n border-radius: 8px 0;\r\n}\r\n.title-block.popup:hover:after {\r\n top: 40px;\r\n bottom: auto;\r\n}\r\n.author {\r\n vertical-align: top;\r\n}\r\n/* When .dot is set, show images with nearest neighbor filtering. */\r\nbody.dot img.filtering,\r\nbody.dot canvas.filtering {\r\n image-rendering: -moz-crisp-edges;\r\n image-rendering: crisp-edges;\r\n image-rendering: pixelated;\r\n}\r\n.bulb-button:hover > .icon-button {\r\n color: #FF0 !important; /* override grey-icon hover color */\r\n}\r\n\r\nbody.light .bulb-button:hover > .icon-button {\r\n stroke: #000;\r\n}\r\n\r\n.bulb-button > .icon-button {\r\n margin-top: -3px;\r\n}\r\n\r\n.post-info > * {\r\n display: inline-block;\r\n background-color: var(--box-link-bg-color);\r\n color: var(--box-link-fg-color);\r\n padding: 2px 10px;\r\n\r\n /* Use a smaller, heavier font to distinguish these from tags. */\r\n font-size: .8em;\r\n font-weight: bold;\r\n}\r\n.description {\r\n border: solid 1px var(--ui-border-color);\r\n padding: .35em;\r\n background-color: var(--ui-bg-section-color);\r\n max-height: 10em;\r\n overflow-y: auto;\r\n}\r\nbody.light .description {\r\n border: none;\r\n}\r\n/* Override obnoxious colors in descriptions. Why would you allow this? */\r\n.description * {\r\n color: var(--ui-fg-color);\r\n}\r\n\r\n.popup {\r\n position: relative;\r\n}\r\n\r\n.popup:hover:after {\r\n pointer-events: none;\r\n background: #111;\r\n border-radius: .5em;\r\n left: 0em;\r\n top: -2.0em;\r\n color: #fff;\r\n content: attr(data-popup);\r\n display: block;\r\n padding: .3em 1em;\r\n position: absolute;\r\n text-shadow: 0 1px 0 #000;\r\n white-space: nowrap;\r\n z-index: 98;\r\n}\r\n.popup-bottom:hover:after {\r\n top: auto;\r\n bottom: -2em;\r\n}\r\n\r\nbody:not(.premium) .premium-only { display: none; }\r\nbody.hide-r18 .r18 { display: none; }\r\nbody.hide-r18g .r18g { display: none; }\r\n\r\n.popup-menu-box {\r\n position: absolute;\r\n min-width: 10em;\r\n background-color: var(--frame-bg-color);\r\n border: 1px solid var(--frame-border-color);\r\n padding: .25em .5em;\r\n z-index: 2;\r\n}\r\n\r\n.menu-button {\r\n cursor: pointer;\r\n}\r\n\r\n.popup-menu-box.hover-menu-box {\r\n visibility: hidden;\r\n}\r\n.popup-visible .popup-menu-box.hover-menu-box {\r\n visibility: inherit;\r\n}\r\n\r\n/* This is an invisible block underneath the hover zone to keep the hover UI visible. */\r\n.hover-area {\r\n position: absolute;\r\n top: -50%;\r\n left: -33%;\r\n width: 150%;\r\n height: 200%;\r\n z-index: -1;\r\n}\r\n/* This one is under the bookmark popup. Extend over the bottom, so the list doesn\\'t disappear\r\n * when deleting a recent bookmark at the bottom of the list, but don\\'t extend over the top, so\r\n * we don\\'t block the mouse hovering over other things.\r\n *\r\n * Note that the positioning of this is important: we want to fully close the gap between the\r\n * popup and the bottom that opened it, but we don't want to overlap the button and block it. */\r\n.navigation-menu-box .hover-area,\r\n.settings-menu-box .hover-area,\r\n.image-settings-menu-box .hover-area\r\n{\r\n top: -2px;\r\n height: 125%;\r\n}\r\n\r\n.follow-popup input{\r\n margin: .25em;\r\n padding: .25em;\r\n}\r\n.popup-menu-box .button {\r\n padding: .25em;\r\n cursor: pointer;\r\n width: 100%;\r\n}\r\n\r\n.popup-menu-box .button:hover {\r\n background-color: var(--dropdown-menu-hover-color);\r\n}\r\n\r\n.view-search-container {\r\n position: absolute;\r\n width: 100%;\r\n height: 100%;\r\n top: 0;\r\n left: 0;\r\n overflow-x: hidden;\r\n /* Always show the vertical scrollbar, so we don't relayout as images load. */\r\n overflow-y: scroll;\r\n color: #fff;\r\n}\r\n\r\n.top-ui-box\r\n{\r\n /* This places the thumbnail UI at the top, so the thumbnails sit below it when\r\n * scrolled all the way up, and scroll underneath it. */\r\n position: sticky;\r\n top: 0;\r\n width: 100%;\r\n display: flex;\r\n flex-direction: row;\r\n align-items: center;\r\n padding-top: 1em;\r\n padding-bottom: .5em;\r\n z-index: 1;\r\n\r\n /* Prevent the empty space around the UI for centering from eating button presses. */\r\n pointer-events: none;\r\n}\r\n\r\n/* If .ui-on-hover is set, switch to showing the top UI when it's hovered instead of sticky. */\r\nbody.ui-on-hover .top-ui-box\r\n{\r\n position: fixed;\r\n top: auto;\r\n bottom: 100%;\r\n transition: transform ease-out .2s;\r\n\r\n /* Normally pointer-events is disabled above, so the sides of the UI box don't cover clicks.\r\n * However, that also makes the hover not include the top padding above the UI, causing it\r\n * to flicker on and off when the mouse is in that area. This is tricky to fix nicely, so just\r\n * stop disabling pointer-events when ui-on-hover is enabled. */\r\n pointer-events: auto;\r\n}\r\n\r\n/* This is used to temporarily disable the transition when the ui-on-hover setting is\r\n * changed in the options menu. */\r\nbody.ui-on-hover .top-ui-box.disable-transition\r\n{\r\n transition: none;\r\n}\r\n\r\n/* .force-open is set to lock the UI in place when a menu is open. It has the same\r\n * effect as a hover. */\r\nbody.ui-on-hover .top-ui-box.hover,\r\nbody.ui-on-hover .top-ui-box.force-open\r\n{\r\n transform: translateY(100%);\r\n}\r\n\r\nbody.ui-on-hover .top-ui-box:not(.hover):not(.force-open)\r\n{\r\n /* This is the amount the UI pokes on-screen when not hovered. */\r\n transform: translateY(40px);\r\n}\r\n\r\n/* When ui-on-hover is disabled we get spacing at the top of the thumbs automatically from\r\n * position: sticky, but ui-on-hover is position: fixed and we don't get that, so we have\r\n * to add padding manually. */\r\nbody.ui-on-hover .top-ui-box + .top-ui-box-padding\r\n{\r\n height: 30px;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box {\r\n width: 50%;\r\n /* Make sure this doesn't get too narrow, or it'll overlap too much of the thumbnail area. */\r\n min-width: 800px;\r\n background-color: var(--ui-bg-color);\r\n color: var(--ui-fg-color);\r\n box-shadow: 0 0 15px 10px var(--ui-shadow-color);\r\n border-radius: 4px;\r\n\r\n padding: 10px;\r\n pointer-events: auto;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .displaying {\r\n padding-bottom: 4px;\r\n}\r\n\r\n\r\n.view-search-container .thumbnail-ui-box .displaying .word {\r\n padding: 0px 5px;\r\n}\r\n.view-search-container .thumbnail-ui-box .displaying .word:first-child {\r\n padding-left: 0px; /* remove left padding from the first item */\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .displaying .word.or {\r\n font-size: 12px;\r\n padding: 0;\r\n color: #bbb;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .bookmarks-link,\r\n.view-search-container .thumbnail-ui-box .following-link {\r\n display: block;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .following-link {\r\n width: 32px;\r\n height: 32px;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .contact-link {\r\n display: block;\r\n width: 31px;\r\n height: 31px;\r\n margin: 0 3px;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .webpage-link {\r\n display: block;\r\n margin: 0 2px;\r\n width: 26px;\r\n height: 26px;\r\n}\r\n\r\n.view-search-container .thumbnail-ui-box .twitter-icon,\r\n.view-search-container .thumbnail-ui-box .pawoo-icon {\r\n display: block;\r\n width: 32px;\r\n height: 32px;\r\n margin: 0 1px;\r\n}\r\n\r\n/* .thumbnails is the actual thumbnail list. */\r\n.view-search-container .thumbnails {\r\n user-select: none;\r\n -moz-user-select: none;\r\n padding: 0;\r\n text-align: center;\r\n}\r\n\r\n.view-search-container ul.thumbnails {\r\n margin: 0;\r\n margin: 0 auto; /* center */\r\n}\r\n.view-search-container ul.thumbnails > li\r\n{\r\n list-style-type: none;\r\n}\r\n\r\nli.thumbnail-box.load-previous {\r\n width: 100%;\r\n}\r\n\r\nli.thumbnail-load-previous > .load-previous-buttons {\r\n margin-left: auto;\r\n margin-right: auto;\r\n display: flex;\r\n flex-direction: row;\r\n margin-top: 10px;\r\n margin-bottom: 4px;\r\n justify-content: center;\r\n height: 40px;\r\n max-width: 800px;\r\n}\r\nli.thumbnail-load-previous > .load-previous-buttons > .load-previous-button {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n flex-grow: 1;\r\n margin: 0 10px;\r\n background-color: #880;\r\n border-radius: 4px;\r\n padding: 0 10px;\r\n color: var(--box-link-fg-color);\r\n background-color: var(--box-link-bg-color);\r\n}\r\nli.thumbnail-load-previous > .load-previous-buttons > .load-previous-button:hover {\r\n background-color: var(--box-link-hover-color);\r\n}\r\n\r\nli.thumbnail-box {\r\n display: inline-block;\r\n padding: 1em;\r\n}\r\n/* Hide pending images (they haven't been set up yet). */\r\nli.thumbnail-box[data-pending] {\r\n visibility: hidden;\r\n}\r\nli.thumbnail-box .thumbnail-inner {\r\n position: relative;\r\n}\r\n\r\nli.thumbnail-box a.thumbnail-link {\r\n display: block;\r\n\r\n border-radius: 4px;\r\n overflow: hidden;\r\n position: relative;\r\n text-decoration: none;\r\n color: #fff;\r\n}\r\n\r\n.page-count-box {\r\n position: absolute;\r\n right: 2px;\r\n bottom: 2px;\r\n padding: 4px 8px;\r\n background-color: rgba(0,0,0,.6);\r\n border-radius: 6px;\r\n}\r\n\r\n.page-count-box .page-icon {\r\n width: 16px;\r\n height: 16px;\r\n display: inline-block;\r\n vertical-align: middle;\r\n}\r\n\r\n.page-count-box {\r\n transition: opacity .5s;\r\n}\r\n.thumbnail-inner:hover .page-count-box {\r\n/* opacity: 0.5;*/\r\n}\r\n\r\n.page-count-box .page-count {\r\n vertical-align: middle;\r\n margin-left: -4px;\r\n}\r\n\r\n/* The similar illusts button on top of thumbnails. */\r\n.view-search-container li.thumbnail-box .similar-illusts-button {\r\n display: block;\r\n width: 32px;\r\n height: 32px;\r\n margin-top: -2px;\r\n}\r\n.view-search-container li.thumbnail-box:not(:hover) .similar-illusts-button {\r\n visibility: hidden;\r\n}\r\n.view-search-container li.thumbnail-box .similar-illusts-button {\r\n color: #FF0 !important; /* override grey-icon hover color */\r\n opacity: 0.5;\r\n\r\n /* Use a very subtle stroke when not hovered, so it's not completely invisible\r\n * on light backgrounds. */\r\n stroke: rgba(0,0,0,0.5);\r\n}\r\n.view-search-container li.thumbnail-box .similar-illusts-button:hover {\r\n opacity: 1;\r\n stroke: #000;\r\n}\r\n\r\n.view-search-container li.thumbnail-box .thumbnail-bottom-left {\r\n position: absolute;\r\n display: flex;\r\n left: 0px;\r\n bottom: 0px;\r\n}\r\n.view-search-container li.thumbnail-box .heart {\r\n pointer-events: none;\r\n width: 32px;\r\n height: 32px;\r\n}\r\n.view-search-container li.thumbnail-box .heart > svg {\r\n transition: opacity .5s;\r\n}\r\n\r\n.thumbnail-inner:hover .heart > svg {\r\n opacity: 0.5;\r\n}\r\n.view-search-container li.thumbnail-box .ugoira-icon {\r\n pointer-events: none;\r\n width: 32px;\r\n height: 32px;\r\n right: 0px;\r\n bottom: 0px;\r\n color: #fff;\r\n position: absolute;\r\n transition: opacity .5s;\r\n}\r\n\r\n.thumbnail-inner:hover .ugoira-icon {\r\n opacity: 0.5;\r\n}\r\n\r\n.view-search-container li.thumbnail-box[data-pending] a {\r\n /* Don't show a grey box while an image is pending. It just causes extra\r\n * flicker. */\r\n/* opacity: 0.5;\r\n background-color: #444;*/\r\n}\r\n\r\n/* The popup title below thumbs: */\r\n.thumbnail-inner > .thumbnail-label {\r\n position: absolute;\r\n bottom: 3px;\r\n pointer-events: none;\r\n white-space: nowrap;\r\n color: var(--frame-fg-color);\r\n background-color: var(--frame-bg-color);\r\n left: 50%;\r\n position: absolute;\r\n transform: translate(-50%, 0);\r\n padding: 1px 8px;\r\n\r\n /* Max width fills the thumbnail the label is in, minus some space so we don't overlap bottom-left icons. */\r\n max-width: calc(100% - 50px);\r\n overflow: hidden;\r\n border-radius: 2px;\r\n text-overflow: ellipsis;\r\n}\r\n.thumbnail-inner .thumbnail-label > .label {\r\n /* Specify a line-height explicitly, so vertical centering is reasonably consistent for\r\n * both EN and JP text. */\r\n line-height: 19px;\r\n}\r\n\r\n/* Hide the img while it's pending so we don't show a broken image icon. */\r\nli.thumbnail-box[data-pending] a img.thumb {\r\n display: none;\r\n}\r\n\r\n.thumbnail-box .thumb {\r\n object-fit: cover;\r\n\r\n /* Show the top-center of the thunbnail. This generally makes more sense\r\n * than cropping the center. */\r\n object-position: 50% 0%; \r\n width: 100%;\r\n height: 100%;\r\n}\r\n\r\n/* Be careful not to set any transform: scale if zooming is disabled. A scale of 1\r\n * will cause thumbnails to shift around in Firefox. */\r\nbody:not(.disable-thumbnail-zooming) .view-search-container .thumbnail-box .thumb {\r\n transition: transform .5s;\r\n transform: scale(1.25, 1.25);\r\n}\r\n\r\nbody:not(.disable-thumbnail-zooming) .view-search-container .thumbnail-box .thumbnail-inner:hover .thumb {\r\n transform: scale(1, 1);\r\n}\r\n\r\n.thumbnail-box.vertical-panning .thumb,\r\n.thumbnail-box.horizontal-panning .thumb\r\n{\r\n animation-duration: 4s;\r\n animation-timing-function: ease-in-out;\r\n animation-iteration-count: infinite;\r\n}\r\n\r\n.thumbnail-box .thumbnail-inner:not(:hover) .thumb {\r\n animation-play-state: paused;\r\n}\r\n\r\nbody:not(.disable-thumbnail-panning) .thumbnail-box.horizontal-panning .thumb {\r\n animation-name: pan-thumbnail-horizontally;\r\n object-position: left top;\r\n\r\n /* The full animation is 4 seconds, and we want to start 20% in, at the halfway\r\n * point of the first left-right pan, where the pan is exactly in the center where\r\n * we are before any animation. This is different from vertical panning, since it\r\n * pans from the top, which is already where we start (top center). */\r\n animation-delay: -.8s;\r\n\r\n}\r\nbody:not(.disable-thumbnail-panning) .thumbnail-box.vertical-panning .thumb {\r\n animation-name: pan-thumbnail-vertically;\r\n}\r\n\r\n@keyframes pan-thumbnail-horizontally {\r\n /* This starts in the middle, pans left, pauses, pans right, pauses, returns to the middle, then pauses again. */\r\n 0% { object-position: left top; } /* left */\r\n 40% { object-position: right top; } /* pan right */\r\n 50% { object-position: right top; } /* pause */\r\n 90% { object-position: left top; } /* pan left */\r\n 100% { object-position: left top; } /* pause */\r\n}\r\n\r\n@keyframes pan-thumbnail-vertically {\r\n /* This starts at the top, pans down, pauses, pans back up, then pauses again. */\r\n 0% { object-position: 50% 0%; }\r\n 40% { object-position: 50% 100%; }\r\n 50% { object-position: 50% 100%; }\r\n 90% { object-position: 50% 0%; }\r\n 100% { object-position: 50% 0%; }\r\n}\r\n\r\n.view-search-container .thumbnail-box:not(.muted) .muted {\r\n display: none;\r\n}\r\n.view-search-container .thumbnail-box .muted {\r\n pointer-events: none;\r\n left: 0;\r\n top: 50%;\r\n width: 100%;\r\n height: 32px;\r\n color: #000;\r\n position: absolute;\r\n text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff;\r\n font-size: 22px;\r\n}\r\n\r\n/* Zoom muted images in a little, and zoom them out on hover, which is the opposite\r\n * of other images. This also helps hide the black bleed around the edge caused by\r\n * the blur. */\r\n.view-search-container .thumbnail-box.muted .thumb {\r\n filter: blur(10px);\r\n transform: scale(1.25, 1.25);\r\n}\r\nbody:not(.disable-thumbnail-zooming) .view-search-container .thumbnail-box.muted .thumb:hover {\r\n transform: scale(1, 1);\r\n}\r\n\r\n.view-search-container .dot img.thumb {\r\n /* This doesn't work as well on thumbnails. */\r\n /*\r\n image-rendering: -moz-crisp-edges;\r\n image-rendering: crisp-edges;\r\n image-rendering: pixelated;\r\n */\r\n}\r\n\r\n@keyframes flash-thumbnail {\r\n 0% {\r\n filter: brightness(200%);\r\n }\r\n}\r\n\r\n.view-search-container .flash a {\r\n animation-name: flash-thumbnail;\r\n animation-duration: 300ms;\r\n animation-timing-function: ease-out;\r\n animation-iteration-count: 1;\r\n} \r\n\r\n.box-link {\r\n display: inline-block;\r\n cursor: pointer;\r\n text-decoration: none;\r\n padding: .25em .5em;\r\n margin: .25em .25em;\r\n color: var(--box-link-fg-color);\r\n background-color: var(--box-link-bg-color);\r\n user-select: none;\r\n -moz-user-select: none;\r\n white-space: nowrap;\r\n}\r\n\r\n.box-link.disabled {\r\n color: var(--box-link-disabled-color);\r\n}\r\n\r\n.box-link:hover {\r\n background-color: var(--box-link-hover-color);\r\n}\r\n\r\n.box-link.selected {\r\n background-color: var(--box-link-selected-color);\r\n}\r\n\r\n.box-link.tag {\r\n /* Some tags are way too long, since translations don't put any sanity limit on length.\r\n * Cut these off so they don't break the layout. */\r\n max-width: 100%;\r\n text-overflow: ellipsis;\r\n overflow: hidden;\r\n}\r\n\r\n.view-search-container .following-tag {\r\n text-decoration: none;\r\n}\r\n\r\n.view-search-container .search-options-row {\r\n display: flex;\r\n flex-direction: row;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.view-search-container .search-options-row .hover-area {\r\n top: -10px;\r\n height: 150%;\r\n}\r\n.option-list {\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n.search-options-row > div.active > .box-link {\r\n background-color: var(--box-link-selected-color);\r\n}\r\n.search-box {\r\n white-space: nowrap;\r\n margin-bottom: 4px;\r\n position: relative; /* to position the search dropdown */\r\n}\r\n\r\n/* The block around the input box and submit button. A history dropdown widget will\r\n * be placed in here. */\r\n.tag-search-box {\r\n display: inline-block;\r\n position: relative;\r\n}\r\n\r\ninput.search-users {\r\n font-size: 1.2em;\r\n padding: 6px 10px;\r\n vertical-align: middle;\r\n padding-right: 30px; /* extra space for the submit button */\r\n}\r\n.user-search-box .search-submit-button {\r\n margin-left: -30px; /* overlap the input */\r\n}\r\ninput.search-tags {\r\n font-size: 1.2em;\r\n padding: 6px 10px;\r\n vertical-align: middle;\r\n}\r\n\r\n/* Search box in the search page: */\r\n.tag-search-box input.search-tags {\r\n padding-right: 60px; /* extra space for the submit button */\r\n}\r\n\r\n.search-submit-button {\r\n /* Work around HTML's stupid whitespace handling */\r\n font-size: 0;\r\n display: inline-block;\r\n}\r\n\r\n/* Search box in the menu: */\r\n.navigation-search-box .search-submit-button {\r\n vertical-align: middle;\r\n margin-left: -30px; /* overlap the search box */\r\n}\r\n.navigation-search-box input.search-tags {\r\n padding-right: 30px; /* extra space for the submit button */\r\n}\r\n\r\n.view-search-container .right-side-button {\r\n display: inline-block;\r\n vertical-align: middle;\r\n cursor: pointer;\r\n user-select: none;\r\n}\r\n.view-search-container .edit-search-button {\r\n margin-left: -58px; /* overlap the input */\r\n}\r\n.view-search-container .right-side-button > svg\r\n{\r\n vertical-align: middle;\r\n}\r\n\r\n.thumbnail-ui-box .avatar-container {\r\n float: right;\r\n position: relative;\r\n margin-left: 25px;\r\n}\r\n\r\n.image-for-suggestions {\r\n float: right;\r\n margin-left: 25px;\r\n}\r\n.image-for-suggestions > img {\r\n display: block;\r\n max-height: 150px;\r\n border-radius: 5px; /* matches the avatar display */\r\n}\r\n\r\n.grey-icon {\r\n color: var(--button-color);\r\n}\r\n:hover > .grey-icon {\r\n color: var(--button-highlight-color);\r\n}\r\n/* If a grey-icon is directly inside a visible popup menu, eg. the navigation icon: */\r\n.popup-visible > .grey-icon {\r\n color: var(--button-highlight-color);\r\n}\r\n\r\n.mute-display .muted-image {\r\n position: absolute;\r\n top: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 100%;\r\n object-fit: cover;\r\n filter: blur(20px);\r\n opacity: .75;\r\n}\r\n\r\n.mute-display .muted-text {\r\n position: absolute;\r\n width: 100%;\r\n top: 50%;\r\n left: 0;\r\n text-align: center;\r\n font-size: 30px;\r\n color: #000;\r\n text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff;\r\n}\r\n\r\n/* Tag lists are usually inline. Make the tag filter a vertical list. */\r\n.member-tags-box .post-tag-list,\r\n.search-tags-box .related-tag-list {\r\n max-height: 300px;\r\n display: block;\r\n overflow-x: hidden;\r\n overflow-y: auto;\r\n white-space: nowrap;\r\n}\r\n.member-tags-box .post-tag-list .following-tag,\r\n.search-tags-box .related-tag-list .tag {\r\n display: block;\r\n}\r\n\r\n.member-tags-box .post-tag-list .following-tag:hover:after,\r\n.search-tags-box .related-tag-list .tag:hover:after {\r\n left: auto;\r\n right: 0px;\r\n}\r\n\r\n/* These affect both the search edit and search history boxes. */\r\n.input-dropdown {\r\n width: 500px; /* overridden by script */\r\n max-width: 800px;\r\n margin: 1px;\r\n z-index: 1;\r\n user-select: none;\r\n\r\n /* This is used for the search tag dropdown, which is in a fixed position at the top of the\r\n * screen. Limit the height to the size of the window minus (roughly) its position. */\r\n max-height: 400px;\r\n max-height: calc(100vh - 400px);\r\n\r\n /* Always show the vertical scrollbar. Otherwise, the resize handle falls under the buttons\r\n * when it's not shown. */\r\n overflow-x: hidden;\r\n overflow-y: scroll;\r\n resize: horizontal;\r\n position: absolute;\r\n background-color: #fff;\r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list {\r\n display: flex;\r\n flex-direction: column;\r\n white-space: normal;\r\n}\r\n.input-dropdown > .input-dropdown-list > .entry {\r\n display: flex;\r\n flex-direction: row;\r\n color: #000;\r\n align-items: center;\r\n\r\n /* This 6px vertical padding should match the remove-history-entry padding. */\r\n padding: 6px 0;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list > .entry > .search {\r\n color: #000;\r\n flex: 1;\r\n padding-left: 7px;\r\n height: 100%;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list > .entry > .search .word {\r\n display: inline-flex;\r\n align-items: center;\r\n height: 100%;\r\n padding: 0px 5px;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list > .entry > .search .word.or {\r\n font-size: 12px;\r\n padding: 0;\r\n color: #333;\r\n}\r\n\r\n/* Styles specific to the search history version of the dropdown: */\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry .suggestion-icon {\r\n margin: 2px -2px 0 2px; \r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry:not(.autocomplete) .suggestion-icon {\r\n display: none;\r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry.selected {\r\n background-color: #ffa;\r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry:hover {\r\n background-color: #ddd;\r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list .remove-history-entry {\r\n height: 30px;\r\n width: 30px;\r\n\r\n /* Set an arbitrarily low negative margin. This makes it so the button extends into the\r\n * into the surrounding row's padding instead of pushing the whole row out. See\r\n * .input-dropdown-list > .entry padding. */\r\n margin: -6px 0;\r\n\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n visibility: hidden;\r\n}\r\n\r\n/* Hide the button to remove history entries from non-history entries. */\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry {\r\n display: none;\r\n}\r\n\r\n.search-history > .input-dropdown > .input-dropdown-list > .entry:hover .remove-history-entry {\r\n visibility: visible;\r\n}\r\n.search-history > .input-dropdown > .input-dropdown-list .remove-history-entry:hover {\r\n color: #000;\r\n background-color: #c0c0c0;\r\n}\r\n\r\n/* Styles specific to the edit search version of the dropdown. */\r\n.edit-search > .input-dropdown {\r\n padding: 4px 0;\r\n}\r\n/* The edit search list is shown as a wrapped list, so enable wrapping and switch items from flex to inline-flex. */\r\n.edit-search > .input-dropdown > .input-dropdown-list {\r\n white-space: normal;\r\n max-width: 100%;\r\n}\r\n\r\n.edit-search > .input-dropdown > .input-dropdown-list > .entry {\r\n display: inline-flex;\r\n}\r\n\r\n.edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight { background-color: #eeee00; }\r\n.edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag:hover { background-color: #0099FF; }\r\n.edit-search > .input-dropdown > .input-dropdown-list > .entry > A.search .tag.highlight:hover { background-color: #00CCFF; }\r\n\r\n.manga-thumbnail-container\r\n{\r\n position: absolute;\r\n bottom: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 240px;\r\n max-height: 30%;\r\n user-select: none;\r\n -moz-user-select: none;\r\n}\r\n\r\nbody.hide-ui .manga-thumbnail-container\r\n{\r\n display: none;\r\n}\r\n\r\n/* The .strip container is the overall strip. This is a flexbox that puts the nav\r\n * arrows on the outside, and the thumb strip stretching in the middle. The thumb\r\n * strip itself is also a flexbox, for the actual thumbs. */\r\n.manga-thumbnail-container > .strip\r\n{\r\n background-color: var(--ui-bg-color);\r\n height: 100%;\r\n display: flex;\r\n flex-direction: row;\r\n\r\n opacity: 0;\r\n transition: transform .15s, opacity .15s;\r\n transform: translate(0, 25px);\r\n}\r\n\r\n.manga-thumbnail-container.visible > .strip\r\n{\r\n opacity: 1;\r\n transform: translate(0, 0);\r\n}\r\n\r\n.manga-thumbnail-container > .strip > .manga-thumbnails {\r\n flex: 1;\r\n\r\n display: flex;\r\n flex-direction: row;\r\n overflow: hidden;\r\n justify-content: left;\r\n scroll-behavior: smooth;\r\n height: 100%;\r\n padding: 5px 0;\r\n}\r\n\r\n.manga-thumbnail-container .manga-thumbnail-box\r\n{\r\n cursor: pointer;\r\n height: 100%;\r\n margin: 0 5px;\r\n\r\n /* The first entry has the cursor inside it. Set these to relative, so the\r\n * cursor position is relative to it. */\r\n position: relative;\r\n}\r\n\r\n.manga-thumbnail-container .manga-thumbnail-box img.manga-thumb\r\n{\r\n height: 100%;\r\n width: auto;\r\n border-radius: 3px;\r\n\r\n /* This will limit the width to 300px, cropping if needed. This prevents\r\n * very wide aspect ratio images from breaking the layout. Only a fixed\r\n * size will work here, percentage values won't work. */\r\n max-width: 400px;\r\n object-fit: cover;\r\n}\r\n\r\n.manga-thumbnail-arrow\r\n{\r\n height: 100%;\r\n width: 30px;\r\n margin: 0 6px;\r\n}\r\n\r\n.manga-thumbnail-arrow > svg\r\n{\r\n fill: #888;\r\n}\r\n.manga-thumbnail-arrow:hover > svg\r\n{\r\n fill: #ff0;\r\n}\r\nbody.light .manga-thumbnail-arrow:hover > svg\r\n{\r\n stroke: #aa0;\r\n}\r\n\r\n.manga-thumbnail-arrow > svg\r\n{\r\n display: block;\r\n height: 100%;\r\n width: 100%;\r\n padding: 4px;\r\n}\r\n\r\n.thumb-list-cursor\r\n{\r\n position: absolute;\r\n left: 0;\r\n bottom: -6px;\r\n width: 40px;\r\n height: 4px;\r\n background-color: var(--ui-fg-color);\r\n border-radius: 2px;\r\n}\r\n\r\n/* The right click context menu for the image view: */\r\n.popup-context-menu {\r\n color: #fff;\r\n position: fixed;\r\n top: 100px;\r\n left: 350px;\r\n text-align: left;\r\n padding: 10px;\r\n border-radius: 8px;\r\n display: flex;\r\n flex-direction: column;\r\n user-select: none;\r\n -moz-user-select: none;\r\n}\r\n\r\n/* Hide the normal tooltips. The context menu shows them differently. */\r\n.popup-context-menu .popup:hover:after {\r\n display: none;\r\n}\r\n\r\n.popup-context-menu .tooltip-display {\r\n display: flex;\r\n align-items: stretch;\r\n padding: 10px 0 0 8px;\r\n pointer-events: none;\r\n}\r\n.popup-context-menu .tooltip-display .tooltip-display-text {\r\n background-color: var(--frame-bg-color);\r\n color: var(--frame-fg-color);\r\n padding: 2px 8px;\r\n border-radius: 4px;\r\n}\r\n\r\n.popup-context-menu .button-strip {\r\n display: flex;\r\n align-items: stretch;\r\n}\r\n.popup-context-menu .button-strip > .button-block {\r\n display: inline-block;\r\n background-color: var(--frame-bg-color);\r\n padding: 12px;\r\n}\r\n\r\n/* Remove the double horizontal padding: */\r\n.popup-context-menu .button-strip > .button-block:not(:first-child) { padding-left: 0px; }\r\n\r\n/* Remove the double vertical padding. Do this with a negative margin instead of zeroing\r\n * the padding, so the rounded black background stays the same size. */\r\n.popup-context-menu .button-strip:not(:last-child) > .button-block { margin-bottom: -12px; }\r\n\r\n/* Round the outer corners of each strip. */\r\n.popup-context-menu .button-strip > .button-block:first-child { border-radius: 5px 0 0 5px; }\r\n.popup-context-menu .button-strip > .button-block:last-child { border-radius: 0 5px 5px 0; }\r\n\r\n.popup-context-menu .button-strip .button {\r\n border-radius: 4px;\r\n padding: 6px;\r\n height: 100%;\r\n text-align: center;\r\n cursor: pointer;\r\n display: flex;\r\n flex-direction: column;\r\n justify-content: center;\r\n background-color: var(--toggle-button-bg-dim-color);\r\n color: var(--toggle-button-fg-dim-color);\r\n}\r\n\r\n/* This nudges the zoom strip to the left by the width of one button, to add the browser\r\n * back button to the left of other buttons. */\r\n.popup-context-menu .button-strip > .button-block.shift-left {\r\n margin-left: -56px;\r\n}\r\n\r\n/* Grey out the buttons if this strip isn't enabled. */\r\n.popup-context-menu .button-strip .button:not(.enabled)\r\n{\r\n cursor: inherit;\r\n color: var(--toggle-button-fg-disabled-color);\r\n}\r\n.popup-context-menu .button-strip .button > * {\r\n min-width: 32px;\r\n}\r\n.popup-context-menu .button-strip .button > svg {\r\n width: 32px;\r\n height: 32px;\r\n}\r\n\r\n.popup-context-menu .button-strip .button.enabled:hover {\r\n color: var(--toggle-button-fg-color);\r\n}\r\n.popup-context-menu .button-strip .button.enabled.selected {\r\n background-color: var(--toggle-button-bg-color);\r\n color: var(--toggle-button-fg-color);\r\n}\r\n\r\n/* We don't have a way to add classes to inlined SVGs yet, so for now just use nth-child.\r\n The first child is the + icon and the second child is -. */\r\n.popup-context-menu .button-strip .button.button-zoom:not(.selected) > :nth-child(1) { display: none; }\r\n.popup-context-menu .button-strip .button.button-zoom.selected > :nth-child(2) { display: none; }\r\n\r\n/* Popup menu bookmarking */\r\n.popup-context-menu .button-strip .button .tag-dropdown-arrow {\r\n width: 0; \r\n height: 0; \r\n border-top: 10px solid #222;\r\n border-left: 10px solid transparent;\r\n border-right: 10px solid transparent;\r\n}\r\nbody.light .popup-context-menu .button-strip .button .tag-dropdown-arrow {\r\n border-top-color: #ccc;\r\n}\r\n\r\n.popup-context-menu .context-menu-image-info {\r\n /* Bottom align within the row. */\r\n align-self: flex-end;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n background-color: var(--box-link-bg-color);\r\n padding-right: 8px;\r\n}\r\n.popup-context-menu .context-menu-image-info > * {\r\n background-color: var(--box-link-bg-color);\r\n color: var(--box-link-fg-color);\r\n padding: 2px 0 0px 0px;\r\n font-size: .8em;\r\n font-weight: bold;\r\n}\r\n\r\n.popup-bookmark-tag-dropdown {\r\n background-color: var(--frame-bg-color);\r\n color: var(--frame-fg-color);\r\n position: absolute;\r\n padding: 4px;\r\n top: calc(100%);\r\n left: 0;\r\n border-radius: 0px 0px 4px 4px;\r\n\r\n /* Put this on top of other elements, like the image-ui tag list. */\r\n z-index: 1;\r\n}\r\n/* In the context menu version, nudge the tag dropdown up slightly to cover\r\n * the rounded corners. */\r\n.popup-context-menu .popup-bookmark-tag-dropdown {\r\n top: calc(100% - 4px);\r\n}\r\n\r\n.popup-bookmark-tag-dropdown > .tag-list {\r\n display: flex;\r\n flex-direction: column;\r\n max-height: 200px;\r\n min-width: 200px;\r\n overflow-x: hidden;\r\n overflow-y: auto;\r\n}\r\n\r\n.popup-bookmark-tag-dropdown > .tag-right-button-strip {\r\n position: absolute;\r\n top: 0;\r\n left: 100%;\r\n background-color: var(--frame-bg-color);\r\n color: var(--frame-fg-color);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n}\r\n.popup-bookmark-tag-dropdown > .tag-right-button-strip .tag-button {\r\n cursor: pointer;\r\n}\r\n\r\n.button.button-bookmark,\r\n.button.button-like\r\n{\r\n /* Center the count text. */\r\n}\r\n.button.button-bookmark .count,\r\n.button.button-like .count\r\n{\r\n color: var(--minor-text-fg-color);\r\n\r\n text-shadow:\r\n 0px 1px 1px var(--minor-text-shadow-color),\r\n 0px -1px 1px var(--minor-text-shadow-color),\r\n 1px 0px 1px var(--minor-text-shadow-color),\r\n -1px 0px 1px var(--minor-text-shadow-color);\r\n font-size: .7em;\r\n font-weight: bold;\r\n position: absolute;\r\n top: calc(100% - 14px);\r\n left: 0;\r\n width: 100%;\r\n text-align: center;\r\n}\r\n\r\n.ui-box .button > svg {\r\n display: block;\r\n}\r\n\r\n.ui-box .button.button-bookmark .count,\r\n.ui-box .button.button-like .count\r\n{\r\n top: calc(100% - 11px);\r\n left: calc(-50px + 50%)\r\n width: 100px;\r\n pointer-events: none;\r\n}\r\n\r\n/* Nudge the public heart icon up a bit to make room for the bookmark count. \r\n * Only do this on the popup menu, not image-ui. */\r\n.popup-context-menu .button.button-bookmark.public > svg\r\n{\r\n margin-top: -10px;\r\n}\r\n.popup-context-menu .button.button-like > svg\r\n{\r\n margin-top: -2px;\r\n}\r\n\r\n/* Bookmark buttons. These appear in image_ui and the popup menu. */\r\n.button.button-bookmark.will-delete.enabled:hover svg.heart-image .delete {\r\n display: inline;\r\n}\r\n\r\n/* Hide the \"delete\" stroke over the heart icon unless clicking the button will\r\n * remove the bookmark. */\r\nsvg.heart-image .delete {\r\n display: none;\r\n}\r\n\r\n/* These are !important to override the default white coloring in the context\r\n * menu. */\r\n.button-bookmark {\r\n color: #400 !important;\r\n}\r\n.button-bookmark.enabled {\r\n color: #800 !important;\r\n stroke: none;\r\n}\r\n.button-bookmark.bookmarked,\r\n.button-bookmark.enabled:hover {\r\n color: #f00 !important;\r\n stroke: none;\r\n}\r\n/* Add a stroke around the heart on thumbnails for visibility. Don't\r\n * change the black lock. */\r\n.view-search-container .thumbnails .button-bookmark svg > .heart {\r\n stroke: #000;\r\n stroke-width: .5px;\r\n}\r\n\r\n/* This is a pain due to transition bugs in Firefox. It doesn't like having\r\n * transition: transform on both an SVG and on individual paths inside the\r\n * SVG and clips the image incorrectly during the animation. Work around this\r\n * by only placing transitions on the paths. */\r\n.button.button-like > svg {\r\n color: var(--like-button-color);\r\n}\r\n\r\n.button.button-like.liked > svg {\r\n color: var(--like-button-liked-color);\r\n}\r\n.button.button-like.enabled:hover > svg {\r\n color: var(--like-button-hover-color);\r\n}\r\n\r\n.button.button-browser-back .arrow {\r\n transition: transform ease-in-out .15s;\r\n transform: translate(-2px, 0px);\r\n}\r\n.button.button-browser-back:hover .arrow {\r\n transform: translate(1px, 0px);\r\n}\r\n.button.button-like > svg > * {\r\n transition: transform ease-in-out .15s;\r\n transform: translate(0, 0px);\r\n}\r\n.button.button-like > svg > .mouth {\r\n transform: scale(1, .75);\r\n}\r\n\r\n.button.button-like.liked > svg > * {\r\n transform: translate(0, -3px);\r\n}\r\n.button.button-like.liked > svg > .mouth {\r\n transform: scale(1, 1.1) translate(0, -3px);\r\n}\r\n.button.button-like.enabled:hover > svg > * {\r\n transform: translate(0, -2px);\r\n}\r\n.button.button-like.enabled:hover > svg > .mouth {\r\n transform: scale(1, .9) translate(0, -3px);\r\n}\r\n.button-bookmark.public svg.heart-image .lock {\r\n display: none;\r\n}\r\n.button-bookmark svg.heart-image .lock {\r\n stroke: #888;\r\n}\r\n.view-manga-container {\r\n width: 100%;\r\n height: 100%;\r\n overflow-x: hidden;\r\n overflow-y: scroll;\r\n}\r\n\r\n.view-manga-container > .thumbnails {\r\n width: 100%;\r\n text-align: center;\r\n\r\n /* If we don't fill the screen, center. */\r\n margin: 0 auto;\r\n}\r\n.view-manga-container > .thumbnails {\r\n width: 100%;\r\n}\r\n\r\n.view-manga-container li.thumbnail-box {\r\n /* The size of .thumbnail-box is set to the dimensions of each thumb, not\r\n * including padding, so set box-sizing to content-box. */\r\n// box-sizing: content-box;\r\n}\r\n/* .thumbnail-inner centers each thumb in the box, and aligns it to the bottom. */\r\n/*\r\n * .view-manga-container li.thumbnail-box > .thumbnail-inner {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n height: 100%;\r\n width: 100%;\r\n}\r\n\r\n.view-manga-container img.thumb {\r\n border-radius: 4px;\r\n display: block;\r\n}\r\n.view-manga-container li.thumbnail-box {\r\n display: inline-block;\r\n padding: .5em;\r\n vertical-align: bottom;\r\n}\r\n*/\r\n\r\n.whats-new-box {\r\n position: absolute;\r\n z-index: 1000;\r\n top: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 100%;\r\n background-color: rgba(0,0,0,0.75);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.whats-new-box > .content {\r\n font-size: 18px;\r\n width: 80%;\r\n max-width: 800px;\r\n height: 80%;\r\n background-color: var(--ui-bg-color);\r\n color: var(--ui-fg-color);\r\n border-radius: 5px;\r\n position: relative;\r\n}\r\n\r\n.whats-new-box > .content > .scroll {\r\n width: 100%;\r\n height: 100%;\r\n overflow-y: auto;\r\n padding: 1em;\r\n}\r\n\r\n.whats-new-box .close-button {\r\n position: absolute;\r\n top: 5px;\r\n right: -40px;\r\n color: var(--button-color);\r\n background-color: var(--ui-bg-color);\r\n padding: 4px;\r\n border-radius: 5px;\r\n cursor: pointer;\r\n}\r\n.whats-new-box .close-button:hover {\r\n color: var(--button-highlight-color);\r\n}\r\n.whats-new-box .close-button > svg {\r\n display: block;\r\n}\r\n\r\n.whats-new-box .header {\r\n font-size: 40px;\r\n margin-bottom: 20px;\r\n}\r\n\r\n.whats-new-box .rev {\r\n display: inline-block;\r\n color: var(--box-link-fg-color);\r\n background-color: var(--box-link-bg-color);\r\n padding: 5px 10px;\r\n}\r\n.whats-new-box .text {\r\n margin: 1em 0;\r\n padding: 0 20px; /* inset horizontally a bit */\r\n}\r\n", "main.html": "
\r\n
\r\n
\r\n
\r\n\r\n
\r\n\r\n \r\n
\r\n
\r\n
\r\n\r\n
\r\n\r\n \r\n \r\n\r\n
\r\n
\r\n \r\n \r\n \r\n
\r\n
\r\n
\r\n\r\n \r\n \r\n\r\n \r\n\r\n \r\n \r\n \r\n
\r\n \r\n \r\n \r\n\r\n \r\n\r\n \r\n\r\n \r\n \r\n\r\n \r\n \r\n\r\n \r\n\r\n \r\n\r\n \r\n\r\n \r\n \r\n \r\n\r\n \r\n \r\n \r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n\r\n \r\n \r\n \r\n\r\n \r\n \r\n
\r\n
\r\n" }; var binary_data = { "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", "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=", "noise-light.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAD1BMVEXo6Ojn5+fo6Ojn5+fn5+cbG+42AAAACXBIWXMAAAsTAAALEwEAmpwYAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yNlQyMzowMjo0My0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIyIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OWM1ZTI1ZTktNmM0Ny1lMTRkLWI1NmYtMWM3MWRhOTk3NzAxIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ODcxMzVhMGQtMTYwMy1hODRhLTliZWMtNGJhMDllZDE1NGQ2IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MTEzYjRiYTgtMmYzNC0zZTQ2LTgyZmUtNTM2ZDNlZDgzYTlmIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMTNiNGJhOC0yZjM0LTNlNDYtODJmZS01MzZkM2VkODNhOWYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjZUMjM6MDI6NDMtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjljNWUyNWU5LTZjNDctZTE0ZC1iNTZmLTFjNzFkYTk5NzcwMSIgc3RFdnQ6d2hlbj0iMjAxOC0wNy0wMVQxNzoyMjozNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5BpB4MAAAWVUlEQVR4nKWb25osuXGrfyD4/k9cBHwRzDXy/rw9GvmuR+pVncWMA04cTq2C5I5wXU5HHRDgnmL11BVjMK4BMHY1cFAFTCU4OQVOjEd2ZVdIs3/JPZURwhpF2KoYUDUXMDkXJGlQVHxVixbXtqIpo5rpJArIBQtfh4FiEairACgUaaIgcHGYwQRcpVJ8B1oQnOJQdQD1BDuI1CkuwZf4GnRUVVKLW3D3b8oR/U4TI4JVFeFqptSFY4AWWWIaWRSpakdWc3yd/TVMcPfPnKjnJ1edVgj3KGIouqjAAe+HqaLaf23gICRG+HCMZIE0GGGMtO/nSIPH2n+h8QFsg3QkAYP2B/ZLwwgbhJGNYYyNbQbPSTFVXAqRjXMqCjiyHKjVUtx0VE6mIZLUuqAOlba0wENlSVWx+ypILWoBOSrqpGiPEUmZtOn5TTsaA9l2KALTURVMqxNX1GDNJUK0ruL6FlH0ykHKRCChCrtBljqcgFQNvoD39RRRSY50OeK4pexfYFrV6WSoahXrO8aqCtLUrZCDwlwxouIYRZMKCXSExmZ0wAySYeSzvT6MhNH7b8bnGDiy9pcN1tbDjL3vX8egM0doOOcwgGUADbOfDZ067lxnsDtVPIVMrVIBQo2RKmV+mSCk8grS2WptVPA4JoCTFjA3THnnJYAJYgRVpHbiyk2mkMKooMl+MuhPO++LFVuolagclxqQ0mjocVUGcZRTh/0R1QgyyghTqZ07glSn0sBMKAd9dTsUJKzgClUxp1Lp1y8FE4EoEeHcKY1KVZeGU9nBJh6oTnHltlQEo7szxkmsjF6/CITjUEGNrqrKilt3j1ej8o1cEU8OlUQR01ahvn++0X1FX80P1fse20NaDck+TanrH3KrbJtJ9Q8p7Ai45oLA15kG0PwEVIqcb5JWFN/ZF3myc7MnuEyl6iRIRCoqhsJ+pNlPUutk6gBWdKGcOurUmVhOTyVhRzlSjAmKqo7ZAqJ4+9Z2K4brSEHtUN4pYUup7lRGqk1OxeDtDIFy3nccoNRhl9RcIlWxcqg9Ffb9Xo7rzmVrIqKFE2e6q97OG9Fon2eboxUEGEXApLP/g1qd/cVR5hqo+rpf7wl337qMqqoj9M56h1/anTbdXqxOWw5OsV9rjhr2wd2myBS1mrhzK1oLy9TxPQo+eKzDSJakebPqwIA5oBECwegIbAnNTkCLGWSwvasUfHaOImxjjkYWx9qpysEgM1Rz6+CIanbrML5vukVvpg2TsmevutOpUdyiTmdxTU8sXVxLeC49+Hr3a9Td9tFErsbSnVZwMlSyKeyr4VX6qE5E0dmBt02k6g1H6I4KFCngqKLzU9VGdWSKd2tVFMUahbPzNciivpEmeQB1ZLXjeyK2m/e9TtS66sQunYKMenzVgTr1fWDIivOeuy1GU1yxy07iSHPA2jU2u7J4+GKX5lbDQeDRgH12D75jsDzIfigLS2Mk7z9hmP3tEUcM8qnuzlW4hSwwPtcj7giEnDM/sLmiYZdRx9lvrd3NGrVFinx2eGhadkeRuLrahpPTqTstB8k7hrC3fXvuOG4NkpMYLyivOiUs3pF/b0qq6GrBcB1fMcH0hOgBI1ev7luGRz/a1pfKjibeuZc4O5BAi2Vn31mRuyBouGh3duc14aH6s61irovd6kTZHVod4UtnBjmuqSWglWs7HPBc1WMyMfY9F5yFcaNUurPfsKi6UaU8hNdt83cm+1zu7GANZrI46mExDDOIgcEAC6MljgZpZA72oCPbmoPwyDrIW5dnxhrBGe/QGo4k6Wwt6+N1wPHIleIuq7tConPhtbw9Fu2ohKpCZVql1uVkFBGDmLu7pzW+yjyoT/Ww0u567SjTbBHD7hn/IRPgAzPLHpdEnMVWgw+ah0W3wf6WvYwl9jwY2yOQhOShHueo+Cvr2iUmk/HioHQuiKlR5QtGO7XF6zjFThbtqUYd5BsmOEg4Lpbve4CeB5P/Iaz2v8DqJXLI5y9Ib0seYYZz9il3ohxxRpJ1xhZ4Oq7DeaiQ1B9UW3SYpSKNYEKncjHQxU7AqHVMVCHoYzHq6Go69KEw0J0FD1NpMpM6Ol2gqTeN6sE5KeVIi7MkMYsnXVpFc+UIV+e3OGrPunYeglcz8UTFW3o7JAQmnaIwDe/hVSuTLhfV4LT7XLWUolEcuYhOJpq4mTK06kKewpgeYOIbI4OUg9z9rwTN/mFxsvoJjhVX5XTIfJT2FPVOVToswzc51512qcX++U+puVPSiWAkheXZ6DpbvFKRfQb8n8oEXi7+j2QCPZnA9hlVpW9gQj03s8ChrveUC6N48oDKcqHiaZHqwr4KYEeVzIQRO4K8xzbd/1ucKAvtPfvFJcvbNeMHAoBjefwwwUeG5bO/jg4HYx8L8XrO3wfY9qJFvKoYswcJOhiGuXi/B1ugXtFoSfmur1KerjRxK+osyXeYqz1COWeXjFTPwkUj5ROPZimOKpg7SIM8gikMFUyWzanAtMY90XTKFBHmsaIF9+RkVv960luYi2+dk21FIpieu3tge6hsPfMpX/CHhDV4+X8LRNNWBzdMx84UjB/DUlbsfPLP/FxX+GJ9xGHuIaKVaMVUsxDYl0Sm3Up+4BWuaqP6rDz32xPlJpMBe7/JrJYxT3OZ3jHXtMeJm8uEq1+o0SwWvYWr0U4OVE8Lzk4QkacvTVucCkftMMrCZGgVk9nfoJqqjhXhrh6V4yizdeNAEHbrZHVCm2NmjhGzB/Znq4A80kicA7IPeFYh1EHWnHNYLKJz/ix0rRy1FOu8PsIzKwBjbM74IiBzkagCEj2d69E92h1FEffjpnM1ecIKarqPHvlSjqba0rNKpJjWGFDExGjuguX5C1udGSx/j2pkSwcWPyxpGCR0LD1WiN+e/c+w3QzU+LcIvkuZ7DhIO8hh3AKn5YlM9UJZcDQ7IirFqiZP1dru0An0bcd0TKtxqlaeaknWmLhLM9ppVfVcHOTbbvMP8+tTPhdev2Y1HRelqEdxtiNWSvNb8kcOuqCmUM0d11eKdFc6hakcTRZ1zS4unU5otbBVYMbyLe08XTSr5Omu9lqzo292+lFuaHZX6HRhCWtJlAktJ07L/LztcGVnP00FrMl+rYp8mMik84Nd6aNS90SlE6ZlSIcueGaqukJnJF+e47Dz56Kjxde6XmVCdWM/DZJR5oNdO1JnRaBljXnKk1uvPKeni89O6waGCGWW8lVOdEIj47s+kpXOKocdlNmd4mnnQu0+OT7qrMI99Ox48lUFPa2dKmgavPPqUx+mOJ46g6/1SOSD2eu21O1qUqVGbVlXqNOjavXwZRgUeu4+WDhlhfX3xXWuXDxasYgRdSP79pHlDmsquVMvsDEr5IWKo8xnAqw67SC20QTjUM37N/LCiMAIXXZIL1EUs7tn7lZWyig5iOl6Ifqg5Eqpku7iBoYy+6aEJp9R9cDXbmJTvxpXpR46nczVIvsxq6xvP295+3ZVPLQlhlJ/taZ6xfotr6KcPBOM8+nAXn1OE+HMcK2KpudqPQ8LMdLrdc4nz0nDxJfXvKomR9e8/r/B69p0TaF1EjCzJiNUK5kpEpmw63HE+aES3xLnDfn/A0H9t+21fyGoswYNSIxpUE/rHcp/+bMKtEa+D+2ESk5x8ErCde31lvypz2u+rsxYYdU1q6d3aKqwGM8rDZYn98M8fFnOBXHu6VJZq+jUF2ahVs/qSS1/RPb1PJ7KU1ti0l3Pa1xanXDIGDK7BJ8Wzmk0azZU6GRlbqdvA67GV7eu59da6ro/O63AJSqtZFLmd6qwf5NOaz+3YoWlrnBHp1LMM2CZorksQtwZwGRFtrpMVgxd8X+hiq58/VxGvEhldzxq54qa9IPUVy4dHYidOicLfxA/UyB1m9G+daAOw5mUnj080zWLLsSyTRHRocnxQss5Qu19iF7zgzXcD+C1r0dG0nB8pPPooQaxTvt26443JHnA0gouRzMWg89iIq8ywXbCaqrbOzrPsZytT5iVGKh2xLLWEXr1uCSYiSLhHKZM5yLJ7XSyOFmV7uiyooef27OYakSZeH4jFY8f+D7p5guwXbnzQ0vSVrd/dja71DupxV0nMlLrSC4WbzwP9Oh2eao9IaYT27RKXC+BhMPsPFobYfmjtKrQ2Z/1x7bgIdOPgO6bWHrO+hMW5jt2znoWyJbPcvdPmJqFxKs+LWIXsl85DDrzHmGfbvDxWSd5qfE5o+eqsMLfCltbOyOdJXpzQNYrKTzyDCqZWA/Q5T3znjsdJwsN7blgeq6MWte6GyTJAxhGu49OeJjMeYhQvmU7WBOrkzrMDm79p7bBdyb/xDaYv2wDpshXs/mEdRa5u9yI1i4HnbwdtwXpiOyDSSmNgzivrS8KTkdV5+I8V7neuVFFT1zQSLFCV7VyfFeHUMbKjjp1xYEqp3UrR40D7qHPLn58ZT3mmviZNOJoxX6JNoA3EjPnl0W8VfdEL9N9oifoPZd5pdcdBUc5jzCohMO1/lKBopVByfa/1EwjP1txVhd/esg20pXLobPCRdYJG6JOmJy8MrWXlfeL8WjqW5W56yWLUe8ynHoCltde1uq3ux1qPRb6f+hjvzIU1urG6AyWNOKMeZ23liWrOOuNAMYjVWo/SzYSE99VEfsEyfd+Jd/ZLNLiK6VaULxt63Jq3tIUUWA6VBvxmcRmm6xm5eysvvmyPutosYB5nYVPfWWBhbLTX4TpuZ6rahfKIj7f56q7aKqG05Wkim7t0E5GS/3poAW7fcKtkHW3qZYTDmXhGCvXLpBYU0prsk+cw0Yb5PskTVqRNcczEiszK3LKvI2uWb8FxH4b/zaaJnry8B99UsD04Oo8NLCqXwJvnWb8o2ZzWAtXQS9w0toZJ7irCfsN55cg+kfLwXDMmLE48j9YDp7z6KpAPc8vWGlwBYKVIVhcZVFXUkL8MkZeqX4jAwp1TgI9fyHsVV7ZCMLKJWxd7YT/M8T/A+HbOg8dnXlgwsPMvLU+u2l8vNSQ7xN98LGZdTHY5CUTh7ZH2oSELDaj4udS6dzjq2/ppBtzdGJnG1qkZnzVPpakCCp9UVGnh8w9+H8YLv8MpOxw8RsuYvx3IIU33ODM7AjZLTIRj1EoONZdMqHlNp2VHLSiVKdBJwq2LehRlb70Wa0FhIFzfd866uam9gwZ14tD62zcbIHoFgJz+zQL7wR8PGIWIw6TWtM0jnXrPmTYzyxvOf6xoUPZyTOSvFmIvzMG52/yfn9jDOp/yPstwGEXk/53+Pa3E+J47GH9jech6byB8P+ZEP6XCbEBrvdOVJp92K6fVfL2wVy0uAa341KOadfPq7LJhKr7NiVpfhUvcDEZtWlxVzoETQbNek53y1LpEJN9IvXsys/h8+SA+J42mTCz3HDFiLuDmpc7M9KGIH23KjV5/qOnWycvY/sfmnf/yQzTXxlfjeTY4m0GmBiGrH1K2eyzY/Ey0kV9yVrtpujTLLrrfEebor5gpb9gZc/GkXoUJmYcvXCgVtRi1geCHadtbMh52bAvX2TTtftWf91FsSrHpqyvDVk2vsZJMxv7dFNO1ozmKMv4KqEdGCAn80boGi0ncwvnuh7gytwnhn2Opstm7iPq+wppLp1esDdaMHWVSQ/+x6P3v/PDf2f0/r+4kr9Gv6YsvqGzj73+6BqUqx1K/aTd4jVkn5K5K3gVybmzynR4uLLez+70j0a7ijLZEeDsNhzf7dcHwCRfevoBJMkr70J7VoqOMFaZNVRWydQfdadeLrSjQXMhO5+fa952cp7c2tY9KOdBWEvzY5umJ5IUtUcmTntQnxi/guPMD4EOZTOZL/8E1bRdqLt81Z3WmktlwqBeDr/zM6uMb1/N3dUN6w39QD2/8+ewNjwNnFTL1TaXyIaPfdmg+ZCufKzSuSuxlxkZhZS5VZh7ursKjXAkRVt+G/ibu2yvUqbestdl7oZf682FcqK0z/J3v04DvuiqcGYvVVgtkuauQRKrc1+uzeswa1nHnWUxzjTrBQ5ZmztIfuXTuhyosqztSyK8INhb2zM9fdWyt1AsK2xO5A9M7+banuUNrmaFi6servCmatxmLcx9/+EZGsb7QrtC/+Z1dKfncurPKDbxtdXQ002uul6UyLPhN1SCnc2raDR3avotVufr4cq0ozC/NQPFuZtgXovp2cN/LgvIHCGGnT3S5mfXh9Bo5PFZ/IdXBuRdPxCHY0v2LqqjYeO56z7LDz4uKPTMZn6QMnEXILQnCnlMAHUT/tXm3ojSWU13T86bBDroktbDH/PdjlbDENv6QXAWY2aEwkkzbZG7A6aqmPRQHdrN7PNeyNnp9ycSqX3I5wMJS5sOr1/846m5j1xKutLjzm+gstswK55H+3JEJ0G8q1R1IxHQsApNDh27pRPpFeutRfa+yhMst4ex3Nsd9uPOTPZ2FNoc9zzkY9OU8jiGZjOHoqenNFqTd82AwGHDvu65GoLd9il68buGBekibM39TuBlRF/zqNKUjaOCNmrsqw3sQ96lqZy+tFhXBjzNOhPVRgS80DNvaedp9wpTm5ZhzbEsRvczZew4G4ZdmCJdqemmT5yK6vF47bqrzm/olv9r1ufD8crBK1Ku3KeUkxESxe38uQ9QdDdu2ZPz4tsmXYu8866rnRfD4l8HTbrpccKm4KdrI7/LVcM4HGVjjujf06Q5e60Qc9D4TQDr39Sy/hvm+RRWxJmHp81fSs6KpgNmwmNkC2u/Y98ImvZKhJ3SqXXRlzehq95PxfjueATrRzGTsVAz4+tai+BTabD1QgkpY69X/wxOhxO9KykKPbfro06+oVW5frmRPMVn11Mcyx3q3ijvx3PrTkTHum4Ydc0ipZnVZDekeHGH+SOtrZWy9ANtkMPvXtJLeGpFtMqh+3WuVgnsG+G23yvX+J+xvaWoswvDMoPm3ciwxdHB53BmS+dbISvOnJVNFmPKjnxrMgrvHhd0qHJ+uBPfvbElEa3084I2ed/7vmmoeWCyLkr2Ok4eKe6GTO/eS6xTc5PxZYJu3IO6CDHaRCbgeuVlZaVSdVZxuHvn48PAr3qHW1+6+Yr44mqUtlPszKxBr7VlhXgpVd8lFmWVRsXvh1ZsUDlF5GX8PgS6cbGKCefXF9+803f8SyWzeRZ1mNa7za4gbodrYslKPXl/tHtlDMynz+kj2n97J09eaXUiSLMtvXkJaaPKnVLHtLNXEBRNYi30GoWuGcHpXNs5dJu/wg6n8ypnLWXvTdOza3tmc8ov4tuONJZkgqZf6MK0Q+1rglOmeYBA9to1u+kdaflQnYyky9krZo+DrEm1958TNr0IFScGTxV8v9TgshKmXNXtahbNvNzWovnpQr2hnT7gJ1Suqmbk7pN5XZLK8KL8epqF74R3qcvuidDegdxbgXvPokFEk8cUSF7AYPNheyGG2ZqYOzF9tsleQ9nrRSJPPpOUlkOO4jAWpI3bg3S196c6lZq6rufuLYhnia/Jc3PYO4yZWzfuM2fEO5U+knO+MLk252/pMpFw59McVCTTHmXuPibfXbUiVpCql+2TvXJVGOULa3K+fAMraX6qSzdvw/FGcjaEVaJZGBktDF189N2+sNRzV+CZTvTdl37T0xuhWqZpFiytSWE+T4bvJmxd3+WZok/t4r8AFfaHiXZkyrwAAAAASUVORK5CYII=", "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=", "page-icon-hover.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMC0wMS0xMlQxODo1NDowNi0wNjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjAtMDEtMTJUMTg6NTQ6MDYtMDY6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6MjE2NDcxMGYtOTM5My1hZjRmLTg3ZWUtY2NmY2YxNGIzMjYzIiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDplMDMzNGE3MS0yZDYyLWU0NGMtYWIyNS1kYmNlM2VhNzA1NjAiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjYxNjM4NWIxLTA4YjUtZDI0YS1iNDMyLTcwM2IwZjQxYzYxOSIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YTk5ZGEzMWUtNTM5Zi03OTQ3LWI5MzMtNzRiYzliZDMyYjRmIiBzdEV2dDp3aGVuPSIyMDIwLTAxLTEyVDE4OjU0OjA2LTA2OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+iRnHuAAAAEFJREFUOI1j/P//PwMlgIki3QwMDCxIbEJOYaSJC0g14D8Dmkvp7oLhaAByOsAaz6QYAAMkpW2qegEGSPLKwMcCANSMByRCC4aMAAAAAElFTkSuQmCC", "page-icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFwmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjE2Mzg1YjEtMDhiNS1kMjRhLWI0MzItNzAzYjBmNDFjNjE5IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6YmNlMzU0ZTgtMDE3Zi1iMjRkLTg4MTYtOGZkZTZlYTgyZDg5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTMwVDAyOjAxOjE3LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2MTYzODViMS0wOGI1LWQyNGEtYjQzMi03MDNiMGY0MWM2MTkiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+1Ie7qQAAADpJREFUOI1j/P//PwMlgIki3dQwgAWNT8g/jNRwAYolAx8GowZgpgOMeCbVAAYGwomJoAEkuWLgAxEAc7EGJRNwU4UAAAAASUVORK5CYII=", "play-button.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjNDQ0IiBzdHJva2U9IiNlZWUiPg0KICAgIDxwYXRoIGQ9Ik0yNiAxNiBMIDggNCBMIDggMjggTCAyNiAxNiIvPg0KPC9zdmc+DQoNCg==", "regular_pixiv_icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ZDA2Zjg3ZjUtMzY1MC1lMzRmLWFjY2UtZTFjYzY4OGVkMjY4IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ZGU4MTZkODItNmVkNi1mNTQxLTg3MTEtMGZlZTZkN2EwMWI5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6Y2YxNWJkY2QtOGJhZi0wMjQwLWI4NTktM2VjMGI0MDJlZjE1Ij4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjZjE1YmRjZC04YmFmLTAyNDAtYjg1OS0zZWMwYjQwMmVmMTUiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDA6NTY6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmQwNmY4N2Y1LTM2NTAtZTM0Zi1hY2NlLWUxY2M2ODhlZDI2OCIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0yMFQwMTowMDowMS0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7IMKLyAAAFYUlEQVR4nO3aWcgcRRAH8N9Oji/xTDzxQBMhAfXFCxVBxTx4IQQvRAQ1ICiKihJRUFBBQfFNUIyIgiLexgMUEVTwBI34oohK4n0gajzivbs+1Iy72ezMN7uzm/3G+IdhZ6d7aqqqq6uruqth5cN6sBVuwHnYtrexpvgNT+FifNvdkPR0PB8/4jL/HeFhPs7AN7gNjayhWwFP4A7M3pycbWY0cCHel8qeKeB8LJ8QU5PAUjxJKGBr3D5RdiaDk7AowZ029QVbCh5McMqkuZggDkowb9JcTBBztlTT/xdbvALGuea3uu6nU/QgfUeKUSogEyIRoec6vIxn8QG+x69oop32m4ftsATH4wQsxlRKb+zKaFj5cLvC+xmTTbyDG/ESfqjEE7vhElyA7Y1RGcMSzUb7IywTI3YIVqsmPGEdX+IqLBBR25qe744Mg06BlmDwLlwpEqci2nvjKJwoBFmAufgTP4mY/Gk8JxKVfvgQh2JXvIJ9jNAayiqgmf5eh5vxV06/+Tgd12MvwWieg2thPxGIZX7jIZGJru9D+xvhK07GYzp+pBLKeuf7hHA36C/87nhVOLl7dITPvpHY9Fu9z+fjbDGFnk//98Nq7ICfjWBKFCmghbXpx1boL/hWQvAvcHgXzWFHJnvvGPyS/vbD+pSvtTrWWemD3cjm+WnC5Iqc2tc4rIDWsJiV0nsBN+X0aQn+PlHBEnp9QFOM5v5iBIrQEOv4rGE/XhJXCMd5eU77EmERQ+1g9Y7aKuG5pxM+w8iXpT5IcKlwfnk8LK5CPMMyXNSn/YRhiY8QCR7Hwpz274SSBh6QRKzJi/FiT9tcEb7ePSjRMaElos083IoNgxLNtsQ+7nm+jZhXUyJgmQlIsCcOKOiz3IBWkODvnmdTYu98SljBTMPqgrYX8fsgxHqdYAOfCcFn4l5BgkUiQcrDKgNYQa+QD4gAYyYKn6GJcwrabzEA/90ddxenJ+Ne16uiIc4x8vAV/ihLLBHhbHZfBySmX/fXKTkNEpHTE5qrC+YpdtBvlyWUYBeR5q6oxtNmRUNx6PtWWUKzxZy/tipHE0CRBXxZlkhd5n0/NAraSqfIdVZAkadfUJZIXRXQFjtCeTiwLKG6KuBXkcTl4YiyhOqogBbem6bPUiVlq6MCiAOYPOykE9xNizoqoIFnCtpXGmAVqFtBVFOUu+WdSxClcKXzmbpZwCycVdB+kgELPuqkgKbYs/wtp72BRwwoU10U0MS7iqvZ7jbEDlYdfEBTbM4WBTcH4NxhiM90C2iKxGY3+fn9TgZIf3tRVQFzjPdw5Clx0Jq3rC0U+xhDF3lUUUAbV+soYBSKaKXXjzhYcQ3jErF7PVsFOapawE3C8RyJ13Ti81bXNR26+32OY0U2V2TWl4i6o8r7l6Nwgm1RuXGkWIp2EUflp4pR3FWEpnPS9kb6zl86VSKrRKVIUYZH+II3xAHJSFBVAVM2zsvbopLjyfTqRkNYXEN568iwEPeLc8qRFkxVIdQQh5KfisqOosMKQjlNcRJVRviGKLx6SyyDx6XPR7pyVSU2V5wn3CPOEteLRGW5MNdB5miCHdN3XxVHXG/qrP9jWbKrToGWmNsZthcjdZxOgdQG4a0/FxbzvZj7C8Uavif2SP/PtnHBZffvWDCOSDDpud82vRaVfK+2pbLTYUZGnTOSqc2J/xWgYp1dzdFM8PqkuZgg1iWiJmBLxYpE5NsPTZqTCWAtXsmc4JmiNqgXbflFy2wcuNQJG7AvnVWgLQKV3lrBBPfmEGmLkLVuK8lHIuT+k03r95eJGv41eFRsMV9TQOxo7Gzm1BLmoSmO044WGyn/ZrD/AKixCmbSPqIVAAAAAElFTkSuQmCC" }; // Global actions. class actions { // Set a bookmark. Any existing bookmark will be overwritten. static async _bookmark_add_internal(illust_info, options) { 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 && settings.get("auto-like")) { console.log("Automatically liking image as well as bookmarking it due to auto-like preference"); actions.like_image(illust_info, true /* quiet */); } // Remember whether this is a new bookmark or an edit. var was_bookmarked = illust_info.bookmarkData != null; var illust_id = illust_info.illustId; var request = { "illust_id": illust_id, "tags": options.tags || [], "comment": options.comment || "", "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"; // last_bookmark_id seems to be the ID of the new bookmark. We need to store this correctly // so the unbookmark button works. // // Update bookmark info in image data. // // Even if we weren't given tags or a comment, we still know that they're unset, // so set comment and tags so we won't need to request bookmark details later. illust_info.bookmarkData = { id: new_bookmark_id, private: !!request.restrict, comment: request.comment, tags: request.tags, } console.log("Updated bookmark data:", illust_info.bookmarkData); if(!was_bookmarked) illust_info.bookmarkCount++; // If this image's thumbnail info is loaded, update that too. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) { thumbnail_info.bookmarkData = { id: result.body.last_bookmark_id, private: !!request.restrict, } } message_widget.singleton.show( was_bookmarked? "Bookmark edited": options.private? "Bookmarked privately":"Bookmarked"); image_data.singleton().call_illust_modified_callbacks(illust_id); } static bookmark_edit(illust_info, options) { return actions.bookmark_add(illust_info, options); } // Create or edit a bookmark. // // Create or edit a bookmark. options can contain any of the fields tags, comment // 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 and comment if we need them. We // try to avoid making the extra bookmark details request if possible. static async bookmark_add(illust_info, options) { if(options == null) options = {}; console.log("Edit bookmark options:", options); // This is a mess, since Pixiv's APIs are all over the place. // // If the image isn't bookmarked, just use bookmark_add. if(illust_info.bookmarkData == null) { console.log("Initial bookmark"); if(options.tags != null) helpers.update_recent_bookmark_tags(options.tags); return await actions._bookmark_add_internal(illust_info, 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.comment == 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.comment == null && options.private != null) { // If the image is already bookmarked, use bookmark_set_private to edit the // existing bookmark. This won't auto-like. console.log("Only editing private field", options.private); return await actions.bookmark_set_private(illust_info, options.private); } // If we're modifying tags or comments, we need bookmark details loaded. // This will insert the info into illust_info.bookmarkData. We could skip // this if we're setting both tags and comments, but we don't currently do // that. await image_data.singleton().load_bookmark_details(illust_info); var bookmark_params = { // Don't auto-like if we're editing an existing bookmark. disable_auto_like: true, }; // Copy any of these keys that are in options to our bookmark_add arguments. // Copy any fields that aren't being set from the current value. for(var key of ["private", "comment", "tags"]) { var value = options[key]; if(value == null) value = illust_info.bookmarkData[key]; bookmark_params[key] = value; } // 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 = illust_info.bookmarkData.tags.indexOf(tag) == -1; console.log("new tag:", is_new_tag, tag); if(is_new_tag) helpers.update_recent_bookmark_tags([tag]); } } return await actions._bookmark_add_internal(illust_info, bookmark_params); } static async bookmark_remove(illust_info) { if(illust_info.bookmarkData == null) { console.log("Not bookmarked"); return; } var illust_id = illust_info.illustId; var bookmark_id = illust_info.bookmarkData.id; console.log("Remove bookmark", bookmark_id, illust_info); var result = await helpers.rpc_post_request("/rpc/index.php", { mode: "delete_illust_bookmark", bookmark_id: bookmark_id, }); console.log("Removing bookmark finished"); illust_info.bookmarkData = null; illust_info.bookmarkCount--; var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) thumbnail_info.bookmarkData = null; message_widget.singleton.show("Bookmark removed"); image_data.singleton().call_illust_modified_callbacks(illust_id); } // Change an existing bookmark to public or private. static async bookmark_set_private(illust_info, private_bookmark) { var illust_id = illust_info.illustId; var bookmark_id = illust_info.bookmarkData.id; // We're mimicing a form submission here, since there doesn't seem to be any // API call for it. var params = new URLSearchParams(); params.set("book_id[]", bookmark_id); params.set("type", ""); params.set("untagged", 0); // "rest" is actually the bookmark page the user is viewing, not the new state. // We just mimic the value in the form (it probably only affects the redirect that // we don't use). params.set("rest", private_bookmark? "show":"hide"); if(private_bookmark) params.set("hide", "Private"); else params.set("show", "Public"); params.set("tag", ""); params.set("p", "1"); params.set("order", ""); params.set("add_tag", ""); params.toString(); // This returns an HTML page that we don't care about. var result = await helpers.post_form_request("/bookmark_setting.php", params); // If this image's info is loaded, update its bookmark info. Leave fields other // than private_bookmark alone. if(illust_info.bookmarkData != null) illust_info.bookmarkData.private = private_bookmark; // If this image's thumbnail info is loaded, update that too. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) thumbnail_info.bookmarkData.private = private_bookmark; message_widget.singleton.show(private_bookmark? "Bookmarked privately":"Bookmarked"); image_data.singleton().call_illust_modified_callbacks(illust_id); } // Show a prompt to enter tags, so the user can add tags that aren't already in the // list. Add the bookmarks to recents, and bookmark the image with the entered tags. static async add_new_tag(illust_id) { let illust_data = await image_data.singleton().get_image_info(illust_id); console.log("Show tag prompt"); // Hide the popup when we show the prompt. this.hide_temporarily = true; var prompt = new text_prompt(); try { var tags = await prompt.result; } catch(e) { // The user cancelled the prompt. return; } // Split the new tags. tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); console.log("New tags:", tags); // This should already be loaded, since the only way to open this prompt is // in the tag dropdown. await image_data.singleton().load_bookmark_details(illust_data); // Add each tag the user entered to the tag list to update it. let active_tags = illust_data.bookmarkData? Array.from(illust_data.bookmarkData.tags):[]; for(let tag of tags) { if(active_tags.indexOf(tag) != -1) continue; // Add this tag to recents. bookmark_edit will add recents too, but this makes sure // that we add all explicitly entered tags to recents, since bookmark_edit will only // add tags that are new to the image. helpers.update_recent_bookmark_tags([tag]); active_tags.push(tag); } console.log("All tags:", active_tags); // Edit the bookmark. await actions.bookmark_edit(illust_data, { tags: active_tags, }); } // If quiet is true, don't print any messages. static async like_image(illust_data, quiet) { var illust_id = illust_data.illustId; console.log("Clicked like on", illust_id); if(illust_data.likeData) { if(!quiet) message_widget.singleton.show("Already liked this image"); return; } var result = await helpers.post_request("/ajax/illusts/like", { "illust_id": illust_id, }); // Update the image data. illust_data.likeData = true; illust_data.likeCount++; image_data.singleton().call_illust_modified_callbacks(illust_id); if(!quiet) message_widget.singleton.show("Illustration liked"); } static async follow(user_data, follow_privately, tags) { var result = await helpers.rpc_post_request("/bookmark_add.php", { mode: "add", type: "user", user_id: user_data.userId, tag: tags, restrict: follow_privately? 1:0, format: "json", }); // This doesn't return any data. Record that we're following and refresh the UI. user_data.isFollowed = true; image_data.singleton().call_user_modified_callbacks(user_data.userId); var message = "Followed " + user_data.name; if(follow_privately) message += " privately"; message_widget.singleton.show(message); } static async unfollow(user_data) { var result = await helpers.rpc_post_request("/rpc_group_setting.php", { mode: "del", type: "bookuser", id: user_data.userId, }); // Record that we're no longer following and refresh the UI. user_data.isFollowed = false; image_data.singleton().call_user_modified_callbacks(user_data.userId); message_widget.singleton.show("Unfollowed " + user_data.name); } // Image downloading // // Download illust_data. static download_illust(illust_data, progress_bar_controller) { var download_type = actions.get_download_type_for_image(illust_data); if(download_type == null) { console.error("No download types are available"); return; } console.log("Download", illust_data.illustId, "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; } // Download all images. // // Pixiv's host for images changed from i.pximg.net to i-cf.pximg.net. This will fail currently for that // host, since it's not in @connect, and adding that will prompt everyone for permission. Work around that // by replacing i-cf.pixiv.net with i.pixiv.net, since that host still works fine. This only affects downloads. var images = []; for(var page of illust_data.mangaPages) { let url = page.urls.original; url = url.replace(/:\/\/i-cf.pximg.net/, "://i.pximg.net"); images.push(url); } var user_data = illust_data.userInfo; helpers.download_urls(images, function(results) { // If there's just one image, save it directly. if(images.length == 1) { var url = images[0]; var buf = results[0]; var blob = new Blob([results[0]]); var ext = helpers.get_extension(url); var filename = user_data.name + " - " + illust_data.illustId + " - " + 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. console.log(results); 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_data.name + " - " + illust_data.illustId + " - " + illust_data.illustTitle + ".zip"; helpers.save_blob(zip, filename); }); } static is_download_type_available(download_type, illust_data) { // Single image downloading only works for single images. if(download_type == "image") return illust_data.illustType != 2 && illust_data.pageCount == 1; // ZIP downloading only makes sense for image sequences. if(download_type == "ZIP") return illust_data.illustType != 2 && illust_data.pageCount > 1; // MJPEG only makes sense for videos. if(download_type == "MKV") { if(illust_data.illustType != 2) return false; // All of these seem to be JPEGs, but if any are PNG, disable MJPEG exporting. // We could encode to JPEG, but if there are PNGs we should probably add support // for APNG. if(illust_data.ugoiraMetadata.mime_type != "image/jpeg") return false; return true; } throw "Unknown download type " + download_type; }; static get_download_type_for_image(illust_data) { var download_types = ["image", "ZIP", "MKV"]; for(var type of download_types) if(actions.is_download_type_available(type, illust_data)) return type; return null; } static async load_recent_bookmark_tags() { let url = "https://www.pixiv.net/ajax/user/" + window.global_data.user_id + "/illusts/bookmark/tags"; let result = await helpers.get_request(url, {}); let bookmark_tags = []; console.log(result); for(let tag of result.body.public) if(bookmark_tags.indexOf(tag.tag) == -1) bookmark_tags.push(tag.tag); for(let tag of result.body.private) if(bookmark_tags.indexOf(tag.tag) == -1) bookmark_tags.push(tag.tag); return bookmark_tags; } } // This handles querying whether a tag or a user is muted. We don't handle // editing this list currently. class muting { static get singleton() { if(muting._singleton == null) muting._singleton = new muting(); return muting._singleton; }; constructor() { } set_muted_tags(muted_tags) { this.muted_tags = muted_tags; } set_muted_user_ids(muted_user_ids) { this.muted_user_ids = muted_user_ids; } is_muted_user_id(user_id) { return this.muted_user_ids.indexOf(user_id) != -1; }; // Return true if any tag in tag_list is muted. any_tag_muted(tag_list) { for(var tag of tag_list) { if(tag.tag) tag = tag.tag; if(this.muted_tags.indexOf(tag) != -1) return tag; } return null; } } /* pako/lib/zlib/crc32.js, MIT license: https://github.com/nodeca/pako/ */ var 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; }; })(); // Get and set values in localStorage. // // We don't use GM_setValue/GM_getValue since GreaseMonkey is inconsistent and changed // these functions unnecessarily. We could polyfill those with this, but that would cause // the storage to change if those functions are restored. Doing it this way also allows // us to share settings if a user switches from GM to TM. class settings { static session_settings = { }; static get_change_callback_list(key) { if(settings._callbacks == null) settings._callbacks = {}; var callbacks = settings._callbacks[key]; if(callbacks == null) callbacks = settings._callbacks[key] = new callback_list(); return callbacks; } static _get_from_storage(key, default_value) { key = "_ppixiv_" + key; if(!(key in localStorage)) return default_value; let result = localStorage[key]; try { return JSON.parse(result); } catch(e) { // Recover from invalid values in localStorage. console.warn(e); console.log("Removing invalid setting:", result); delete localStorage.storage_key; return default_value; } } static get(key, default_value) { // If this is a session setting and we've already read it, use our loaded value. if(settings.session_settings[key]) return settings.session_settings[key]; let result = settings._get_from_storage(key, default_value); // If this is a session setting, remember it for reuse. This will store the default value // if there's no stored setting. if(settings.session_settings[key] !== undefined) settings.session_settings[key] = result; return result; } static set(key, value) { // 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 session setting, replace its value. if(settings.session_settings[key] !== undefined) settings.session_settings[key] = value; var setting_key = "_ppixiv_" + key; var value = JSON.stringify(value); localStorage[setting_key] = value; // Call change listeners for this key. settings.get_change_callback_list(key).call(key); } // Mark a setting as per-session. These 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. static set_per_session(key) { // Create the key if it doesn't exist. if(settings.session_settings[key] === undefined) settings.session_settings[key] = null; } static register_change_callback(key, callback) { settings.get_change_callback_list(key).register(callback); } static unregister_change_callback(key, callback) { settings.get_change_callback_list(key).unregister(callback); } } // This is thrown when an XHR request fails. class APIError extends Error { constructor(message, url) { super(message); this.url = url; } }; // This is thrown when an XHR request fails with a Pixiv error message. class PixivError extends APIError { }; // This is thrown when we disable creating blocked elements. class ElementDisabled extends Error { }; var helpers = { 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) { for(var child = parent.firstChild; child; ) { var next = child.nextElementSibling; parent.removeChild(child); child = next; } }, // 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.createElement("style", {pp: true}); style.type = "text/css"; style.textContent = css; return style; }, create_from_template: function(type) { var template; if(typeof(type) == "string") { template = document.body.querySelector(type); if(template == null) throw "Missing 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; }, // SVG has a big problem: it uses IDs to reference its internal assets, and that // breaks if you inline the same SVG more than once in a while. Making them unique // at build time doesn't help, since they break again as soon as you clone a template. // This makes styling SVGs a nightmare, since you can only style inlined SVGs. // // doesn't help, since that's just broken with masks and gradients entirely. // Broken for over a decade and nobody cares: https://bugzilla.mozilla.org/show_bug.cgi?id=353575 // // This seems like a basic feature of SVG, and it's just broken. // // Work around it by making IDs within SVGs unique at runtime. This is called whenever // we clone SVGs. _svg_id_sequence: 0, make_svg_ids_unique(svg) { let id_map = {}; let idx = helpers._svg_id_sequence; // First, find all IDs in the SVG and change them to something unique. for(let def of svg.querySelectorAll("[id]")) { let old_id = def.id; let new_id = def.id + "_" + idx; idx++; id_map[old_id] = new_id; def.id = new_id; } // Search for all URL references within the SVG and point them at the new IDs. for(let node of svg.querySelectorAll("*")) { for(let attr of node.getAttributeNames()) { let value = node.getAttribute(attr); // See if this is an ID reference. We don't try to parse all valid URLs // here. var re = /url\(#.*?\)/; var new_value = value.replace(re, (str) => { var re = /url\(#(.*)\)/; var old_id = str.match(re)[1]; let new_id = id_map[old_id]; if(new_id == null) { console.warn("Unmatched SVG ID:", old_id); return str; } // Replace the ID. return "url(#" + new_id + ")"; }); node.setAttribute(attr, new_value); } } // Store the index, so the next call will start with the next value. helpers._svg_id_sequence = idx; }, // Fetch a simple data resource, and call callback with the result. // // In principle this is just a simple XHR. However, if we make two requests for the same // resource before the first one finishes, browsers tend to be a little dumb and make a // whole separate request, instead of waiting for the first to finish and then just serving // the second out of cache. This causes duplicate requests when prefetching video ZIPs. // This works around that problem by returning the existing XHR if one is already in progress. _fetches: {}, async fetch_resource(url, options) { if(options == null) options = {}; // If there's an abort signal and it's already signalled, do nothing. if(options.signal && options.signal.aborted) throw "Aborted by signal"; // If there's no ongoing fetch for this URL, create one. Otherwise, we'll just wait // on the existing request. if(this._fetches[url] == null) { // options.signal may be an abort signal, but it only aborts this instance of the // request. abort_actual_request is our internal signal to abort the actual request, // which we only do if every fetch for this request is aborted. var abort_actual_request = new AbortController(); var request = helpers.send_pixiv_request({ "method": "GET", "url": url, "responseType": "arraybuffer", "headers": { "Accept": "application/json", }, signal: abort_actual_request.signal, onprogress: function(e) { for(var options of request.callers.slice()) { try { if(options.onprogress) options.onprogress(e); } catch(exc) { console.error(exc); } } }, }); request.abort_actual_request = abort_actual_request; this._fetches[url] = request; // Remember the number of times fetch_resource has been called on this URL. request.fetch_count = 0; request.callers = []; request.callers.push(options); } else { var request = this._fetches[url]; } // Remember that another fetch was made for this resource. request.fetch_count++; // Override request.abort to reference count fetching, so we only cancel the load if // every caller cancels. request.callers.push(options); if(options.signal) { options.signal.addEventListener("abort", (e) => { // Remove this caller's callbacks, if any. if(options != null) { var idx = request.callers.indexOf(options); if(idx != -1) request.callers.splice(idx, 1); } if(request.fetch_count == 0) { console.error("Fetch was aborted more times than it was started:", url); return; } request.fetch_count--; if(request.fetch_count > 0) return; delete this._fetches[url]; // Abort the underlying request. abort_actual_request.abort(); }); } try { return await request; } finally { delete helpers._fetches[url]; } }, // Prompt to save a blob to disk. For some reason, the really basic FileSaver API disappeared from // the web. save_blob: function(blob, filename) { var blobUrl = URL.createObjectURL(blob); var a = document.createElement("a"); a.hidden = true; document.body.appendChild(a); a.href = blobUrl; a.download = filename; a.click(); // Clean up. // // If we revoke the URL now, or with a small timeout, Firefox sometimes just doesn't show // the save dialog, and there's no way to know when we can, so just use a large timeout. setTimeout(function() { window.URL.revokeObjectURL(blobUrl); a.parentNode.removeChild(a); }.bind(this), 1000); }, // Work around IntersectionObserver bugs. intersection_observer: function(callback, options) { // Chrome only supports the "threshold" option and not "thresholds". // // Firefox's thresholds don't work at all (it'll give partially-visible items even with // threshold 1). However, we still need to give a threshold, and call it "thresholds". // If we don't give "thresholds", we'll never receive removal callbacks. If we give // "threshold" at all, we also won't receive removal callbacks. // // Yeah. Awesome. let firefox = navigator.userAgent.indexOf("Gecko/"); if(firefox) { let new_options = {}; Object.assign(new_options, options); options = new_options; if(options.threshold != null) { options.thresholds = [options.threshold]; delete options.threshold; } } return new IntersectionObserver(callback, options); }, // 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; }, // Stop the underlying page from sending XHR requests, since we're not going to display any // of it and it's just unneeded traffic. For some dumb reason, Pixiv sends error reports by // creating an image, instead of using a normal API. Override window.Image too to stop it // from sending error messages for this script. // // Firefox is now also bad and seems to have removed beforescriptexecute. The Web is not // much of a dependable platform. block_network_requests: function() { RealXMLHttpRequest = unsafeWindow.XMLHttpRequest; unsafeWindow.Image = exportFunction(function() { }, unsafeWindow); 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. // We mark any scripts and styles we load with createElement("style", {pp: true}) so we can bypass // this for our own elements. block_elements: function() { let origCreateElement = unsafeWindow.HTMLDocument.prototype.createElement; unsafeWindow.HTMLDocument.prototype.createElement = function(type, options) { // Prevent the underlying site from creating new script and style elements. We override // this ourself using the "pp: true" option. if(type == "script" || type == "style") { if(options == null || !options.pp) { 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.indexOf("Element disabled") == -1) return; e.preventDefault(); e.stopPropagation(); }); }, // Stop all scripts from running on the page. This only works in Firefox. This is a basic // thing for a userscript to want to do, why can't you do it in Chrome? block_all_scripts: function() { window.addEventListener("beforescriptexecute", function(e) { e.stopPropagation(); e.preventDefault(); }, true); }, add_style: function(css) { var head = document.getElementsByTagName('head')[0]; let style = helpers.create_style(css); head.appendChild(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); }, date_to_string: function(date) { var date = new Date(date); var day = date.toLocaleDateString(); var time = date.toLocaleTimeString(); return day + " " + time; }, age_to_string: function(seconds) { var to_plural = function(label, places, value) { var factor = Math.pow(10, places); var plural_value = Math.round(value * factor); if(plural_value > 1) label += "s"; return value.toFixed(places) + " " + label; }; if(seconds < 60) return to_plural("sec", 0, seconds); var minutes = seconds / 60; if(minutes < 60) return to_plural("min", 0, minutes); var hours = minutes / 60; if(hours < 24) return to_plural("hour", 0, hours); var days = hours / 24; if(days < 30) return to_plural("day", 0, days); var months = days / 30; if(months < 12) return to_plural("month", 0, months); var years = months / 12; return to_plural("year", 1, years); }, get_extension: function(fn) { var parts = fn.split("."); return parts[parts.length-1]; }, encode_query: function(data) { var str = []; for (var key in data) { if(!data.hasOwnProperty(key)) continue; str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key])); } return str.join("&"); }, // Sending requests in user scripts is a nightmare: // - In TamperMonkey you can simply use unsafeWindow.XMLHttpRequest. However, in newer versions // of GreaseMonkey, the request will be sent, but event handlers (eg. load) will fail with a // permissions error. (That doesn't make sense, since you can assign DOM events that way.) // - window.XMLHttpRequest will work, but won't make the request as the window, so it will // act like a cross-origin request. We have to use GM_xmlHttpRequest/GM.XMLHttpRequest instead. // - But, we can't use that in TamperMonkey (at least in Chrome), since ArrayBuffer is incredibly // slow. It seems to do its own slow buffer decoding: a 2 MB ArrayBuffer can take over half a // second to decode. We need to use regular XHR with TamperMonkey. // - GM_xmlhttpRequest in GreaseMonkey doesn't send a referer by default, and we need to set it // manually. (TamperMonkey does send a referer by default.) // send_request_gm: Send a request with GM_xmlhttpRequest. // // The returned object will have an abort method that might abort the request. // (TamperMonkey provides abort, but GreaseMonkey doesn't.) // // Only the following options are supported: // // - headers // - method // - data // - responseType // - onload // - onprogress // // The returned object will only have abort, which is a no-op in GM. // // onload will always be called (unless the request is aborted), so there's always just // one place to put cleanup handlers when a request finishes. // // onload will be called with only resp.response and not the full response object. On // error, onload(null) will be called rather than onerror. // // We use a limited interface since we have two implementations of this, one using XHR (for TM) // and one using GM_xmlhttpRequest (for GM), and this prevents us from accidentally // using a field that's only implemented with GM_xmlhttpRequest and breaking TM. send_request_gm: function(options) { if(options == null) options = {}; return new Promise((resolve, reject) => { if(options.signal && options.signal.aborted) { reject("Aborted by signal"); return; } var req_options = {}; for(var key of ["url", "headers", "method", "data", "responseType", "onload", "onprogress"]) { if(!(key in options)) continue; // We'll override onload. if(key == "onload") { req_options.real_onload = options.onload; continue; } req_options[key] = options[key]; } // Set the referer, or some requests will fail. var url = new URL(document.location); url.hash = ""; req_options.headers["Referer"] = url.toString(); req_options.onload = function(response) { resolve(response.response); }; // When is this ever called? req_options.onerror = function(response) { console.log("Request failed:", response); reject(e); } var actual_request = GM_xmlhttpRequest(req_options); if(options.signal) { options.signal.addEventListener("abort", (e) => { console.log("Aborting XHR"); // actual_request is null with newer, broken versions of GM, in which case // we only pretend to cancel the request. if(actual_request != null) actual_request.abort(); // Remove real_onload, so if we can't actually cancel the request, we still // won't call onload, since the caller is no longer expecting it. delete req_options.real_onload; reject("Aborted by signal"); }); } }); }, // The same as send_request_gm, but with XHR. send_request_xhr: function(options) { if(options == null) options = {}; return new Promise((resolve, reject) => { if(options.signal && options.signal.aborted) { reject("Aborted by signal"); return; } let XMLHttpRequest = unsafeWindow.RealXMLHttpRequest || unsafeWindow.XMLHttpRequest; var xhr = new XMLHttpRequest(); if(options.signal) { options.signal.addEventListener("abort", (e) => { console.log("Aborting XHR"); xhr.abort(); reject("Aborted by signal"); }); } xhr.open(options.method || "GET", options.url); if(options.headers) { for(var key in options.headers) xhr.setRequestHeader(key, options.headers[key]); } if(options.responseType) xhr.responseType = options.responseType; xhr.addEventListener("load", (e) => { resolve(xhr.response); }); xhr.addEventListener("error", (e) => { reject(e); }); xhr.addEventListener("progress", function(e) { if(options.onprogress) { try { options.onprogress(e); } catch(exc) { console.error(exc); } } }); if(options.method == "POST") xhr.send(options.data); else xhr.send(); }); }, async send_request(options) { // In GreaseMonkey, use send_request_gm. Otherwise, use send_request_xhr. If // GM_info.scriptHandler doesn't exist, assume we're in GreaseMonkey, since // TamperMonkey always defines it. // // We also assume that if GM_info doesn't exist we're in GreaseMonkey, since it's // GM that has a nasty habit of removing APIs that people are using, so if that // happens we're probably in GM. var greasemonkey = true; try { greasemonkey = GM_info.scriptHandler == null || GM_info.scriptHandler == "Greasemonkey"; } catch(e) { greasemonkey = true; } if(greasemonkey) return await helpers.send_request_gm(options); else return await helpers.send_request_xhr(options); }, // 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, document.location).hostname; if(hostname == "www.pixiv.net" && "global_data" in window) options.headers["x-csrf-token"] = global_data.csrf_token; return await helpers.send_request(options); }, // 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", }, }); console.log(result); if(result && result.error) throw new PixivError(result.message, url); 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", }, }); if(result.error) throw new PixivError(result.message, url); 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", }, }); if(result.error) throw new PixivError(result.message, url); return result; }, async get_request(url, data, options) { var params = new URLSearchParams(); for(var key in data) params.set(key, data[key]); 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" }; if(result.error) throw new PixivError(result.message, url); return result; }, async post_form_request(url, params) { params.set("tt", global_data.csrf_token); var result = await helpers.send_pixiv_request({ "method": "POST", "url": url, "data": params.toString(), "headers": { "Content-Type": "application/x-www-form-urlencoded", }, }); return result; }, // Download all URLs in the list. Call callback with an array containing one ArrayData for each URL. If // any URL fails to download, call callback with null. // // I'm not sure if it's due to a bug in the userscript extension or because we need to specify a // header here, but this doesn't properly use cache and reloads the resources from scratch, which // is really annoying. We can't read the images directly since they're on a different domain. // // We could start multiple requests to pipeline this better. However, the usual case where we'd download // lots of images is downloading a group of images, and in that case we're already preloading them as // images, so it's probably redundant to do it here. download_urls: function(urls, callback) { // Make a copy. urls = urls.slice(0); var results = []; var start_next = function() { var url = urls.shift(); if(url == null) { callback(results); return; } // FIXME: This caches in GreaseMonkey, but not in TamperMonkey. Do we need to specify cache // headers or is TamperMonkey just broken? 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: function(result) { results.push(result.response); start_next(); }.bind(this), }); }; start_next(); }, // 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) { if(GM_info.scriptHandler == "Tampermonkey") { // 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. var result = await helpers.send_pixiv_request({ "method": "GET", "url": url, "responseType": "document", }); return result; } // The above won't work with Greasemonkey. It returns a document we can't access, // raising exceptions if we try to access it. Greasemonkey's sandboxing needs to // be shot into the sun. // // Instead, we load the document in a sandboxed iframe. It'll still load resources // that we don't need (though they'll mostly load from cache), but it won't run // scripts. return new Promise((resolve, reject) => { var iframe = document.createElement("iframe"); // Enable sandboxing, so scripts won't run in the iframe. Set allow-same-origin, or // we won't be able to access it in contentDocument (which doesn't really make sense, // sandbox is for sandboxing the iframe, not us). iframe.sandbox = "allow-same-origin"; iframe.src = url; iframe.hidden = true; document.body.appendChild(iframe); iframe.addEventListener("load", function(e) { try { resolve(iframe.contentDocument); } finally { // Remove the iframe. For some reason, we have to do this after processing it. setTimeout(() => { document.body.removeChild(iframe); }, 0); } }); }); }, set_recent_bookmark_tags(tags) { settings.set("recent-bookmark-tags", JSON.stringify(tags)); }, get_recent_bookmark_tags() { var recent_bookmark_tags = settings.get("recent-bookmark-tags"); if(recent_bookmark_tags == null) return []; return JSON.parse(recent_bookmark_tags); }, // Move tag_list to the beginning of the recent tag list, and prune tags at the end. update_recent_bookmark_tags: function(tag_list) { // Move the tags we're using to the top of the recent bookmark tag list. var recent_bookmark_tags = helpers.get_recent_bookmark_tags(); for(var i = 0; i < tag_list.length; ++i) { var tag = tag_list[i]; var idx = recent_bookmark_tags.indexOf(tag_list[i]); if(idx != -1) recent_bookmark_tags.splice(idx, 1); } for(var i = 0; i < tag_list.length; ++i) recent_bookmark_tags.unshift(tag_list[i]); // Remove tags that haven't been used in a long time. recent_bookmark_tags.splice(20); helpers.set_recent_bookmark_tags(recent_bookmark_tags); }, // Add tag to the recent search list, or move it to the front. add_recent_search_tag(tag) { if(this._disable_adding_search_tags) return; var recent_tags = settings.get("recent-tag-searches") || []; var idx = recent_tags.indexOf(tag); if(idx != -1) recent_tags.splice(idx, 1); recent_tags.unshift(tag); // Trim the list. recent_tags.splice(50); settings.set("recent-tag-searches", recent_tags); window.dispatchEvent(new Event("recent-tag-searches-changed")); }, // This is a hack used by tag_search_box_widget to temporarily disable adding to history. disable_adding_search_tags(value) { this._disable_adding_search_tags = value; }, remove_recent_search_tag(tag) { // Remove tag from the list. There should normally only be one. var recent_tags = settings.get("recent-tag-searches") || []; while(1) { var idx = recent_tags.indexOf(tag); if(idx == -1) break; recent_tags.splice(idx, 1); } settings.set("recent-tag-searches", recent_tags); window.dispatchEvent(new Event("recent-tag-searches-changed")); }, // Split a tag search into individual tags. split_search_tags(search) { // Replace full-width spaces with regular spaces. Pixiv treats this as a delimiter. search = search.replace(" ", " "); return search.split(" "); }, // If a tag has a modifier, return [modifier, tag]. -tag seems to be the only one, so // we return ["-", "tag"]. split_tag_prefixes(tag) { if(tag[0] == "-") return ["-", tag.substr(1)]; else return ["", tag]; }, // If this is an older page (currently everything except illustrations), the CSRF token, // etc. are stored on an object called "pixiv". We aren't actually executing scripts, so // find the script block. get_pixiv_data(doc) { // Find all script elements that set pixiv.xxx. There are two of these, and we need // both of them. var init_elements = []; for(var element of doc.querySelectorAll("script")) { if(element.innerText == null) continue; if(!element.innerText.match(/pixiv.*(token|id) = /)) continue; init_elements.push(element); } if(init_elements.length < 1) return null; // Create a stub around the scripts to let them execute as if they're initializing the // original object. var init_script = ""; init_script += "(function() {"; init_script += "var pixiv = { config: {}, context: {}, user: {} }; "; for(var element of init_elements) init_script += element.innerText; init_script += "return pixiv;"; init_script += "})();"; return eval(init_script); }, get_tags_from_illust_data(illust_data) { // illust_data might contain a list of dictionaries (data.tags.tags[].tag), or // a simple list (data.tags[]), depending on the source. if(illust_data.tags.tags == null) return illust_data.tags; var result = []; for(var tag_data of illust_data.tags.tags) result.push(tag_data.tag); return result; }, // Return true if the given illust_data.tags contains the pixel art (ドット絵) tag. tags_contain_dot(illust_data) { var tags = helpers.get_tags_from_illust_data(illust_data); for(var tag of tags) if(tag.indexOf("ドット") != -1) return true; return false; }, // Find all links to Pixiv pages, and set a #ppixiv anchor. // // This allows links to images in things like image descriptions to be loaded // internally without a page navigation. make_pixiv_links_internal(root) { for(var a of root.querySelectorAll("A")) { var url = new URL(a.href, document.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. 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(url == null) url = new URL(document.location); 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) { document.querySelector("title").textContent = title; }, set_page_icon: function(url) { document.querySelector("link[rel='icon']").href = url; }, // Get the search tags from an "/en/tags/TAG" search URL. _get_search_tags_from_url: function(url) { url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); // ["", "tags", tag string, "search type"] let tags = parts[2] || ""; return decodeURIComponent(tags); }, // Watch for clicks on links inside node. If a search link is clicked, add it to the // recent search list. add_clicks_to_search_history: function(node) { node.addEventListener("click", function(e) { if(e.defaultPrevented) return; if(e.target.tagName != "A") return; // Only look at "/tags/TAG" URLs. var url = new URL(e.target.href); url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); let first_part = parts[1]; if(first_part != "tags") return; let tag = helpers._get_search_tags_from_url(url); console.log("Adding to tag search history:", tag); helpers.add_recent_search_tag(tag); }); }, // Add a basic event handler for an input: // // - When enter is pressed, submit will be called. // - Event propagation will be stopped, so global hotkeys don't trigger. // // Note that other event handlers on the input will still be called. input_handler: function(input, submit) { input.addEventListener("keydown", function(e) { // Always stopPropagation, so inputs aren't handled by main input handling. e.stopPropagation(); if(e.keyCode == 13) // enter submit(e); }); }, // Parse the hash portion of our URL. For example, // // #ppixiv?a=1&b=2 // // returns { a: "1", b: "2" }. // // If this isn't one of our URLs, return null. parse_hash: function(url) { var ppixiv_url = url.hash.startsWith("#ppixiv"); if(!ppixiv_url) return null; // Parse the hash of the current page as a path. For example, if // the hash is #ppixiv/foo/bar?baz, parse it as /ppixiv/foo/bar?baz. var adjusted_url = url.hash.replace(/#/, "/"); return new URL(adjusted_url, url); }, get_hash_args: function(url) { var hash_url = helpers.parse_hash(url); if(hash_url == null) return new unsafeWindow.URLSearchParams(); var query = hash_url.search; if(!query.startsWith("?")) return new unsafeWindow.URLSearchParams(); query = query.substr(1); // Use unsafeWindow.URLSearchParams to work around https://bugzilla.mozilla.org/show_bug.cgi?id=1414602. var params = new unsafeWindow.URLSearchParams(query); return params; }, // Set the hash portion of url to args, as a ppixiv url. // // For example, given { a: "1", b: "2" }, set the hash to #ppixiv?a=1&b=2. set_hash_args: function(url, hash_params) { url.hash = "#ppixiv"; var hash_string = hash_params.toString(); if(hash_string != "") url.hash += "?" + hash_string; }, // Replace the given field in the URL path. // // If the path is "/a/b/c/d", "a" is 0 and "d" is 4. set_path_part: function(url, index, value) { url = new URL(url); // Split the path, and extend it if needed. let parts = url.pathname.split("/"); // The path always begins with a slash, so the first entry in parts is always empty. // Skip it. index++; // Hack: If this URL has a language prefixed, like "/en/users", add 1 to the index. This way // the caller doesn't need to check, since URLs can have these or omit them. if(parts.length > 1 && parts[1].length == 2) index++; // Extend the path if needed. while(parts.length < index) parts.push(""); parts[index] = value; // If the value is empty and this was the last path component, remove it. This way, we // remove the trailing slash from "/users/12345/". if(value == "" && parts.length == index+1) parts = parts.slice(0, index); url.pathname = parts.join("/"); return url; }, get_path_part: function(url, index, value) { // The path always begins with a slash, so the first entry in parts is always empty. // Skip it. index++; let parts = url.pathname.split("/"); if(parts.length > 1 && parts[1].length == 2) index++; return parts[index] || ""; }, // Given a URLSearchParams, return a new URLSearchParams with keys sorted alphabetically. sort_query_parameters(search) { var search_keys = unsafeWindow.Array.from(search.keys()); // GreaseMonkey encapsulation is bad search_keys.sort(); var result = new URLSearchParams(); for(var key of search_keys) result.set(key, search.get(key)); return result; }, // This is incremented whenever we navigate forwards, so we can tell in onpopstate // whether we're navigating forwards or backwards. current_history_state_index() { return (history.state && history.state.index != null)? history.state.index: 0; }, get_args: function(url) { var url = new URL(url, document.location); return { path: url.pathname, query: url.searchParams, hash: helpers.get_hash_args(url), } }, get_url_from_args(args) { var url = new URL(document.location); url.pathname = args.path; url.search = args.query.toString(); helpers.set_hash_args(url, args.hash); return url; }, set_args(args, add_to_history, cause) { var url = helpers.get_url_from_args(args); helpers.set_page_url(url, add_to_history, cause); }, // 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. set_page_url(url, add_to_history, cause) { var old_url = document.location.toString(); // history.state.index is incremented whenever we navigate forwards, so we can // tell in onpopstate whether we're navigating forwards or backwards. var current_history_index = helpers.current_history_state_index(); var new_history_index = current_history_index; if(add_to_history) new_history_index++; var history_data = { index: new_history_index }; // console.log("Changing state to", url.toString()); if(add_to_history) history.pushState(history_data, "", url.toString()); else history.replaceState(history_data, "", 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", document.location.toString(), add_to_history); if(document.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:", document.location.toString()); var event = new PopStateEvent("popstate"); // Set initialNavigation to true. This indicates that this event is for a new // navigation, and not from browser forwards/back. event.navigationCause = cause; window.dispatchEvent(event); } }, setup_popups(container, selectors) { var setup_popup = function(box) { box.addEventListener("mouseover", function(e) { helpers.set_class(box, "popup-visible", true); }); box.addEventListener("mouseout", function(e) { helpers.set_class(box, "popup-visible", false); }); } for(var selector of selectors) { var box = container.querySelector(selector); if(box == null) { console.warn("Couldn't find", selector); continue; } setup_popup(box); } }, // Return the offset of element relative to an ancestor. get_relative_pos(element, ancestor) { var x = 0, y = 0; while(element != null && element != ancestor) { x += element.offsetLeft; y += element.offsetTop; // Advance through parents until we reach the offsetParent or the ancestor // that we're stopping at. We do this rather than advancing to offsetParent, // in case ancestor isn't an offsetParent. var search_for = element.offsetParent; while(element != ancestor && element != search_for) element = element.parentNode; } return [x, y]; }, clamp(value, min, max) { return Math.min(Math.max(value, min), max); }, // Return a promise that resolves when img finishes loading, or rejects if it // fails to load. wait_for_image_load(img, abort_signal) { return new Promise((resolve, reject) => { // Resolve immediately if the image is already loaded. if(img.complete) { resolve(); return; } if(abort_signal && abort_signal.aborted) { reject("Aborted"); return; } var onabort = (e) => { remove_listeners(); reject("Aborted"); }; var onerror = (e) => { remove_listeners(); reject("Load error"); }; var onload = (e) => { remove_listeners(); resolve(); }; var remove_listeners = () => { img.removeEventListener("error", onerror); img.removeEventListener("load", onload); if(abort_signal) abort_signal.addEventListener("abort", onabort); }; img.addEventListener("error", onerror); img.addEventListener("load", onload); if(abort_signal) abort_signal.addEventListener("abort", onabort); }); }, // If image.decode is available, asynchronously decode url. async decode_image(url, abort_signal) { var img = document.createElement("img"); img.src = url; var onabort = (e) => { // If we're aborted, set the image to a small PNG, which cancels the previous load // in Firefox and Chrome. img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; }; if(abort_signal) abort_signal.addEventListener("abort", onabort); try { await helpers.wait_for_image_load(img, abort_signal); } catch(e) { // Ignore load errors, since this is just a load optimization. // console.error("Ignoring error in decode:", e); return; } finally { // Remove the abort listener. if(abort_signal) abort_signal.removeEventListener("abort", onabort); } // If we finished by aborting, don't bother decoding the blank PNG we changed the // image to. if(abort_signal && abort_signal.aborted) return; if(HTMLImageElement.prototype.decode == null) { // If we don't have img.decode, fake it by drawing the image into an offscreen canvas // to force the browser to decode it. var canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; var context = canvas.getContext('2d'); context.drawImage(img, 0, 0); } else { try { await img.decode(); } catch(e) { // console.error("Ignoring error in decode:", e); } } }, // Return a CSS style to specify thumbnail resolutions. // // Based on the dimensions of the container and a desired pixel size of thumbnails, // figure out how many columns to display to bring us as close as possible to the // desired size. // // container is the containing block (eg. ul.thumbnails). // top_selector is a CSS selector for the thumbnail block. We should be able to // simply create a scoped stylesheet, but browsers don't understand the importance // of encapsulation. make_thumbnail_sizing_style(container, top_selector, options) { // The total pixel size we want each thumbnail to have: var desired_size = options.size || 300; var ratio = options.ratio || 1; var max_columns = options.max_columns || 5; var desired_pixels = desired_size*desired_size / window.devicePixelRatio; var container_width = container.parentNode.clientWidth; var padding = container_width / 100; padding = Math.min(padding, 10); padding = Math.round(padding); if(options.min_padding) padding = Math.max(padding, options.min_padding); var closest_error_to_desired_pixels = -1; var best_size = [0,0]; var best_columns = 0; for(var columns = max_columns; columns >= 1; --columns) { // The amount of space in the container remaining for images, after subtracting // the padding around each image. var remaining_width = container_width - padding*columns*2; var max_width = remaining_width / columns; var max_height = max_width; if(ratio < 1) max_width *= ratio; else if(ratio > 1) max_height /= ratio; max_width = Math.floor(max_width); max_height = Math.floor(max_height); var pixels = max_width * max_height; var error = Math.abs(pixels - desired_pixels); if(closest_error_to_desired_pixels == -1 || error < closest_error_to_desired_pixels) { closest_error_to_desired_pixels = error; best_size = [max_width, max_height]; best_columns = columns; } } max_width = best_size[0]; max_height = best_size[1]; // If we want a smaller thumbnail size than we can reach within the max column // count, we won't have reached desired_pixels. In this case, just clamp to it. // This will cause us to use too many columns, which we'll correct below with // container_width. if(max_width * max_height > desired_pixels) { max_height = max_width = Math.round(Math.sqrt(desired_pixels)); if(ratio < 1) max_width *= ratio; else if(ratio > 1) max_height /= ratio; } // Clamp the width of the container to the number of columns we expect. var container_width = max_columns * (max_width+padding*2); var css = top_selector + " .thumbnail-link { " + "width: " + max_width + "px; " + "height: " + max_height + "px; " + "} " + top_selector + " li.thumbnail-box { padding: " + padding + "px; }"; if(container_width != null) css += top_selector + " > .thumbnails { max-width: " + container_width + "px; }"; return css; }, // If the aspect ratio is very narrow, don't use any panning, since it becomes too spastic. // If the aspect ratio is portrait, use vertical panning. // If the aspect ratio is landscape, use horizontal panning. // // If it's in between, don't pan at all, since we don't have anywhere to move and it can just // make the thumbnail jitter in place. // // Don't pan muted images. // // container_aspect_ratio is the aspect ratio of the box the thumbnail is in. If the // thumb is in a 2:1 landscape box, we'll adjust the min and max aspect ratio accordingly. set_thumbnail_panning_direction(thumb, width, height, container_aspect_ratio) { var aspect_ratio = width / height; aspect_ratio /= container_aspect_ratio; var min_aspect_for_pan = 1.1; var max_aspect_for_pan = 4; var vertical_panning = aspect_ratio > (1/max_aspect_for_pan) && aspect_ratio < 1/min_aspect_for_pan; var horizontal_panning = aspect_ratio > min_aspect_for_pan && aspect_ratio < max_aspect_for_pan; helpers.set_class(thumb, "vertical-panning", vertical_panning); helpers.set_class(thumb, "horizontal-panning", horizontal_panning); }, set_title(illust_data, user_data) { if(user_data == null && illust_data != null) user_data = illust_data.userInfo; if(illust_data == null) { helpers.set_page_title("Loading..."); return; } var page_title = ""; if(illust_data.bookmarkData) page_title += "★"; page_title += user_data.name + " - " + illust_data.illustTitle; helpers.set_page_title(page_title); }, set_icon(illust_data, user_data) { if(user_data == null && illust_data != null) user_data = illust_data.userInfo; helpers.set_page_icon(user_data && user_data.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']); }, set_title_and_icon(illust_data, user_data) { helpers.set_title(illust_data, user_data) helpers.set_icon(illust_data, user_data) }, // Return 1 if the given keydown event should zoom in, -1 if it should zoom // out, or null if it's not a zoom keypress. is_zoom_hotkey(e) { if(!e.ctrlKey) return null; if(e.code == "NumpadAdd" || e.code == "Equal") /* = */ return +1; if(e.code == "NumpadSubtract" || e.code == "Minus") /* - */ return -1; return null; }, // https://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas/3368118#3368118 /* * Draws a rounded rectangle using the current state of the canvas. * If you omit the last three params, it will draw a rectangle * outline with a 5 pixel border radius */ draw_round_rect(ctx, x, y, width, height, radius) { if(typeof radius === 'undefined') radius = 5; if(typeof radius === 'number') { radius = {tl: radius, tr: radius, br: radius, bl: radius}; } else { var defaultRadius = {tl: 0, tr: 0, br: 0, bl: 0}; for(var side in defaultRadius) radius[side] = radius[side] || defaultRadius[side]; } ctx.beginPath(); ctx.moveTo(x + radius.tl, y); ctx.lineTo(x + width - radius.tr, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); ctx.lineTo(x + width, y + height - radius.br); ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); ctx.lineTo(x + radius.bl, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); ctx.lineTo(x, y + radius.tl); ctx.quadraticCurveTo(x, y, x + radius.tl, y); ctx.closePath(); }, // Helpers for IDs in the illustration list. // // Most things we show in thumbs are illustration IDs, and we pass them around normally. // If we need to show something else in a thumbnail, we encode it. We can show a user // thumbnail by adding "user:12345" as an ID. // // Return the type of the ID. If this is just a regular illustration ID, return "illust". // Otherwise, return the prefix ("user"). id_type(id) { let parts = id.split(":"); if(parts.length < 2) return "illust"; else return parts[0]; }, // Return the real ID. For example, for "user:12345", the actual ID is 12345. actual_id(id) { let parts = id.split(":"); if(parts.length < 2) return id; else return parts[1]; } }; // Handle maintaining and calling a list of callbacks. class callback_list { 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. class view_hidden_listener { static send_viewhidden(element) { var event = new Event("viewhidden", { bubbles: false }); element.dispatchEvent(event); } constructor(element, callback) { this.onviewhidden = this.onviewhidden.bind(this); this.callback = callback; // There's no way to listen on events on any parent, so we have to add listeners // to each parent in the tree. this.listening_on_elements = []; while(element != null) { this.listening_on_elements.push(element); element.addEventListener("viewhidden", this.onviewhidden); element = element.parentNode; } } // Remove listeners. shutdown() { for(var element of this.listening_on_elements) element.removeEventListener("viewhidden", this.onviewhidden); this.listening_on_elements = []; } onviewhidden(e) { this.callback(e); } }; // Filter an image to a canvas. // // When an image loads, draw it to a canvas of the same size, optionally applying filter // effects. // // If base_filter is supplied, it's a filter to apply to the top copy of the image. // If overlay(ctx, img) is supplied, it's a function to draw to the canvas. This can // be used to mask the top copy. class image_canvas_filter { constructor(img, canvas, base_filter, overlay) { this.img = img; this.canvas = canvas; this.base_filter = base_filter || ""; this.overlay = overlay; this.ctx = this.canvas.getContext("2d"); this.img.addEventListener("load", this.update_canvas.bind(this)); // For some reason, browsers can't be bothered to implement onloadstart, a seemingly // fundamental progress event. So, we have to use a mutation observer to tell when // the image is changed, to make sure we clear it as soon as the main image changes. this.observer = new MutationObserver((mutations) => { for(var mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "src") { this.update_canvas(); } } } }); this.observer.observe(this.img, { attributes: true }); this.update_canvas(); } clear() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } update_canvas() { this.canvas.width = this.img.naturalWidth; this.canvas.height = this.img.naturalHeight; this.clear(); // If the image is still loading, just clear any previous image from the canvas. if(!this.img.complete) return; // Draw the image onto the canvas. this.ctx.save(); this.ctx.filter = this.base_filter; this.ctx.drawImage(this.img, 0, 0); this.ctx.restore(); // Composite on top of the base image. this.ctx.save(); if(this.overlay) this.overlay(this.ctx, this.img); this.ctx.restore(); // Use destination-over to draw the image underneath the overlay we just drew. this.ctx.globalCompositeOperation = "destination-over"; this.ctx.drawImage(this.img, 0, 0); } } // Add delays to hovering and unhovering. The class "hover" will be set when the mouse // is over the element (equivalent to the :hover selector), with a given delay before the // state changes. // // This is used when hovering the top bar when in ui-on-hover mode, to delay the transition // before the UI disappears. transition-delay isn't useful for this, since it causes weird // hitches when the mouse enters and leaves the area quickly. class hover_with_delay { 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 class key_storage { constructor(name) { this.name = name; this.ready = new Promise((resolve, reject) => { var request = indexedDB.open("ppixiv"); request.onupgradeneeded = e => { this.db = e.target.result; this.db.createObjectStore(this.name); }; request.onsuccess = e => { this.db = e.target.result; resolve(); }; request.onerror = e => { this.db = e.target.result; reject(e); }; }); } getStore() { let transaction = this.db.transaction(this.name, "readwrite"); return transaction.objectStore(this.name); } static async_store_get(store, key) { return new Promise((resolve, reject) => { var request = store.get(key); request.onsuccess = e => resolve(e.target.result); request.onerror = reject; }); } async get(key, store) { await this.ready; return key_storage.async_store_get(this.getStore(), key); } // Given a list of keys, return known translations. Tags that we don't have data for are null. async multi_get(keys) { await this.ready; let store = this.getStore(); 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) { await this.ready; return key_storage.async_store_set(this.getStore(), key, value); } // Internal helper: batch set all keys[n] to values[n]. static async_store_multi_set(store, keys, values) { if(keys.length != values.length) throw "key and value arrays have different lengths"; return new Promise((resolve, reject) => { // Only wait for onsuccess on the final put, for performance. for(let i = 0; i < keys.length; ++i) { var request = store.put(values[i], keys[i]); request.onerror = reject; if(i == keys.length - 1) request.onsuccess = resolve; } }); } // Given a dictionary, set all key/value pairs. async multi_set(data) { await this.ready; let store = this.getStore(); let keys = Object.keys(data); let values = []; for(let key of keys) values.push(data[key]); await key_storage.async_store_multi_set(store, keys, values); } } class SaveScrollPosition { constructor(node) { this.node = node; this.child = null; this.original_scroll_top = this.node.scrollTop; } // Instead of saving the top-level scroll position, store the scroll position of a given child. save_relative_to(child) { this.child = child; this.original_offset_top = child.offsetTop; } restore() { let scroll_top = this.original_scroll_top; if(this.child) { let offset = this.child.offsetTop - this.original_offset_top; scroll_top += offset; } this.node.scrollTop = scroll_top; } }; // 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. class fix_chrome_clicks { constructor(container) { // Don't do anything if we're not in Chrome. this.enabled = navigator.userAgent.indexOf("Chrome") != -1; if(!this.enabled) return; this.onpointer = this.onpointer.bind(this); this.onclick = this.onclick.bind(this); this.oncontextmenu = this.oncontextmenu.bind(this); this.container = container; this.pressed_node = null; // Since the pointer events API is ridiculous and doesn't send separate pointerdown // events for each mouse button, we have to listen to all clicks in window in order // to find out if button 0 is pressed. If the user presses button 2 outside of our // container we still want to know about button 0, but that button 0 event might happen // in another element that we don't care about, // for each this.container.addEventListener("pointerdown", this.onpointer, true); this.container.addEventListener("pointerup", this.onpointer, true); this.container.addEventListener("pointermove", this.onpointer, true); this.container.addEventListener("contextmenu", this.oncontextmenu); this.container.addEventListener("click", this.onclick, true); } // We have to listen on window as well as our container for events, since a // mouse up might happen on another node after the mouse down happened in our // node. We only register these while a button is pressed in our node, so we // don't have global pointer event handlers installed all the time. start_waiting_for_release() { if(this.pressed_node != null) { console.warn("Unexpected call to start_waiting_for_release"); return; } window.addEventListener("pointerup", this.onpointer, true); window.addEventListener("pointermove", this.onpointer, true); } stop_waiting_for_release() { if(this.pressed_node == null) return; window.removeEventListener("pointerup", this.onpointer, true); window.removeEventListener("pointermove", this.onpointer, true); this.pressed_node = null; } // The pointer events API is nonsensical: button presses generate pointermove // instead of pointerdown or pointerup if another button is already pressed. That's // completely useless, so we have to just listen to all of them the same way and // deduce what's happening from the button mask. onpointer(e) { if(e.pointerType != "mouse") return; if(e.type == "pointerdown") { // Start listening to move events. We only need this while a button // is pressed. this.start_waiting_for_release(); } if(e.buttons & 1) { // The primary button is pressed, so remember what element we were on. if(this.pressed_node == null) { // console.log("mousedown", e.target.id); this.pressed_node = e.target; } return; } if(this.pressed_node == null) return; var pressed_node = this.pressed_node; // The button was released. Unregister our temporary event listeners. this.stop_waiting_for_release(); // console.log("released:", e.target.id, "after click on", pressed_node.id); var released_node = e.target; var click_target = null; if(helpers.is_above(released_node, pressed_node)) click_target = released_node; else if(helpers.is_above(pressed_node, released_node)) click_target = pressed_node; if(click_target == null) { // console.log("No target for", pressed_node, "and", released_node); return; } // If the click target is above our container, stop. if(helpers.is_above(click_target, this.container)) return; // Why is cancelling the event not preventing mouse events and click events? e.preventDefault(); // console.log("do click on", click_target.id, e.defaultPrevented, e.type); this.send_click_event(click_target, e); } oncontextmenu(e) { if(this.pressed_node != null && !e.defaultPrevented) { console.log("Not sending click because the context menu was opened"); this.pressed_node = null; } } // Cancel regular mouse clicks. // // Pointer events is a broken API. It sends mouse button presses as pointermove // if another button is already pressed, which already doesn't make sense and // makes it a headache to use. But, to make things worse, pointermove is defined // as having the same default event behavior as mousemove, despite the fact that it // can correspond to a mouse press or release. Also, preventDefault just seems to // be broken in Chrome and has no effect. // // So, we just cancel all button 0 click events that weren't sent by us. onclick(e) { if(e.button != 0) return; // Ignore synthetic events. if(!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); } send_click_event(target, source_event) { var e = new MouseEvent("click", source_event); e.synthetic = true; target.dispatchEvent(e); } shutdown() { if(!this.enabled) return; this.stop_waiting_for_release(); this.pressed_node = null; this.container.removeEventListener("pointerup", this.onpointer, true); this.container.removeEventListener("pointerdown", this.onpointer, true); this.container.removeEventListener("pointermove", this.onpointer, true); this.container.removeEventListener("contextmenu", this.oncontextmenu); this.container.removeEventListener("click", this.onclick, true); } } // A basic widget base class. class widget { constructor(container) { this.container = container; // Let the caller finish, then refresh. setTimeout(() => { this.refresh(); }, 0); } async refresh() { } } // A widget that shows info for a particular illust_id. // // An illust_id can be set, and we'll refresh when it changes. class illust_widget extends widget { constructor(container) { super(container); // Refresh when the image data changes. image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } set illust_id(value) { if(this._illust_id == value) return; this._illust_id = value; this.refresh(); } get illust_id() { return this._illust_id; } get visible() { return !this.container.hidden; } async refresh() { // Grab the illust info. var illust_id = this._illust_id; var illust_data = null; if(this._illust_id != null) illust_data = await image_data.singleton().get_image_info(this._illust_id); // Stop if the ID changed while we were async. if(this._illust_id != illust_id) return; await this.refresh_internal(illust_data); } refresh_internal(illust_data) { throw "Not implemented"; } } // Display messages in the popup widget. This is a singleton. class message_widget { static get singleton() { if(message_widget._singleton == null) message_widget._singleton = new message_widget(); return message_widget._singleton; } constructor() { this.container = document.body.querySelector(".hover-message"); this.timer = null; } show(message) { this.clear_timer(); this.container.querySelector(".message").innerHTML = message; this.container.classList.add("show"); this.container.classList.remove("centered"); this.timer = setTimeout(function() { this.container.classList.remove("show"); }.bind(this), 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. class click_outside_listener { constructor(node_list, callback) { this.window_onmousedown = this.window_onmousedown.bind(this); this.node_list = node_list; this.callback = callback; window.addEventListener("mousedown", this.window_onmousedown, true); } // Return true if node is below any node in node_list. is_node_in_list(node) { for(var 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. class dropdown_menu_opener { static create_handlers(container, selectors) { for(var selector of selectors) { var item = container.querySelector(selector); if(item == null) { console.warn("Couldn't find", selector); continue; } dropdown_menu_opener.create_handler(item); } } // A shortcut for creating an opener for our common button/popup layout. static create_handler(container) { var button = container.querySelector(".menu-button"); var box = container.querySelector(".popup-menu-box"); if(button == null) { console.error("Couldn't find menu button for " + container); return; } if(box == null) { console.error("Couldn't find menu box for " + container); return; } new dropdown_menu_opener(button, box); } constructor(button, box) { this.box_onclick = this.box_onclick.bind(this); this.button = button; this.box = box; this.visible = false; this.button.addEventListener("click", (e) => { this.button_onclick(e); }); // Hide popups when any containing view is hidden. new view_hidden_listener(this.button, (e) => { this.visible = false; }); } // The viewhidden event is sent when the enclosing view is no longer visible, and // all menus in it should be hidden. onviewhidden(e) { this.visible = false; } get visible() { return !this.box.hidden; } set visible(value) { if(this.box.hidden == !value) return; this.box.hidden = !value; helpers.set_class(this.box, "popup-visible", value); if(value) { this.listener = new click_outside_listener([this.button, this.box], () => { this.visible = false; }); if(this.close_on_click_inside) this.box.addEventListener("click", this.box_onclick, true); } else { if(this.listener) { this.listener.shutdown(); this.listener = null; } this.box.removeEventListener("click", this.box_onclick, true); } // If we're inside a .top-ui-box container (the UI that sits at the top of the screen), set // .force-open on that element while we're open. let top_ui_box = this.box.closest(".top-ui-box"); if(top_ui_box) helpers.set_class(top_ui_box, "force-open", value); // Let the widget know its visibility has changed. this.box.dispatchEvent(new Event(value? "popupshown":"popuphidden")); } // Return true if this popup should close when clicking inside it. If false, // the menu will stay open until something else closes it. get close_on_click_inside() { return true; } box_onclick(e) { if(e.target.closest(".keep-menu-open")) return; this.visible = false; } // Toggle the popup when the button is clicked. button_onclick(e) { e.preventDefault(); e.stopPropagation(); this.visible = !this.visible; } }; // A pointless creepy eye. Looks away from the mouse cursor when hovering over // the unfollow button. class creepy_eye_widget { constructor(eye) { this.onevent = this.onevent.bind(this); this.eye = eye; this.eye.addEventListener("mouseenter", this.onevent); this.eye.addEventListener("mouseleave", this.onevent); this.eye.addEventListener("mousemove", this.onevent); this.eye_middle = this.eye.querySelector(".middle"); } onevent(e) { if(e.type == "mouseenter") this.hover = true; if(e.type == "mouseleave") this.hover = false; if(!this.hover) { this.eye_middle.style.transform = ""; return; } var mouse = [e.pageX, e.pageY]; var bounds = this.eye.getBoundingClientRect(); var eye = [bounds.x + bounds.width/2, bounds.y + bounds.height/2]; var vector_length = function(vec) { return Math.sqrt(vec[0]*vec[0] + vec[1]*vec[1]); } // Normalize to get a direction vector. var normalize_vector = function(vec) { var length = vector_length(vec); if(length < 0.0001) return [0,0]; return [vec[0]/length, vec[1]/length]; }; var pos = [mouse[0] - eye[0], mouse[1] - eye[1]]; pos = normalize_vector(pos); if(Math.abs(pos[0]) < 0.5) { var negative = pos[0] < 0; pos[0] = 0.5; if(negative) pos[0] *= -1; } // pos[0] = 1 - ((1-pos[0]) * (1-pos[0])); pos[0] *= -3; pos[1] *= -6; this.eye_middle.style.transform = "translate(" + pos[0] + "px, " + pos[1] + "px)"; } } class avatar_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) { this.options = options; if(this.options.mode != "dropdown" && this.options.mode != "overlay") throw "Invalid avatar widget mode"; this.clicked_follow = this.clicked_follow.bind(this); this.user_changed = this.user_changed.bind(this); this.root = helpers.create_from_template(".template-avatar"); helpers.set_class(this.root, "big", this.options.big); image_data.singleton().user_modified_callbacks.register(this.user_changed); var element_author_avatar = this.root.querySelector(".avatar"); this.img = document.createElement("img"); // A canvas filter for the avatar. This has no actual filters. This is just to kill off any // annoying GIF animations in people's avatars. this.base_filter = new image_canvas_filter(this.img, element_author_avatar.querySelector("canvas.main")); // The actual highlight filter: this.highlight_filter = new image_canvas_filter(this.img, element_author_avatar.querySelector("canvas.highlight"), "brightness(150%)", (ctx, img) => { ctx.globalCompositeOperation = "destination-in"; var feather = 25; var radius = 15; ctx.filter = "blur(" + feather + "px)"; helpers.draw_round_rect(ctx, feather, feather + this.img.naturalHeight/2, this.img.naturalWidth - feather*2, this.img.naturalHeight - feather*2, radius); ctx.fill(); }); this.root.dataset.mode = this.options.mode; // Show the favorite UI when hovering over the avatar icon. var avatar_popup = this.root; //container.querySelector(".avatar-popup"); if(this.options.mode == "dropdown") { avatar_popup.addEventListener("mouseover", function(e) { helpers.set_class(avatar_popup, "popup-visible", true); }.bind(this)); avatar_popup.addEventListener("mouseout", function(e) { helpers.set_class(avatar_popup, "popup-visible", false); }.bind(this)); } new creepy_eye_widget(this.root.querySelector(".unfollow-button .eye-image")); for(var button of avatar_popup.querySelectorAll(".follow-button.public")) button.addEventListener("click", this.clicked_follow.bind(this, false), false); for(var button of avatar_popup.querySelectorAll(".follow-button.private")) button.addEventListener("click", this.clicked_follow.bind(this, true), false); for(var button of avatar_popup.querySelectorAll(".unfollow-button")) button.addEventListener("click", this.clicked_follow.bind(this, true), false); this.element_follow_folder = avatar_popup.querySelector(".folder"); // Follow publically when enter is pressed on the follow folder input. helpers.input_handler(avatar_popup.querySelector(".folder"), this.clicked_follow.bind(this, false)); this.options.parent.appendChild(this.root); } shutdown() { image_data.singleton().user_modified_callbacks.unregister(this.user_changed); } // Refresh when the user changes. user_changed(user_id) { if(this.user_data == null || this.user_data.userId != user_id) return; this.set_from_user_data(this.user_data); } set_from_user_data(user_data) { // Clear the previous image. Do this even if we're going to set it below, so we show // black while loading a new image rather than showing the previous image. this.base_filter.clear(); this.highlight_filter.clear(); this.user_data = user_data; if(this.user_data == null) { this.root.classList.add("loading"); return; } this.root.classList.remove("loading"); helpers.set_class(this.root, "self", user_data.userId == global_data.user_id); // We can't tell if we're followed privately or not, only that we're following. helpers.set_class(this.root, "followed", this.user_data.isFollowed); this.root.querySelector(".avatar-link").href = "/users/" + user_data.userId + "#ppixiv"; this.root.querySelector(".avatar").dataset.popup = "View " + user_data.name + "'s posts"; // Hide the popup in dropdown mode, since it covers the dropdown. if(this.options.mode == "dropdown") this.root.querySelector(".avatar").classList.remove("popup"); // 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. var key = "imageBig"; if(user_data[key]) this.img.src = user_data[key]; } async follow(follow_privately) { if(this.user_data == null) return; var tags = this.element_follow_folder.value; await actions.follow(this.user_data, follow_privately, tags); } async unfollow() { if(this.user_data == null) return; await actions.unfollow(this.user_data); } // Note that in some cases we'll only have the user's ID and name, so we won't be able // to tell if we're following. clicked_follow(follow_privately, e) { e.preventDefault(); e.stopPropagation(); if(this.user_data == null) return; if(this.user_data.isFollowed) { // Unfollow the user. this.unfollow(); return; } // Follow the user. this.follow(follow_privately); } handle_onkeydown(e) { if(this.user_data == null) return; if(e.keyCode == 70) // f { // f to follow publically, F to follow privately, ^F to unfollow. e.stopPropagation(); e.preventDefault(); if(this.user_data == null) return; if(e.ctrlKey) { // Remove the bookmark. if(!this.user_data.isFollowed) { message_widget.singleton.show("Not following this user"); return; } this.unfollow(); return; } if(this.user_data.isFollowed) { message_widget.singleton.show("Already following (^F to unfollow)"); return; } this.follow(e.shiftKey); return; } } }; // A list of tags, with translations in popups where available. class tag_widget { // options: // parent: node to add ourself to (required) // format_link: a function to format a tag to a URL constructor(options) { this.options = options; this.container = this.options.parent; this.tag_list_container = this.options.parent.appendChild(document.createElement("div")); this.tag_list_container.classList.add("tag-list-widget"); // Refresh when we're opened, in case translations have been turned on or off. this.container.addEventListener("popupshown", (e) => { this.refresh(); }); }; format_tag_link(tag) { if(this.options.format_link) return this.options.format_link(tag); var search_url = new URL("/tags/" + encodeURIComponent(tag) + "/artworks", window.location.href); search_url.hash = "#ppixiv"; return search_url.toString(); }; async set(tags) { this.tags = tags; // Register any tag translations. Not all sources will have this. tag_translations.get().add_translations(this.tags.tags); this.refresh(); } async refresh() { if(this.tags == null) return; // Look up tag translations. let tag_list = []; for(var tag of this.tags.tags) tag_list.push(tag.tag); let translated_tags = await tag_translations.get().get_translations(tag_list, "en"); // Remove any old tag list and create a new one. helpers.remove_elements(this.tag_list_container); for(var tag of tag_list) { var a = this.tag_list_container.appendChild(document.createElement("a")); a.classList.add("tag"); a.classList.add("box-link"); var popup = null; let translated_tag = tag; if(translated_tags[tag]) translated_tag = translated_tags[tag]; a.dataset.tag = tag; a.textContent = translated_tag; a.href = this.format_tag_link(tag); } } }; // A helper for a simple right-click context menu. // // The menu opens on right click and closes when the button is released. class popup_context_menu { constructor(container) { this.onmousedown = this.onmousedown.bind(this); this.window_onmouseup = this.window_onmouseup.bind(this); this.window_onblur = this.window_onblur.bind(this); this.oncontextmenu = this.oncontextmenu.catch_bind(this); this.onmouseover = this.onmouseover.bind(this); this.onmouseout = this.onmouseout.bind(this); this.hide = this.hide.bind(this); this.container = container; this.blocking_context_menu_until_mouseup = false; this.blocking_context_menu_until_timer = false; this.container.addEventListener("mousedown", this.onmousedown); window.addEventListener("contextmenu", this.oncontextmenu); window.addEventListener("mouseup", this.window_onmouseup); // Create the menu. The caller will attach event listeners for clicks. this.menu = helpers.create_from_template(".template-context-menu"); // Work around glitchiness in Chrome's click behavior (if we're in Chrome). new fix_chrome_clicks(this.menu); this.menu.addEventListener("mouseover", this.onmouseover, true); this.menu.addEventListener("mouseout", this.onmouseout, true); // Whether the left and right mouse buttons are pressed: this.buttons_down = [false, false, false]; } 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; } // - Block the context menu when the popup menu is open (we're acting as the context menu). // - When the context menu is closed, keep preventing the context menu until we see a right // click (or loss of window focus), followed by a short delay to work around browser inconsistencies. oncontextmenu(e) { // If we're already visible, always block contextmenu. /* if(this.visible) { console.log("stop context menu (already open)"); e.preventDefault(); e.stopPropagation(); return; } */ if(this.blocking_context_menu_until_mouseup) { // console.log("stop context menu (waiting for mouseup)"); e.preventDefault(); e.stopPropagation(); } if(this.blocking_context_menu_until_timer) { // console.log("stop context menu (waiting for timer)"); e.preventDefault(); e.stopPropagation(); } // console.log("not preventing context menu"); } onmousedown(e) { if(!this.visible && !this.context_menu_enabled_for_element(e.target)) return; if(!this.visible && e.button != 2) return; this.buttons_down[e.button] = true; if(e.button != 2) 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); // We're either showing or hiding the context menu on click, so block the context menu until // release. It's crazy that preventDefault on mousedown doesn't prevent contextmenu and makes // all of this needlessly complicated. this.block_context_menu_until_mouseup(); } // 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); } // After mouseup, we'll move into block_context_menu_until_timer. block_context_menu_until_mouseup() { if(this.blocking_context_menu_until_mouseup) return; console.log("Waiting for mouseup before releasing context menu"); this.blocking_context_menu_until_mouseup = true; } // Keep blocking briefly after mouseup. This works around Firefox being flaky. Otherwise, // mashing the RMB will cause the context menu to randomly appear (this doesn't happen in // Chrome). block_context_menu_until_timer() { console.log("Waiting for timer before releasing context menu"); this.blocking_context_menu_until_mouseup = false; this.blocking_context_menu_until_timer = true; if(this.timer != null) { clearTimeout(this.timer); this.timer = null; } this.timer = setTimeout(() => { this.timer = null; console.log("Releasing context menu after timer"); this.blocking_context_menu_until_timer = false; }, 50); } // 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. window_onmouseup(e) { // If we're blocking until mouseup and this is a RMB release, switch to blocking on // a timer. if(e.button == 2 && this.blocking_context_menu_until_mouseup) this.block_context_menu_until_timer(); if(!this.visible) return; this.buttons_down[e.button] = false; if(this.toggle_mode) return; if(!this.buttons_down[0] && !this.buttons_down[2]) { // Run the hide asynchronously. If we close it immediately and this // release would have triggered a click event, the click won't happen. setTimeout(() => { this.hide(); }, 0); } } 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; } get visible() { return this.displayed_menu != null; } show(x, y, target) { this.menu.hidden = false; if(this.visible) return; this.displayed_menu = this.menu; this.container.appendChild(this.displayed_menu); // Disable popup UI while a context menu is open. document.body.classList.add("hide-ui"); window.addEventListener("blur", this.window_onblur); // In toggle mode, close the popup if anything outside is clicked. if(this.toggle_mode && this.click_outside_listener == null) { this.click_outside_listener = new click_outside_listener([this.menu], () => { // Small hack: delay this, so if this is a right click, it doesn't close and then // immediately reopen the menu. setTimeout(this.hide, 0); }); } var centered_element = this.element_to_center; if(centered_element == null) centered_element = this.displayed_menu; var pos = helpers.get_relative_pos(centered_element, this.displayed_menu); x -= pos[0]; y -= pos[1]; x -= centered_element.offsetWidth / 2; y -= centered_element.offsetHeight * 3 / 4; this.displayed_menu.style.left = x + "px"; this.displayed_menu.style.top = y + "px"; hide_mouse_cursor_on_idle.disable_all(); } // If element is within a button that has a tooltip set, show it. show_tooltip_for_element(element) { if(element != null) element = element.closest("[data-popup]"); if(this.tooltip_element == element) return; this.tooltip_element = element; this.refresh_tooltip(); if(this.tooltip_observer) { this.tooltip_observer.disconnect(); this.tooltip_observer = null; } if(this.tooltip_element == null) return; // Refresh the tooltip if the popup attribute changes while it's visible. this.tooltip_observer = new MutationObserver((mutations) => { for(var mutation of mutations) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-popup") this.refresh_tooltip(); } } }); this.tooltip_observer.observe(this.tooltip_element, { attributes: true }); } refresh_tooltip() { var element = this.tooltip_element; if(element != null) element = element.closest("[data-popup]"); this.menu.querySelector(".tooltip-display").hidden = element == null; if(element != null) this.menu.querySelector(".tooltip-display-text").textContent = element.dataset.popup; } onmouseover(e) { this.show_tooltip_for_element(e.target); } onmouseout(e) { this.show_tooltip_for_element(e.relatedTarget); } get hide_temporarily() { return this.menu.hidden; } set hide_temporarily(value) { this.menu.hidden = value; } hide() { if(!this.visible) return; // Let menus inside the context menu know we're closing. view_hidden_listener.send_viewhidden(this.menu); this.displayed_menu.parentNode.removeChild(this.displayed_menu); this.displayed_menu = null; hide_mouse_cursor_on_idle.enable_all(); this.buttons_down = [false, false, false]; document.body.classList.remove("hide-ui"); window.removeEventListener("blur", this.window_onblur); if(this.click_outside_listener) { this.click_outside_listener.shutdown(); this.click_outside_listener = null; } } shutdown() { this.hide(); // Remove any mutation observer. this.show_tooltip_for_element(null); this.container.removeEventListener("mousedown", this.onmousedown); this.container.removeEventListener("click", this.onclick); window.removeEventListener("contextmenu", this.oncontextmenu); window.removeEventListener("mouseup", this.window_onmouseup); } } // A popup for inputting text. // // This is currently special purpose for the add tag prompt. class text_prompt { constructor() { this.submit = this.submit.bind(this); this.close = this.close.bind(this); this.onkeydown = this.onkeydown.bind(this); this.result = new Promise((completed, cancelled) => { this._completed = completed; this._cancelled = cancelled; }); this.root = helpers.create_from_template(".template-add-tag-prompt"); document.body.appendChild(this.root); this.input = this.root.querySelector("input.add-tag-input"); this.input.value = ""; this.input.focus(); this.root.querySelector(".close-button").addEventListener("click", this.close); this.root.querySelector(".submit-button").addEventListener("click", this.submit); this.root.addEventListener("click", (e) => { // Clicks that aren't inside the box close the dialog. if(e.target.closest(".box") != null) return; e.preventDefault(); e.stopPropagation(); this.close(); }); window.addEventListener("keydown", this.onkeydown); // This disables global key handling and hotkeys. document.body.dataset.popupOpen = "1"; } onkeydown(e) { if(e.key == "Escape") { e.preventDefault(); e.stopPropagation(); this.close(); } if(e.key == "Enter") { e.preventDefault(); e.stopPropagation(); this.submit(); } } // Close the popup and call the completion callback with the result. submit(e) { var result = this.input.value; console.log("submit", result); this._remove(); this._completed(result); } close() { this._remove(); // Cancel the promise. If we're actually submitting a result, this._cancelled("Cancelled by user"); } _remove() { window.removeEventListener("keydown", this.onkeydown); delete document.body.dataset.popupOpen; this.root.remove(); } } // Widget for editing bookmark tags. class bookmark_tag_list_widget extends illust_widget { constructor(container) { super(container); this.container.hidden = true; this.displaying_illust_id = null; this.container.appendChild(helpers.create_from_template(".template-popup-bookmark-tag-dropdown")); this.container.addEventListener("click", this.clicked_bookmark_tag.bind(this), true); this.container.querySelector(".add-tag").addEventListener("click", async (e) => { await actions.add_new_tag(this._illust_id); }); this.container.querySelector(".sync-tags").addEventListener("click", async (e) => { var bookmark_tags = await actions.load_recent_bookmark_tags(); console.log("refreshed", bookmark_tags); helpers.set_recent_bookmark_tags(bookmark_tags); }); // Close if our containing widget is closed. new view_hidden_listener(this.container, (e) => { this.visible = false; }); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); settings.register_change_callback("recent-bookmark-tags", this.refresh.bind(this)); } // Return an array of tags selected in the tag dropdown. get selected_tags() { var tag_list = []; var bookmark_tags = this.container; for(var entry of bookmark_tags.querySelectorAll(".popup-bookmark-tag-entry")) { if(!entry.classList.contains("active")) continue; tag_list.push(entry.dataset.tag); } return tag_list; } // Override setting illust_id to save tags when we're closed. Otherwise, illust_id will already // be cleared when we close and we won't be able to save. set illust_id(value) { // If we're hiding and were previously visible, save changes. if(value == null) this.save_current_tags(); super.illust_id = value; console.log("Tag list illust_id:", value); } get visible() { return !this.container.hidden; } // Why can't setters be async? set visible(value) { this._set_tag_dropdown_visible(value); } // Hide the dropdown without committing anything. This happens if a bookmark // button is pressed to remove a bookmark: if we just close the dropdown normally, // we'd readd the bookmark. hide_without_sync() { this._set_tag_dropdown_visible(false, true); } async _set_tag_dropdown_visible(value, skip_save) { if(this.container.hidden == !value) return; this.container.hidden = !value; if(value) { // We only load existing bookmark tags when the tag list is open, so refresh. await this.refresh(); } else { if(!skip_save) { // Save any selected tags when the dropdown is closed. this.save_current_tags(); } // Clear the tag list when the menu closes, so it's clean on the next refresh. var bookmark_tags = this.container.querySelector(".tag-list"); helpers.remove_elements(bookmark_tags); this.displaying_illust_id = null; } } async refresh_internal(illust_data) { let illust_id = illust_data? illust_data.illustId:null; // If we're refreshing the same illust that's already refreshed, store which tags were selected // before we clear the list. var old_selected_tags = this.displaying_illust_id == illust_id? this.selected_tags:[]; this.displaying_illust_id = null; var bookmark_tags = this.container.querySelector(".tag-list"); helpers.remove_elements(bookmark_tags); var bookmarked = illust_data && illust_data.bookmarkData != null; var public_bookmark = illust_data && illust_data.bookmarkData && !illust_data.bookmarkData.private; var private_bookmark = illust_data && illust_data.bookmarkData && illust_data.bookmarkData.private; // Make sure the dropdown is hidden if we have no image. if(illust_data == null) this.visible = false; if(illust_data == null || !this.visible) return; // Create a temporary entry to show loading while we load bookmark details. var 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. await image_data.singleton().load_bookmark_details(illust_data); // Remember which illustration's bookmark tags are actually loaded. this.displaying_illust_id = illust_id; // Remove elements again, in case another refresh happened while we were async // and to remove the loading entry. helpers.remove_elements(bookmark_tags); // Put tags that are set on the bookmark first in alphabetical order, followed by // all other tags in order of recent use. var active_tags = illust_data.bookmarkData? Array.from(illust_data.bookmarkData.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(var tag of old_selected_tags) { if(active_tags.indexOf(tag) == -1) active_tags.push(tag); } var shown_tags = Array.from(active_tags); // copy shown_tags.sort(); var recent_bookmark_tags = Array.from(helpers.get_recent_bookmark_tags()); // copy for(var tag of recent_bookmark_tags) if(shown_tags.indexOf(tag) == -1) shown_tags.push(tag); console.log("Showing tags:", shown_tags); for(var i = 0; i < shown_tags.length; ++i) { var tag = shown_tags[i]; var entry = helpers.create_from_template(".template-popup-bookmark-tag-entry"); entry.dataset.tag = tag; bookmark_tags.appendChild(entry); entry.querySelector(".tag-name").innerText = tag; var active = active_tags.indexOf(tag) != -1; helpers.set_class(entry, "active", active); } } // Save the selected bookmark tags to the current illust. async save_current_tags() { // Store the ID and tag list we're saving, since they can change when we await. let illust_id = this._illust_id; let new_tags = this.selected_tags; if(illust_id == null) return; // Only save tags if we're refreshed to the current illust ID, to make sure we don't save // incorrectly if we're currently waiting for the async refresh. if(illust_id != this.displaying_illust_id) return; // Get the tags currently on the bookmark to compare. var illust_data = await image_data.singleton().get_image_info(illust_id); await image_data.singleton().load_bookmark_details(illust_data); var old_tags = illust_data.bookmarkData? illust_data.bookmarkData.tags:[]; var equal = new_tags.length == old_tags.length; for(var 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); var is_bookmarked = illust_data.bookmarkData != null; await actions.bookmark_edit(illust_data, { tags: new_tags, }); } // Toggle tags on click. We don't save changes until we're closed. async clicked_bookmark_tag(e) { var 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. var tag = a.dataset.tag; helpers.set_class(a, "active", !a.classList.contains("active")); } } // The button that shows and hides the tag list. class toggle_bookmark_tag_list_widget extends illust_widget { constructor(container, bookmark_tag_widget) { super(container); this.bookmark_tag_widget = bookmark_tag_widget; // XXX // this.menu.querySelector(".tag-dropdown-arrow").hidden = !value; this.container.addEventListener("click", (e) => { e.preventDefault(); // Ignore clicks if this button isn't enabled. if(!this.container.classList.contains("enabled")) return; this.bookmark_tag_widget.visible = !this.bookmark_tag_widget.visible; }); } async refresh_internal(illust_data) { helpers.set_class(this.container, "enabled", illust_data != null); } } class bookmark_button_widget extends illust_widget { constructor(container, private_bookmark, bookmark_tag_widget) { super(container); this.private_bookmark = private_bookmark; this.bookmark_tag_widget = bookmark_tag_widget; this.container.addEventListener("click", this.clicked_bookmark.bind(this)); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } async refresh_internal(illust_data) { var count = this.container.querySelector(".count"); if(count) count.textContent = illust_data? illust_data.bookmarkCount:"---"; var bookmarked = illust_data && illust_data.bookmarkData != null; var our_bookmark_type = illust_data && illust_data.bookmarkData && illust_data.bookmarkData.private == this.private_bookmark; // Set up the bookmark buttons. helpers.set_class(this.container, "enabled", illust_data != null); helpers.set_class(this.container, "bookmarked", our_bookmark_type); helpers.set_class(this.container, "will-delete", our_bookmark_type); // Set the tooltip. var type_string = this.private_bookmark? "private":"public"; this.container.dataset.popup = illust_data == null? "": !bookmarked? (this.private_bookmark? "Bookmark privately":"Bookmark image"): our_bookmark_type? "Remove bookmark": "Change bookmark to " + type_string; } // Clicked one of the top-level bookmark buttons or the tag list. async clicked_bookmark(e) { // See if this is a click on a bookmark button. var 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. var 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. var illust_data = await image_data.singleton().get_image_info(this._illust_id); if(illust_data.bookmarkData && illust_data.bookmarkData.private == this.private_bookmark) { await actions.bookmark_remove(illust_data); // If the current image changed while we were async, stop. if(this._illust_id != illust_data.illustId) return; // Hide the tag dropdown after unbookmarking, without saving any tags in the // dropdown (that would readd the bookmark). if(this.bookmark_tag_widget) this.bookmark_tag_widget.hide_without_sync(); return; } // Add or edit the bookmark. await actions.bookmark_edit(illust_data, { private: this.private_bookmark, tags: tag_list, }); // If the current image changed while we were async, stop. if(this._illust_id != illust_data.illustId) return; } } class like_button_widget extends illust_widget { constructor(container, private_bookmark) { super(container); this.private_bookmark = private_bookmark; this.container.addEventListener("click", this.clicked_like.bind(this)); image_data.singleton().illust_modified_callbacks.register(this.refresh.bind(this)); } async refresh_internal(illust_data) { // Update the like button highlight and tooltip. this.container.querySelector(".count").textContent = illust_data? illust_data.likeCount:"---"; helpers.set_class(this.container, "liked", illust_data && illust_data.likeData); helpers.set_class(this.container, "enabled", illust_data != null && !illust_data.likeData); this.container.dataset.popup = illust_data && !illust_data.likeData? "Like image": illust_data && illust_data.likeData? "Already liked image":""; } async clicked_like(e) { e.preventDefault(); e.stopPropagation(); var illust_data = await image_data.singleton().get_image_info(this._illust_id); actions.like_image(illust_data); } } // Simple menu settings widgets. class menu_option { static add_settings(container) { if(container.closest(".view-manga-container")) { new thumbnail_size_slider_widget(container, { label: "Thumbnail size", setting: "manga-thumbnail-size", min: 0, max: 5, }); } if(container.closest(".view-search-container")) { new thumbnail_size_slider_widget(container, { label: "Thumbnail size", setting: "thumbnail-size", min: 0, max: 5, }); } new menu_option_toggle(container, { label: "Bookmarking auto-likes", setting: "auto-like", }); new menu_option_toggle(container, { label: "Disabled by default", setting: "disabled-by-default", }); new menu_option_toggle(container, { label: "Hide cursor", setting: "no-hide-cursor", invert_display: true, }); // Firefox's contextmenu behavior is broken, so hide this option. if(navigator.userAgent.indexOf("Firefox/") == -1) { new menu_option_toggle(container, { label: "Hold shift to open context menu", setting: "invert-popup-hotkey", }); } new menu_option_toggle(container, { label: "Hover to show UI", setting: "ui-on-hover", onchange: this.update_from_settings, }); new menu_option_toggle(container, { label: "Invert scrolling while zoomed", setting: "invert-scrolling", }); new menu_option_toggle_light_theme(container, { label: "Light mode", setting: "theme", }); new menu_option_toggle(container, { label: "Show translations", setting: "disable-translations", invert_display: true, }); new menu_option_toggle(container, { label: "Thumbnail panning", setting: "disable_thumbnail_panning", invert_display: true, }); new menu_option_toggle(container, { label: "Thumbnail zooming", setting: "disable_thumbnail_zooming", invert_display: true, }); /* new menu_option_toggle(container, { label: "Touchpad mode", setting: "touchpad-mode", }); */ } constructor(container, options) { this.refresh = this.refresh.bind(this); this.container = container; this.options = options; settings.register_change_callback(this.options.setting, this.refresh); } get value() { return settings.get(this.options.setting); } set value(value) { settings.set(this.options.setting, value); } refresh() { if(this.options.onchange) this.options.onchange(); } } class menu_option_toggle extends menu_option { constructor(container, options) { super(container, options); this.onclick = this.onclick.bind(this); this.item = helpers.create_from_template(".template-menu-toggle"); this.container.appendChild(this.item); this.item.addEventListener("click", this.onclick); this.item.querySelector(".label").innerText = options.label; this.refresh(); } refresh() { super.refresh(); var value = this.value; if(this.options.invert_display) value = !value; this.item.querySelector(".on").hidden = !value; this.item.querySelector(".off").hidden = value; } onclick(e) { e.preventDefault(); e.stopPropagation(); this.value = !this.value; } } // A special case for the theme, which is just a light/dark toggle but stored // as a string. class menu_option_toggle_light_theme extends menu_option_toggle { get value() { var value = super.value; return value == "light"; } set value(value) { super.value = value? "light":"dark"; } } class menu_option_slider extends menu_option { constructor(container, options) { super(container, options); this.oninput = this.oninput.bind(this); this.item = helpers.create_from_template(".template-menu-slider"); this.item.addEventListener("input", this.oninput); this.item.querySelector(".label").innerText = options.label; this.slider = this.item.querySelector("input"); this.slider.min = this.options.min; this.slider.max = this.options.max; this.container.appendChild(this.item); } refresh() { this._slider_value = this.value; super.refresh(); } oninput(e) { this.value = this._slider_value; } get value() { return parseInt(super.value); } set value(value) { super.value = value; } set _slider_value(value) { if(this.slider.value == value) return; this.slider.value = value; } get _slider_value() { return parseInt(this.slider.value); } } // A widget to control the thumbnail size slider. class thumbnail_size_slider_widget extends menu_option_slider { constructor(container, options) { super(container, options); this.onwheel = this.onwheel.bind(this); this.onkeydown = this.onkeydown.bind(this); var view = this.container.closest(".view"); view.addEventListener("wheel", this.onwheel, { passive: false }); view.addEventListener("keydown", this.onkeydown); this.refresh(); } get min_value() { return this.options.min; } get max_value() { return this.options.max; } onkeydown(e) { var zoom = helpers.is_zoom_hotkey(e); if(zoom != null) { e.preventDefault(); e.stopImmediatePropagation(); this.move(zoom < 0); } } onwheel(e) { if(!e.ctrlKey) return; e.preventDefault(); e.stopImmediatePropagation(); this.move(e.deltaY > 0); } // Increase or decrease zoom. move(down) { var value = this._slider_value; value += down?-1:+1; value = helpers.clamp(value, 0, 5); this._slider_value = value; this.value = this._slider_value; } get value() { var value = super.value; if(typeof(value) != "number" || isNaN(value)) value = 4; return value; } set value(value) { super.value = value; } static thumbnail_size_for_value(value) { return 100 * Math.pow(1.3, value); } get thumbnail_size() { return thumbnail_size_slider_widget.thumbnail_size_for_value(this.slider.value); } }; // 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. class context_menu_image_info_widget extends illust_widget { set_illust_and_page(illust_id, page) { if(this._illust_id == illust_id && this._page == page) return; this._illust_id = illust_id; this._page = page; this.refresh(); } refresh_internal(illust_data) { this.container.hidden = (illust_data == null || this._page == null); if(this.container.hidden) return; var set_info = (query, text) => { var node = this.container.querySelector(query); node.innerText = text; node.hidden = text == ""; }; // Add the page count for manga. var page_text = ""; if(illust_data.pageCount > 1) { if(this._page == -1) page_text = illust_data.pageCount + " pages"; else page_text = "Page " + (this._page+1) + "/" + illust_data.pageCount; } set_info(".page-count", page_text); // Show info for the current page. If _page is -1 then we're on the search view and don't have // a specific page, so show info for the first page. let page = this._page; if(page == -1) page = 0; var info = ""; var page_info = illust_data.mangaPages[page]; info += page_info.width + "x" + page_info.height; set_info(".image-info", info); } } class main_context_menu extends popup_context_menu { // Return the singleton. static get get() { return main_context_menu._singleton; } constructor(container) { super(container); if(main_context_menu._singleton != null) throw "Singleton already exists"; main_context_menu._singleton = this; this.onwheel = this.onwheel.bind(this); this.onkeydown = this.onkeydown.bind(this); this._on_click_viewer = null; this._page = -1; // Refresh the menu when the view changes. this.mode_observer = new MutationObserver(function(mutationsList, observer) { for(var mutation of mutationsList) { if(mutation.type == "attributes") { if(mutation.attributeName == "data-current-view") this.refresh(); } } }.bind(this)); this.mode_observer.observe(document.body, { attributes: true, childList: false, subtree: false }); this.menu.querySelector(".button-return-to-search").addEventListener("click", this.clicked_return_to_search.bind(this)); this.menu.querySelector(".button-fullscreen").addEventListener("click", this.clicked_fullscreen.bind(this)); this.menu.querySelector(".button-zoom").addEventListener("click", this.clicked_zoom_toggle.bind(this)); this.menu.querySelector(".button-browser-back").addEventListener("click", (e) => { history.back(); }); 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, }); window.addEventListener("keydown", this.onkeydown); for(var button of this.menu.querySelectorAll(".button-zoom-level")) button.addEventListener("click", this.clicked_zoom_level.bind(this)); this.bookmark_tag_widget = new bookmark_tag_list_widget(this.menu.querySelector(".popup-bookmark-tag-dropdown-container")); this.toggle_tag_widget = new toggle_bookmark_tag_list_widget(this.menu.querySelector(".button-bookmark-tags"), this.bookmark_tag_widget); this.like_button = new like_button_widget(this.menu.querySelector(".button-like")); this.image_info_widget = new context_menu_image_info_widget(this.menu.querySelector(".context-menu-image-info")); this.avatar_widget = new avatar_widget({ parent: this.menu.querySelector(".avatar-widget-container"), mode: "overlay", }); // The bookmark buttons, and clicks in the tag dropdown: this.bookmark_buttons = []; for(var a of this.menu.querySelectorAll(".button-bookmark")) this.bookmark_buttons.push(new bookmark_button_widget(a, a.classList.contains("private"), this.bookmark_tag_widget)); this.element_bookmark_tag_list = this.menu.querySelector(".bookmark-tag-list"); this.refresh(); } // Return the illust ID active in the context menu, or null if none. // // If we're opened by right clicking on an illust, we'll show that image's // info. Otherwise, we'll show the info for the illust we're on, if any. get effective_illust_id() { if(this._clicked_illust_info != null) return this._clicked_illust_info.illustId; else return this._illust_id; } get effective_page() { if(this._clicked_page != null) return this._clicked_page; else return this._page; } // When the effective illust ID changes, let our widgets know. _effective_illust_id_changed() { // If we're not visible, don't refresh until we are, so we don't trigger // data loads. if(!this.visible) return; var illust_id = this.effective_illust_id; this.like_button.illust_id = illust_id; this.bookmark_tag_widget.illust_id = illust_id; this.toggle_tag_widget.illust_id = illust_id; for(var button of this.bookmark_buttons) button.illust_id = illust_id; this.image_info_widget.set_illust_and_page(this.effective_illust_id, this.effective_page); } set illust_id(value) { if(this._illust_id == value) return; this._illust_id = value; this._effective_illust_id_changed(); } set page(value) { if(this._page == value) return; this._page = value; this._effective_illust_id_changed(); } shutdown() { this.mode_observer.disconnect(); window.removeEventListener("wheel", this.onwheel, true); super.shutdown(); } // 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_info() { return this._user_info; } set user_info(user_info) { if(this._user_info == user_info) return; this._user_info = user_info; this.refresh(); } // Put the zoom toggle button under the cursor, so right-left click is a quick way // to toggle zoom lock. get element_to_center() { return this.displayed_menu.querySelector(".button-zoom"); } get _is_zoom_ui_enabled() { var view = document.body.dataset.currentView; return view == "illust" && this._on_click_viewer != null; } set_data_source(data_source) { if(this.data_source == data_source) return; this.data_source = data_source; this.refresh(); } onkeydown(e) { 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); } } } 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 control is pressed, or if the popup menu is visible. if(!e.ctrlKey && !this.visible) return; // We want to override almost all mousewheel events while the popup menu is open, but // don't override scrolling the popup menu's tag list. if(e.target.closest(".popup-bookmark-tag-dropdown")) return; e.preventDefault(); e.stopImmediatePropagation(); var down = e.deltaY > 0; this.handle_zoom_event(e, down); } // Handle both mousewheel and control-+/- zooming. handle_zoom_event(e, down) { e.preventDefault(); e.stopImmediatePropagation(); if(!this.hide_temporarily) { // Hide the popup menu. It remains open, so hide() will still be called when // the right mouse button is released and the overall flow remains unchanged, but // the popup itself will be hidden. this.hide_temporarily = true; } // If e is a keyboard event, use null to use the center of the screen. var keyboard = e instanceof KeyboardEvent; var pageX = keyboard? null:e.pageX; var pageY = keyboard? null:e.pageY; let center = this._on_click_viewer.get_image_position([pageX, pageY]); // If mousewheel zooming is used while not zoomed, turn on zooming and set // a 1x zoom factor, so we zoom relative to the previously unzoomed image. if(!this._on_click_viewer.zoom_active) { this._on_click_viewer.zoom_level = 0; this._on_click_viewer.locked_zoom = true; this.refresh(); } this._on_click_viewer.change_zoom(down); // As a special case, // If that put us in 0x zoom, we're now showing the image identically to not being zoomed // at all. That's confusing, since toggling zoom does nothing since it toggles between // unzoomed and an identical zoom. When this happens, switch zoom off and change the zoom // level to "cover". The display will be identical, but clicking will zoom. // // This works with the test above: if you zoom again after this happens, we'll turn locked_zoom // back on. if(this._on_click_viewer.zoom_level == 0) { // but this should leave locked_zoom false, which we don't want this._on_click_viewer.zoom_level = "cover"; this._on_click_viewer.locked_zoom = false; } this._on_click_viewer.set_image_position([pageX, pageY], center); this.refresh(); } show(x, y, target) { // 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) { var user_target = target.closest("[data-user-id]"); if(user_target != null) this._set_temporary_user(user_target.dataset.userId); var illust_target = target.closest("[data-illust-id]"); if(illust_target != null) this._set_temporary_illust(illust_target.dataset.illustId, illust_target.dataset.pageIdx); } super.show(x, y, target); // Make sure we're up to date if we deferred an update while hidden. this._effective_illust_id_changed(); } // Set an alternative illust ID to show. This is effective until the context menu is hidden. // This is used to remember what the cursor was over when the context menu was opened when in // the search view. async _set_temporary_illust(illust_id, page) { // If this object is null or changed, we know we've been hidden since we // started this request. var show_sentinel = this.load_illust_sentinel = new Object(); // Read illust info to see if we're following the user. var illust_info = await image_data.singleton().get_image_info(illust_id); // If the popup was closed while we were waiting, ignore the results. if(show_sentinel != this.load_illust_sentinel) return; this.load_illust_sentinel = null; if(page != null) page = parseInt(page); this._clicked_illust_info = illust_info; this._clicked_page = page; this._effective_illust_id_changed(); } // Set an alternative user ID to show. This is effective until the context menu is hidden. async _set_temporary_user(user_id) { // Clear the avatar widget while we load user info, so we don't show the previous // user's avatar while the new avatar loads. this.avatar_widget.set_from_user_data(null); // If this object is null or changed, we know we've been hidden since we // started this request. var show_sentinel = this.load_user_sentinel = new Object(); // Read user info to see if we're following the user. var user_info = await image_data.singleton().get_user_info(user_id); // If the popup was closed while we were waiting, ignore the results. if(show_sentinel != this.load_user_sentinel) return; this.load_user_sentinel = null; this._clicked_user_info = user_info; 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.load_illust_sentinel = null; this.load_user_sentinel = null; this._clicked_user_info = null; this._clicked_illust_info = null; this._clicked_page = null; // Even though we're hiding, update widgets so they don't show the last image's // bookmark count, etc. the next time we're displayed. this._effective_illust_id_changed(); super.hide(); } // Update selection highlight for the context menu. refresh() { var view = document.body.dataset.currentView; // Update the tooltip for the thumbnail toggle button. var navigate_out_label = main_controller.singleton.navigate_out_label; var title = navigate_out_label != null? ("Return to " + navigate_out_label):""; this.menu.querySelector(".button-return-to-search").dataset.popup = title; helpers.set_class(this.menu.querySelector(".button-return-to-search"), "enabled", navigate_out_label != null); this.refresh_tooltip(); // Enable the zoom buttons if we're in the image view and we have an on_click_viewer. for(var element of this.menu.querySelectorAll(".button.requires-zoom")) helpers.set_class(element, "enabled", this._is_zoom_ui_enabled); // Set the avatar button. this.avatar_widget.set_from_user_data(this._clicked_user_info || this._user_info); if(this._is_zoom_ui_enabled) { helpers.set_class(this.menu.querySelector(".button-zoom"), "selected", this._on_click_viewer.locked_zoom); var zoom_level = this._on_click_viewer.zoom_level; for(var button of this.menu.querySelectorAll(".button-zoom-level")) helpers.set_class(button, "selected", this._on_click_viewer.locked_zoom && button.dataset.level == zoom_level); } } clicked_return_to_search(e) { main_controller.singleton.navigate_out(); } clicked_fullscreen(e) { if(!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); } // "Zoom lock", zoom as if we're holding the button constantly clicked_zoom_toggle(e) { if(!this._is_zoom_ui_enabled) return; let center = this._on_click_viewer.get_image_position([e.pageX, e.pageY]); this._on_click_viewer.locked_zoom = !this._on_click_viewer.locked_zoom; this._on_click_viewer.set_image_position([e.pageX, e.pageY], center); this.refresh(); } clicked_zoom_level(e) { if(!this._is_zoom_ui_enabled) return; let level = e.currentTarget.dataset.level; // If the zoom level that's already selected is clicked and we're already zoomed, // just toggle zoom as if the toggle zoom button was pressed. if(this._on_click_viewer.zoom_level == level && this._on_click_viewer.locked_zoom) { this.on_click_viewer.locked_zoom = false; this.refresh(); return; } let center = this._on_click_viewer.get_image_position([e.pageX, e.pageY]); // Each zoom button enables zoom lock, since otherwise changing the zoom level would // only have an effect when click-dragging, so it looks like the buttons don't do anything. this._on_click_viewer.zoom_level = level; this._on_click_viewer.locked_zoom = true; this._on_click_viewer.set_image_position([e.pageX, e.pageY], center); this.refresh(); } } // Create an uncompressed ZIP from a list of files and filenames. 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", }); }; create_zip.prototype.create_local_file_header = function(filename, file, crc) { var data = struct(" 0) console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", ")); illust_ids = illust_ids.slice(); for(var new_id of ids_to_remove) { var idx = illust_ids.indexOf(new_id); illust_ids.splice(idx, 1); } // If there's nothing on this page, don't add it, so this doesn't increase // get_highest_loaded_page(). // FIXME: If we removed everything, the data source will appear to have reached the last // page and we won't load any more pages, since thumbnail_view assumes that a page not // returning any data means we're at the end. if(illust_ids.length == 0) return; this.illust_ids_by_page[page] = illust_ids; }; // Return the page number illust_id is on, or null if we don't know. get_page_for_illust(illust_id) { for(var page of Object.keys(this.illust_ids_by_page)) { var ids = this.illust_ids_by_page[page]; page = parseInt(page); if(ids.indexOf(illust_id) != -1) return page; }; return null; }; // Return the next or previous illustration. If we don't have that page, return null. // // This only returns illustrations, skipping over any special entries like user:12345. get_neighboring_illust_id(illust_id, next) { for(let i = 0; i < 100; ++i) // sanity limit { illust_id = this._get_neighboring_illust_id_internal(illust_id, next); if(illust_id == null) return null; // If it's not an illustration, keep looking. if(helpers.id_type(illust_id) == "illust") return illust_id; } return null; } // The actual logic for get_neighboring_illust_id, except for skipping entries. _get_neighboring_illust_id_internal(illust_id, next) { var page = this.get_page_for_illust(illust_id); if(page == null) return null; var ids = this.illust_ids_by_page[page]; var idx = ids.indexOf(illust_id); var new_idx = idx + (next? +1:-1); if(new_idx < 0) { // Return the last illustration on the previous page, or null if that page isn't loaded. var prev_page_no = page - 1; var prev_page_illust_ids = this.illust_ids_by_page[prev_page_no]; if(prev_page_illust_ids == null) return null; return prev_page_illust_ids[prev_page_illust_ids.length-1]; } else if(new_idx >= ids.length) { // Return the first illustration on the next page, or null if that page isn't loaded. var next_page_no = page + 1; var next_page_illust_ids = this.illust_ids_by_page[next_page_no]; if(next_page_illust_ids == null) return null; return next_page_illust_ids[0]; } else { return ids[new_idx]; } }; // Return the page we need to load to get the next or previous illustration. This only // makes sense if get_neighboring_illust returns null. get_page_for_neighboring_illust(illust_id, next) { var page = this.get_page_for_illust(illust_id); if(page == null) return null; var ids = this.illust_ids_by_page[page]; var idx = ids.indexOf(illust_id); var new_idx = idx + (next? +1:-1); if(new_idx >= 0 && new_idx < ids.length) return page; page += next? +1:-1; return page; }; // Return the first ID, or null if we don't have any. get_first_id() { var keys = Object.keys(this.illust_ids_by_page); if(keys.length == 0) return null; var page = keys[0]; return this.illust_ids_by_page[page][0]; } // Return true if the given page is loaded. is_page_loaded(page) { return this.illust_ids_by_page[page] != null; } }; // A data source asynchronously loads illust_ids to show. The callback will be called // with: // { // 'illust': { // illust_id1: illust_data1, // illust_id2: illust_data2, // ... // }, // illust_ids: [illust_id1, illust_id2, ...] // next: function, // } // // Some sources can retrieve user data, some can retrieve only illustration data, and // some can't retrieve anything but IDs. // // The callback will always be called asynchronously, and data_source.callback can be set // after creation. // // If "next" is included, it's a function that can be called to create a new data source // to load the next page of data. If there are no more pages, next will be null. // A data source handles a particular source of images, depending on what page we're // on: // // - Retrieves batches of image IDs to display, eg. a single page of bookmark results // - Load another page of results with load_more() // - Updates the page URL to reflect the current image // // Not all data sources have multiple pages. For example, when we're viewing a regular // illustration page, we get all of the author's other illust IDs at once, so we just // load all of them as a single page. class data_source { constructor(url) { this.url = new URL(url); this.id_list = new illust_id_list(); this.update_callbacks = []; this.loading_pages = {}; this.first_empty_page = -1; this.update_callbacks = []; // If this data source supports a start page, store the page we started on. // This isn't increased as we load more pages, but if we load earlier results // because the user clicks "load previous results", we'll reduce it. if(this.supports_start_page) { let args = helpers.get_args(url); this.initial_page = this.get_start_page(args); console.log("Starting at page", this.initial_page); } else this.initial_page = 1; }; // If a data source returns a name, we'll display any .data-source-specific elements in // the thumbnail view with that name. get name() { return null; } // Most data sources are for illustrations. This is set to "users" for the followed view. get search_mode() { return "illusts"; } // Return a canonical URL for this data source. If the canonical URL is the same, // the same instance of the data source should be used. // // A single data source is used eg. for a particular search and search flags. If // flags are changed, such as changing filters, a new data source instance is created. // However, some parts of the URL don't cause a new data source to be used. Return // a URL with all unrelated parts removed, and with query and hash parameters sorted // alphabetically. // // Due to some quirkiness in data_source_current_illust, this is async. static async get_canonical_url(url) { // Make a copy of the URL. var url = new URL(url); this.remove_ignored_url_parts(url); // Sort query parameters. We don't use multiple parameters with the same key. url.search = helpers.sort_query_parameters(url.searchParams).toString(); // Sort hash parameters. var new_hash = helpers.sort_query_parameters(helpers.get_hash_args(url)); helpers.set_hash_args(url, new_hash); return url.toString(); } // This is overridden by subclasses to remove parts of the URL that don't affect // which data source instance is used. static remove_ignored_url_parts(url) { // If p=1 is in the query, it's the page number, which doesn't affect the data source. url.searchParams.delete("p"); var hash_args = helpers.get_hash_args(url); // #x=1 is a workaround for iframe loading. hash_args.delete("x"); // The manga page doesn't affect the data source. hash_args.delete("page"); // #view=thumbs controls which view is active. hash_args.delete("view"); // illust_id in the hash is always just telling us which image within the current // data source to view. data_source_current_illust is different and is handled in // the subclass. hash_args.delete("illust_id"); // Any illust_id in the search or the hash doesn't require a new data source. // bluh // but the user underneath it does helpers.set_hash_args(url, hash_args); } // 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) { var result = this.loading_pages[page]; if(result == null) { // console.log("started loading page", page); var result = this._load_page_async(page); this.loading_pages[page] = result; result.finally(() => { // console.log("finished loading page", page); delete this.loading_pages[page]; }); } return result; } // Return true if the given page is either loaded, or currently being loaded by a call to load_page. is_page_loaded_or_loading(page) { if(this.id_list.is_page_loaded(page)) return true; if(this.loading_pages[page]) return true; return false; } // Return true if any page is currently loading. get any_page_loading() { for(let page in this.loading_pages) if(this.loading_pages[page]) return true; return false; } async _load_page_async(page) { // 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) { console.info("No pages after", 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; // Start the actual load. var result = await this.load_page_internal(page); // Reduce the start page, which will update the "load more results" button if any. It's important // to do this after the await above. If we do it before, it'll update the button before we load // and cause the button to update before the thumbs. view_search.refresh_images won't be able // to optimize that and it'll cause uglier refreshes. if(this.supports_start_page && page < this.initial_page) this.initial_page = page; // If there were no results, then we've loaded the last page. Don't try to load // any pages beyond this. if(this.id_list.illust_ids_by_page[page] == null) { console.log("No data on page", page); if(this.first_empty_page == -1 || page < this.first_empty_page) this.first_empty_page = page; }; return true; } // Return the illust_id to display by default. // // This should only be called after the initial data is loaded. get_current_illust_id() { // If we have an explicit illust_id in the hash, use it. Note that some pages (in // particular illustration pages) put this in the query, which is handled in the particular // data source. var hash_args = helpers.get_hash_args(document.location); if(hash_args.has("illust_id")) return hash_args.get("illust_id"); return this.id_list.get_first_id(); }; // Return the page title to use. get page_title() { return "Pixiv"; } // This is implemented by the subclass. async load_page_internal(page) { throw "Not implemented"; } // Return true if page is an available page (not past the end). // // We'll always stop if we read a page and it's empty. This allows the extra // last request to be avoided if we know the last page earlier. load_page_available(page) { return true; } // This is called when the currently displayed illust_id changes. The illust_id should // always have been loaded by this data source, so it should be in id_list. The data // source should update the history state to reflect the current state. set_current_illust_id(illust_id, args) { if(this.supports_start_page) { // Store the page the illustration is on in the hash, so if the page is reloaded while // we're showing an illustration, we'll start on that page. If we don't do this and // the user clicks something that came from page 6 while the top of the search results // were on page 5, we'll start the search at page 5 if the page is reloaded and not find // the image, which is confusing. var original_page = this.id_list.get_page_for_illust(illust_id); if(original_page != null) this.set_start_page(args, original_page); } // By default, put the illust_id in the hash. args.hash.set("illust_id", illust_id); } // Return the estimated number of items per page. This is used to pad the thumbnail // list to reduce items moving around when we load pages. get estimated_items_per_page() { return 10; }; // Return the view that should be displayed by default, if no "view" field is in the URL. get default_view() { 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; }; // Some data sources can restart the search at a page. get supports_start_page() { return false; } // Store the current page in the URL. // // This is only used if supports_start_page is true. set_start_page(args, page) { args.query.set("p", page); } get_start_page(args) { let page = args.query.get("p") || "1"; return parseInt(page) || 1; } // Add or remove an update listener. These are called when the data source has new data, // or wants a UI refresh to happen. add_update_listener(callback) { this.update_callbacks.push(callback); } remove_update_listener(callback) { var idx = this.update_callbacks.indexOf(callback); if(idx != -1) this.update_callbacks.splice(idx); } // Register a page of data. add_page(page, illust_ids) { this.id_list.add_page(page, illust_ids); // Call update listeners asynchronously to let them know we have more data. setTimeout(function() { this.call_update_listeners(); }.bind(this), 0); } call_update_listeners() { var callbacks = this.update_callbacks.slice(); for(var callback of callbacks) { try { callback(); } catch(e) { console.error(e); } } } // Refresh parts of the UI that are specific to this data source. This is only called // when first activating a data source, to update things like input fields that shouldn't // be overwritten on each refresh. initial_refresh_thumbnail_ui(container, view) { } // Each data source can have a different UI in the thumbnail view. container is // the thumbnail-ui-box container to refresh. refresh_thumbnail_ui(container, view) { } // A helper for setting up UI links. Find the link with the given data-type, // set all {key: value} entries as query parameters, and remove any query parameters // where value is null. Set .selected if the resulting URL matches the current one. // // If default_values is present, it tells us the default key that will be used if // a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting // s_mode. We prefer to omit it rather than clutter the URL with defaults, but we // need to know this to figure out whether an item is selected or not. // // If a key begins with #, it's placed in the hash rather than the query. set_item(container, type, fields, default_values) { var link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } // This button is selected if all of the keys it sets are present in the URL. var button_is_selected = true; // Adjust the URL for this button. var url = new URL(document.location); // Don't include the page number in search buttons, so clicking a filter goes // back to page 1. url.searchParams.delete("p"); var hash_args = helpers.get_hash_args(url); for(var key of Object.keys(fields)) { var original_key = key; var value = fields[key]; // If key begins with "#", it means it goes in the hash. var hash = key.startsWith("#"); if(hash) key = key.substr(1); var params = hash? hash_args:url.searchParams; // The value we're setting in the URL: var this_value = value; if(this_value == null && default_values != null) this_value = default_values[original_key]; // The value currently in the URL: var selected_value = params.get(key); if(selected_value == null && default_values != null) selected_value = default_values[original_key]; // If the URL didn't have the key we're setting, then it isn't selected. if(this_value != selected_value) button_is_selected = false; // If the value we're setting is the default, delete it instead. if(default_values != null && this_value == default_values[original_key]) value = null; if(value != null) params.set(key, value); else params.delete(key); } helpers.set_hash_args(url, hash_args); helpers.set_class(link, "selected", button_is_selected); link.href = url.toString(); }; // Like set_item for query and hash parameters, this sets parameters in the URL. // // Pixiv used to have clean, consistent URLs with page parameters in the query where // they belong, but recently they've started encoding them in an ad hoc way into the // path. For example, what used to look like "/users/12345?type=illust" is now // "/users/12345/illustrations", so they can't be accessed in a generic way. // // index is the index into the path to replace. In "/users/12345/abcd", "users" is // 0 and "abcd" is 2. If the index doesn't exist, the path will be extended, so // replacing index 2 in "/users/12345" will become "/users/12345/abcd". This only // makes sense when adding a single entry. // // Pixiv URLs can optionally have the language prefixed (which doesn't make sense). // This is handled automatically by get_path_part and set_path_part, and index should // always be for URLs without the language. set_path_item(container, type, index, value) { let link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } // Adjust the URL for this button. let url = new URL(this.url); // Don't include the page number in search buttons, so clicking a filter goes // back to page 1. url.searchParams.delete("p"); // This button is selected if the given value was already set. let button_is_selected = helpers.get_path_part(url, index) == value; // Replace the path part. url = helpers.set_path_part(url, index, value); helpers.set_class(link, "selected", button_is_selected); link.href = url.toString(); }; // Highlight search menu popups if any entry other than the default in them is // selected. // // selector_list is a list of selectors for each menu item. If any of them are // selected and don't have the data-default attribute, set .active on the popup. // Search filters // Set the active class on all top-level dropdowns which have something other than // the default selected. set_active_popup_highlight(container, selector_list) { for(var popup of selector_list) { var box = container.querySelector(popup); var selected_item = box.querySelector(".selected"); if(selected_item == null) { // There's no selected item. If there's no default item then this is normal, but if // there's a default item, it should have been selected by default, so this is probably // a bug. var default_entry_exists = box.querySelector("[data-default]") != null; if(default_entry_exists) console.warn("Popup", popup, "has no selection"); continue; } var selected_default = selected_item.dataset["default"]; helpers.set_class(box, "active", !selected_default); // Find the dropdown menu button. let menu_button = box.querySelector(".menu-button"); if(menu_button == null) { console.warn("Couldn't find menu button for " + box); continue; } // Store the original text, so we can restore it when the default is selected. if(menu_button.dataset.originalText == null) menu_button.dataset.originalText = menu_button.innerText; // If an option is selected, replace the menu button text with the selection's label. if(selected_default) menu_button.innerText = menu_button.dataset.originalText; else { // The short label is used to try to keep these labels from causing the menu buttons to // overflow the container, and for labels like "2 years ago" where the menu text doesn't // make sense. let label = selected_item.dataset.shortLabel; menu_button.innerText = label? label:selected_item.innerText; } } } // Return true of the thumbnail view should show bookmark icons for this source. get show_bookmark_icons() { return true; } }; // 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 { get estimated_items_per_page() { return 30; } async load_page_internal(page) { if(this.pages == null) { var illust_ids = await this.load_all_results(); this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page); } // Register this page. var illust_ids = this.pages[page-1] || []; this.add_page(page, illust_ids); } // Implemented by the subclass. Load all results, and return the resulting IDs. async load_all_results() { throw "Not implemented"; } } // /discovery // // This is an actual API call for once, so we don't need to scrape HTML. We only show // recommended works (we don't have a user view list). // // The API call returns 1000 entries. We don't do pagination, we just show the 1000 entries // and then stop. I haven't checked to see if the API supports returning further pages. class data_source_discovery extends data_source_fake_pagination { get name() { return "discovery"; } // Implement data_source_fake_pagination: async load_all_results() { // Get "mode" from the URL. If it's not present, use "all". var query_args = this.url.searchParams; var mode = query_args.get("mode") || "all"; var data = { type: "illust", sample_illusts: "auto", num_recommendations: 1000, page: "discovery", mode: mode, }; var result = await helpers.get_request("/rpc/recommender.php", data); // Unlike other APIs, this one returns IDs as ints rather than strings. Convert back // to strings. var illust_ids = []; for(var illust_id of result.recommendations) illust_ids.push(illust_id + ""); return illust_ids; }; get page_title() { return "Discovery"; } get_displaying_text() { return "Recommended Works"; } refresh_thumbnail_ui(container) { // Set .selected on the current mode. var current_mode = new URL(document.location).searchParams.get("mode") || "all"; helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all"); helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe"); helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18"); } } // 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. class data_source_discovery_users extends data_source { get name() { return "discovery_users"; } // The constructor receives the original HTMLDocument. constructor(url, doc) { super(url); var hash_args = helpers.get_hash_args(this.url); let user_id = hash_args.get("user_id"); if(user_id != null) { this.showing_user_id = user_id; this.sample_user_ids = [user_id] } else this.sample_user_ids = null; this.original_doc = doc; this.original_url = url; this.seen_user_ids = {}; } // Return true if the two URLs refer to the same data. is_same_page(url1, url2) { var cleanup_url = function(url) { var url = new URL(url); // Any "x" parameter is a dummy that we set to force the iframe to load, so ignore // it here. url.searchParams.delete("x"); // The hash doesn't affect the page that we load. url.hash = ""; return url.toString(); }; var url1 = cleanup_url(url1); var url2 = cleanup_url(url2); return url1 == url2; } // We can always return another page. load_page_available(page) { return true; } 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(); } // Find the sample user IDs we need to request suggestions. await this.load_sample_user_ids(); var data = { mode: "get_recommend_users_and_works_by_user_ids", user_ids: this.sample_user_ids.join(","), user_num: 30, work_num: 5, }; // Get suggestions. Each entry is a user, and contains info about a small selection of // images. var result = await helpers.get_request("/rpc/index.php", data); if(result.error) throw "Error reading suggestions: " + result.message; // Convert the images into thumbnail_info. Like everything else, this is returned in a format // slightly different from the other APIs that it's similar to. let illust_ids = []; for(let user of result.body) { // 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.user_id]) continue; this.seen_user_ids[user.user_id] = true; // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data(user, "recommendations"); illust_ids.push("user:" + user.user_id); for(let illust_data of user.illusts) illust_ids.push(illust_data.illust_id); } // Register the new page of data. this.add_page(page, illust_ids); } // Read /discovery/users and set sample_user_ids from userRecommendSampleUser. async load_sample_user_ids() { if(this.sample_user_ids) return; // Work around a browser issue: loading an iframe with the same URL as the current page doesn't // work. (This might have made sense once upon a time when it would always recurse, but today // this doesn't make sense.) Just add a dummy query to the URL to make sure it's different. // // This usually doesn't happen, since we'll normally use this.original_doc if we're reading // the same page. Skip it if it's not needed, so we don't throw weird URLs at the site if // we don't have to. var url = new unsafeWindow.URL(this.original_url); if(this.is_same_page(url, this.original_url)) url.searchParams.set("x", 1); // If the underlying page isn't /discovery/users, load it in an iframe to get some data. let doc = this.original_doc; if(this.original_doc == null || !this.is_same_page(url, this.original_url)) { console.log("Loading:", url.toString()); doc = await helpers.load_data_in_iframe(url.toString()); } // Look for: // // let sample_user_script = null; for(let script of doc.querySelectorAll("script")) { let text = script.innerText; if(!text.startsWith("pixiv.context.userRecommendSampleUser")) continue; sample_user_script = script.innerText; break; } if(sample_user_script == null) throw "Couldn't find userRecommendSampleUser"; // Pull out the list, and turn it into a JSON array to parse it. let match = sample_user_script.match(/pixiv.context.userRecommendSampleUser = "(.*)";/); if(match == null) throw "Couldn't parse userRecommendSampleUser: " + sample_user_scripts; this.sample_user_ids = JSON.parse("[" + match[1] + "]"); console.log("Sample user IDs:", this.sample_user_ids); } 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) { } }; // bookmark_detail.php (with recommendations=1 in the hash) // // We use this as an anchor page for viewing recommended illusts for an image, since // there's no dedicated page for this. // // This returns a big chunk of results in one call, so we use data_source_fake_pagination // to break it up. class data_source_related_illusts extends data_source_fake_pagination { get name() { return "related-illusts"; } async _load_page_async(page) { // 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. var query_args = this.url.searchParams; var illust_id = query_args.get("illust_id"); image_data.singleton().get_image_info(illust_id).then((illust_info) => { this.illust_info = illust_info; this.call_update_listeners(); }).catch((e) => { console.error(e); }); } return await super._load_page_async(page); } // Implement data_source_fake_pagination: async load_all_results() { var query_args = this.url.searchParams; var illust_id = query_args.get("illust_id"); var data = { type: "illust", sample_illusts: illust_id, num_recommendations: 1000, }; var result = await helpers.get_request("/rpc/recommender.php", data); // Unlike other APIs, this one returns IDs as ints rather than strings. Convert back // to strings. var illust_ids = []; for(var illust_id of result.recommendations) illust_ids.push(illust_id + ""); return illust_ids; }; get page_title() { return "Similar Illusts"; } get_displaying_text() { return "Similar Illustrations"; } refresh_thumbnail_ui(container) { // Set the source image. var source_link = container.querySelector(".image-for-suggestions"); source_link.hidden = this.illust_info == null; if(this.illust_info) { source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv"; var img = source_link.querySelector(".image-for-suggestions > img"); img.src = this.illust_info.urls.thumb; } } } // /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. class data_source_rankings extends data_source { constructor(url) { super(url); this.max_page = 999999; } get name() { return "rankings"; } load_page_available(page) { return page <= this.max_page; } async load_page_internal(page) { /* "mode": "daily", "content": "all", "page": 1, "prev": false, "next": 2, "date": "20180923", "prev_date": "20180922", "next_date": false, "rank_total": 500 */ // Get "mode" from the URL. If it's not present, use "all". var query_args = this.url.searchParams; var data = { format: "json", p: page, }; var date = query_args.get("date"); if(date) data.date = date; var content = query_args.get("content"); if(content) data.content = content; var mode = query_args.get("mode"); if(mode) data.mode = mode; var result = await helpers.get_request("/ranking.php", data); // If "next" is false, this is the last page. if(!result.next) this.max_page = Math.min(page, this.max_page); // Fill in the next/prev dates for the navigation buttons, and the currently // displayed date. if(this.today_text == null) { this.today_text = result.date; // This is "YYYYMMDD". Reformat it. if(this.today_text.length == 8) { var year = this.today_text.slice(0,4); var month = this.today_text.slice(4,6); var day = this.today_text.slice(6,8); this.today_text = year + "/" + month + "/" + day; } } if(this.prev_date == null && result.prev_date) this.prev_date = result.prev_date; if(this.next_date == null && result.next_date) this.next_date = result.next_date; // This returns a struct of data that's like the thumbnails data response, // but it's not quite the same. var illust_ids = []; for(var item of result.contents) { // Most APIs return IDs as strings, but this one returns them as ints. // Convert them to strings. var illust_id = "" + item.illust_id; var user_id = "" + item.user_id; illust_ids.push(illust_id); } // Register this as thumbnail data. thumbnail_data.singleton().loaded_thumbnail_info(result.contents, "rankings"); // Register the new page of data. this.add_page(page, illust_ids); }; get estimated_items_per_page() { return 50; } get page_title() { return "Rankings"; } get_displaying_text() { return "Rankings"; } refresh_thumbnail_ui(container) { var query_args = this.url.searchParams; this.set_item(container, "content-all", {content: null}); this.set_item(container, "content-illust", {content: "illust"}); this.set_item(container, "content-ugoira", {content: "ugoira"}); this.set_item(container, "content-manga", {content: "manga"}); this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"}); this.set_item(container, "mode-daily-r18", {mode: "daily_r18"}); this.set_item(container, "mode-r18g", {mode: "r18g"}); this.set_item(container, "mode-weekly", {mode: "weekly"}); this.set_item(container, "mode-monthly", {mode: "monthly"}); this.set_item(container, "mode-rookie", {mode: "rookie"}); this.set_item(container, "mode-original", {mode: "original"}); this.set_item(container, "mode-male", {mode: "male"}); this.set_item(container, "mode-female", {mode: "female"}); if(this.today_text) container.querySelector(".nav-today").innerText = this.today_text; // This UI is greyed rather than hidden before we have the dates, so the UI doesn't // shift around as we load. var yesterday = container.querySelector(".nav-yesterday"); helpers.set_class(yesterday.querySelector(".box-link"), "disabled", this.prev_date == null); if(this.prev_date) { var url = new URL(window.location); url.searchParams.set("date", this.prev_date); yesterday.querySelector("a").href = url; } var tomorrow = container.querySelector(".nav-tomorrow"); helpers.set_class(tomorrow.querySelector(".box-link"), "disabled", this.next_date == null); if(this.next_date) { var url = new URL(window.location); url.searchParams.set("date", this.next_date); tomorrow.querySelector("a").href = url; } // Not all combinations of content and mode exist. For example, there's no ugoira // monthly, and we'll get an error page if we load it. Hide navigations that aren't // available. This isn't perfect: if you want to choose ugoira when you're on monthly // you need to select a different time range first. We could have the content links // switch to daily if not available... var available_combinations = [ "all/daily", "all/daily_r18", "all/r18g", "all/weekly", "all/monthly", "all/rookie", "all/original", "all/male", "all/female", "illust/daily", "illust/daily_r18", "illust/r18g", "illust/weekly", "illust/monthly", "illust/rookie", "ugoira/daily", "ugoira/weekly", "ugoira/daily_r18", "manga/daily", "manga/daily_r18", "manga/r18g", "manga/weekly", "manga/monthly", "manga/rookie", ]; // Check each link in both checked-links sections. for(var a of container.querySelectorAll(".checked-links a")) { var url = new URL(a.href, document.location); 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. // // This wouldn't be needed if we could access the mobile APIs, but for some reason those // use different authentication tokens and can't be accessed from the website. // // 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 { // The constructor receives the original HTMLDocument. constructor(url, doc) { super(url); if(url == null) throw "url can't be null"; this.original_doc = doc; this.items_per_page = 1; // Remember the URL that original_doc came from. this.original_url = url; } // Return true if the two URLs refer to the same data. is_same_page(url1, url2) { var cleanup_url = function(url) { var url = new URL(url); // p=1 and no page at all is the same. Remove p=1 so they compare the same. if(url.searchParams.get("p") == "1") url.searchParams.delete("p"); // Any "x" parameter is a dummy that we set to force the iframe to load, so ignore // it here. url.searchParams.delete("x"); // The hash doesn't affect the page that we load. url.hash = ""; return url.toString(); }; var url1 = cleanup_url(url1); var url2 = cleanup_url(url2); return url1 == url2; } load_page_available(page) { return true; } 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.original_url); // Update the URL with the current page. var params = url.searchParams; params.set("p", page); if(this.original_doc != null && this.is_same_page(url, this.original_url)) { this.finished_loading_illust(page, this.original_doc); return true; } // Work around a browser issue: loading an iframe with the same URL as the current page doesn't // work. (This might have made sense once upon a time when it would always recurse, but today // this doesn't make sense.) Just add a dummy query to the URL to make sure it's different. // // This usually doesn't happen, since we'll normally use this.original_doc if we're reading // the same page. Skip it if it's not needed, so we don't throw weird URLs at the site if // we don't have to. if(this.is_same_page(url, this.original_url)) params.set("x", 1); url.search = params.toString(); console.log("Loading:", url.toString()); var doc = await helpers.load_data_in_iframe(url.toString()); this.finished_loading_illust(page, doc); }; get estimated_items_per_page() { return this.items_per_page; } // We finished loading a page. Parse it and register the results. finished_loading_illust(page, doc) { var illust_ids = this.parse_document(doc); if(illust_ids == null) { // The most common case of there being no data in the document is loading // a deleted illustration. See if we can find an error message. console.error("No data on page"); var error = doc.querySelector(".error-message"); var error_message = "Error loading page"; if(error != null) error_message = error.textContent; message_widget.singleton.show(error_message); message_widget.singleton.clear_timer(); return; } // Assume that if the first request returns 10 items, all future pages will too. This // is usually correct unless we happen to load the last page last. Allow this to increase // in case that happens. (This is only used by the thumbnail view.) if(this.items_per_page == 1) this.items_per_page = Math.max(illust_ids.length, this.items_per_page); // Register the new page of data. this.add_page(page, illust_ids); } // Parse the loaded document and return the illust_ids. parse_document(doc) { throw "Not implemented"; } }; // There are two ways we can show images for a user: from an illustration page // (/artworks/#), or from the user's works page (/users/#). // // The illustration page is better, since it gives us the ID of every post by the // user, so we don't have to fetch them page by page, but we have to know the ID // of a post to get to to that. It's also handy because we can tell where we are // in the list from the illustration ID without having to know which page we're on, // the page has the user info encoded (so we don't have to request it separately, // making loads faster), and if we're going to display a specific illustration, we // don't need to request it separately either. // // However, we can only do searching and filtering on the user page, and that's // where we land when we load a link to the user. class data_source_artist extends data_source { get name() { return "artist"; } constructor(url) { super(url); } 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 poopulate the tags // dropdown if it's opened, so we don't load user tags for every user page. var popup = document.body.querySelector(".member-tags-box > .popup-menu-box"); this.src_observer = new MutationObserver((mutation_list) => { if(popup.classList.contains("popup-visible")) this.tag_list_opened(); }); this.src_observer.observe(popup, { attributes: true }); } shutdown() { super.shutdown(); // Remove our MutationObserver. this.src_observer.disconnect(); this.src_observer = null; } // Return "artworks" (all), "illustrations" or "manga". get viewing_type() { // The URL is one of: // // /users/12345 // /users/12345/artworks // /users/12345/illustrations // /users/12345/manga // // The top /users/12345 page is the user's profile page, which has the first page of images, but // instead of having a link to page 2, it only has "See all", which goes to /artworks and shows you // page 1 again. That's pointless, so we treat the top page as /artworks the same. /illustrations // and /manga filter those types. let url = helpers.get_url_without_language(this.url); let parts = url.pathname.split("/"); return parts[3] || "artworks"; } async load_page_internal(page) { let viewing_type = this.type; // Make sure the user info is loaded. This should normally be preloaded by globalInitData // in main.js, and this won't make a request. this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); // Update to refresh our page title, which uses user_info. this.call_update_listeners(); var query_args = this.url.searchParams; var hash_args = helpers.get_hash_args(this.url); var tag = query_args.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) { var illust_ids = await this.load_all_results(); this.pages = paginate_illust_ids(illust_ids, this.estimated_items_per_page); } // Register this page. var illust_ids = this.pages[page-1] || []; this.add_page(page, illust_ids); } else { // We're filtering by tag. var type = query_args.get("type"); // For some reason, this API uses a random field in the URL for the type instead of a normal // query parameter. var type_for_url = type == null? "illustmanga": type == "illust"?"illusts": "manga"; var request_url = "/ajax/user/" + this.viewing_user_id + "/" + type_for_url + "/tag"; var result = await helpers.get_request(request_url, { tag: tag, offset: (page-1)*48, limit: 48, }); // This data doesn't have profileImageUrl or userName. That's presumably because it's // used on user pages which get that from user data, but this seems like more of an // inconsistency than an optimization. Fill it in for thumbnail_data. for(var item of result.body.works) { item.userName = this.user_info.name; item.profileImageUrl = this.user_info.imageBig; } var illust_ids = []; for(var illust_data of result.body.works) illust_ids.push(illust_data.id); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } } async load_all_results() { this.call_update_listeners(); var query_args = this.url.searchParams; let type = this.viewing_type; var result = await helpers.get_request("/ajax/user/" + this.viewing_user_id + "/profile/all", {}); console.log(type, "got", result); var illust_ids = []; if(type == "artworks" || type == "illustrations") for(var illust_id in result.body.illusts) illust_ids.push(illust_id); if(type == "artworks" || type == "manga") for(var illust_id in result.body.manga) illust_ids.push(illust_id); // Sort the two sets of IDs back together, putting higher (newer) IDs first. illust_ids.sort(function(lhs, rhs) { return parseInt(rhs) - parseInt(lhs); }); return illust_ids; }; refresh_thumbnail_ui(container, thumbnail_view) { if(this.user_info) { thumbnail_view.avatar_widget.set_from_user_data(this.user_info); } let viewing_type = this.viewing_type; let url = new URL(this.url); this.set_path_item(container, "artist-works", 2, ""); this.set_path_item(container, "artist-illust", 2, "illustrations"); this.set_path_item(container, "artist-manga", 2, "manga"); // Refresh the post tag list. var query_args = this.url.searchParams; var current_query = query_args.toString(); var tag_list = container.querySelector(".post-tag-list"); helpers.remove_elements(tag_list); var add_tag_link = function(tag) { var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = tag; var url = new URL(document.location); url.hash = "#ppixiv"; if(tag != "All") url.searchParams.set("tag", tag); else { url.searchParams.delete("tag"); a.dataset["default"] = 1; } a.href = url.toString(); if(url.searchParams.toString() == current_query) a.classList.add("selected"); tag_list.appendChild(a); }; if(this.post_tags != null) { add_tag_link("All"); for(var tag of this.post_tags || []) add_tag_link(tag); } else { // Tags aren't loaded yet. We'll be refreshed after tag_list_opened loads tags. var span = document.createElement("span"); span.innerText = "Loading..."; tag_list.appendChild(span); } // Set whether the tags menu item is highlighted. We don't use set_active_popup_highlight // here so we don't need to load the tag list. var box = container.querySelector(".member-tags-box"); helpers.set_class(box, "active", query_args.has("tag")); } // This is called when the tag list dropdown is opened. async tag_list_opened() { // Only do this once. if(this.loaded_tags) { console.log("already loaded"); return; } this.loaded_tags = true; // 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); // If we became inactive before the above request finished, stop. if(!this.active) return; // Trigger refresh_thumbnail_ui to fill in tags. this.call_update_listeners(); } async get_user_tags(user_info) { if(user_info.frequentTags) return user_info.frequentTags; var result = await helpers.get_request("https://www.pixiv.net/ajax/user/" + user_info.userId + "/illustmanga/tags", {}); if(result.error) { console.error("Error fetching tags for user " + user_info.userId + ": " + result.error); user_info.frequentTags = []; return user_info.frequentTags; } // Sort most frequent tags first. result.body.sort(function(lhs, rhs) { return rhs.cnt - lhs.cnt; }) var tags = []; for(var tag_info of result.body) tags.push(tag_info.tag); // Cache the results on the user info. user_info.frequentTags = tags; return tags; } 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"; }; } // Viewing a single illustration. // // This page gives us all of the user's illustration IDs, so we can treat this as // a data source for a user without having to make separate requests. // // This reads data from a page, but we don't use data_source_from_page here. We // don't need its pagination logic, and we do want to have pagination from data_source_fake_pagination. class data_source_current_illust extends data_source_fake_pagination { get name() { return "illust"; } // The constructor receives the original HTMLDocument. constructor(url, doc) { super(url); this.original_doc = doc; this.original_url = url; } // Show the illustration by default. get default_view() { return "illust"; } // Implement data_source_fake_pagination: async load_all_results() { if(this.original_doc != null) return this.load_all_results_from(this.original_doc); var url = new unsafeWindow.URL(this.original_url); // Work around browsers not loading the iframe properly when it has the same URL. url.searchParams.set("x", 1); console.log("Loading:", url.toString()); var doc = await helpers.load_data_in_iframe(url.toString()); return this.load_all_results_from(doc); }; load_all_results_from(doc) { var illust_ids = this.parse_document(doc); if(illust_ids != null) return illust_ids; // 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"); var error = doc.querySelector(".error-message"); var error_message = "Error loading page"; if(error != null) error_message = error.textContent; message_widget.singleton.show(error_message); message_widget.singleton.clear_timer(); return []; } get_preload_data(doc) { let preload = doc.querySelector("#meta-preload-data"); if(preload == null) return null; preload = JSON.parse(preload.getAttribute("content")); return preload; } parse_document(doc) { let preload = this.get_preload_data(doc); if(preload == null) { console.error("Couldn't find globalInitData"); return; } var illust_id = Object.keys(preload.illust)[0]; var user_id = Object.keys(preload.user)[0]; this.user_info = preload.user[user_id]; var this_illust_data = preload.illust[illust_id]; // Stash the user data so we can use it in get_displaying_text. this.user_info = preload.user[user_id]; // Add the image list. var illust_ids = []; for(var related_illust_id in this_illust_data.userIllusts) { if(related_illust_id == illust_id) continue; illust_ids.push(related_illust_id); } // Make sure our illustration is in the list. if(illust_ids.indexOf(illust_id) == -1) illust_ids.push(illust_id); // Sort newest first. illust_ids.sort(function(a,b) { return b-a; }); return illust_ids; }; // Unlike most data_source_from_page implementations, we only have a single page. get_current_illust_id() { // /artworks/# let url = new URL(document.location); url = helpers.get_url_without_language(url); let parts = url.pathname.split("/"); var illust_id = parts[2]; return illust_id; }; // data_source_current_illust is tricky. Since it returns posts by the user // of an image, we remove the illust_id (since two images with the same user // can use the same data source), and add the user ID. // // This requires that get_canonical_url be asynchronous, since we might need // to load the image info. static async get_canonical_url(url, callback) { var url = new URL(url); url = helpers.get_url_without_language(url); // /artworks/# let parts = url.pathname.split("/"); var illust_id = parts[2]; var illust_info = await image_data.singleton().get_image_info(illust_id); var hash_args = helpers.get_hash_args(url); hash_args.set("user_id", illust_info.userId); helpers.set_hash_args(url, hash_args); // Remove the illustration ID. url.pathname = "/artworks"; return await data_source.get_canonical_url(url); } // Unlike most data sources, data_source_current_illust puts the illust_id // in the path rather than the hash. set_current_illust_id(illust_id, args) { // Pixiv's inconsistent URLs are annoying. Figure out where the ID field is. // If the first field is a language, it's the third field (/en/artworks/#), otherwise // it's the second (/artworks/#). let parts = args.path.split("/"); let id_part = parts[1].length == 2? 3:2; parts[id_part] = illust_id; args.path = parts.join("/"); }; get page_title() { if(this.user_info) return this.user_info.name; else return "Illustrations"; } get_displaying_text() { if(this.user_info) return this.user_info.name + "'s Illustrations"; else return "Illustrations"; }; refresh_thumbnail_ui(container, thumbnail_view) { if(this.user_info) { thumbnail_view.avatar_widget.set_from_user_data(this.user_info); } } get page_title() { if(this.user_info) return this.user_info.name; else return "Illustrations"; } get viewing_user_id() { if(this.user_info == null) return null; return this.user_info.userId; }; }; // bookmark.php // /users/12345/bookmarks // // If id is in the query, we're viewing another user's bookmarks. Otherwise, we're // viewing our own. // // Pixiv currently serves two unrelated pages for this URL, using an API-driven one // for viewing someone else's bookmarks and a static page for viewing your own. We // always use the API in either case. // // For some reason, Pixiv only allows viewing either public or private bookmarks, // and has no way to just view all bookmarks. class data_source_bookmarks_base extends data_source { get name() { return "bookmarks"; } constructor(url) { super(url); this.bookmark_tag_counts = []; } // Return the bookmark types we're showing: "public", "private" or "both". get public_search_mode() { throw "Not implemented"; } async load_page_internal(page) { this.fetch_bookmark_tag_counts(); // Make sure the user info is loaded. This should normally be preloaded by globalInitData // in main.js, and this won't make a request. var user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); this.user_info = user_info; this.call_update_listeners(); await this.continue_loading_page_internal(page); }; get supports_start_page() { return true; } // 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; // Fetch bookmark tags. We can do this in parallel with everything else. var url = "https://www.pixiv.net/ajax/user/" + this.viewing_user_id + "/illusts/bookmark/tags"; var result = await helpers.get_request(url, {}); let search_mode = this.public_search_mode; // Reformat the tag list into a format that's easier to work with. let tags = { }; let add_tag = function(tag, public_tag) { // Rename "未分類" (uncategorized) to "". if(tag.tag == "未分類") tag.tag = ""; if(tags[tag.tag] == null) tags[tag.tag] = 0; // Add to the tag count. let tag_count = parseInt(bookmark_tag.cnt); if(search_mode == "public" && public_tag) tags[tag.tag] += tag_count; if(search_mode == "private" && !public_tag) tags[tag.tag] += tag_count; if(search_mode == "both") tags[tag.tag] += tag_count; }; for(var bookmark_tag of result.body.public) add_tag(bookmark_tag, true); for(var bookmark_tag of result.body.private) add_tag(bookmark_tag, false); // 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; }); // Trim the list. 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; var tag = query_args.get("untagged") != null ? "未分類" : query_args.get("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)*20, limit: 20, rest: rest, // public or private (no way to get both) }; } // 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"; } var query_args = this.url.searchParams; var hash_args = helpers.get_hash_args(this.url); var private_bookmarks = query_args.get("rest") == "hide"; var displaying = this.viewing_all_bookmarks? "All Bookmarks": private_bookmarks? "Private Bookmarks":"Public Bookmarks"; var tag = query_args.get("tag"); if(tag) displaying += " with tag \"" + tag + "\""; return displaying; }; get viewing_all_bookmarks() { return false; } 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. this.set_item(public_private_button_container, "all", {"#show-all": 1}, {"#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}); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_url = new URL(document.location); current_url.searchParams.delete("p"); let current_query = current_url.searchParams.toString(); var tag_list = container.querySelector(".bookmark-tag-list"); helpers.remove_elements(tag_list); var add_tag_link = (tag) => { let tag_count = this.bookmark_tag_counts[tag]; var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); let tag_name = tag; if(tag_name == "") tag_name = "Uncategorized"; a.innerText = tag_name; // Show the bookmark count in the popup. if(tag != "All") { a.classList.add("popup"); a.dataset.popup = tag_count + (tag_count == 1? " bookmark":" bookmarks"); } var url = new URL(document.location); url.searchParams.delete("p"); if(tag == "") // Uncategorized url.searchParams.set("untagged", 1); else url.searchParams.delete("untagged", 1); if(tag != "All" && tag != "") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); a.href = url.toString(); if(url.searchParams.toString() == current_query) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All"); 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); } if(this.user_info) thumbnail_view.avatar_widget.set_from_user_data(this.user_info); } get viewing_user_id() { if(helpers.get_path_part(this.url, 0) == "users") { // New URLs (/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); } else { // Old URLs (/bookmark.php?id=12345) // If there's no user ID in the URL, view our own bookmarks. var query_args = this.url.searchParams; var user_id = query_args.get("id"); if(user_id == null) return window.global_data.user_id; return query_args.get("id"); } }; // 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. class data_source_bookmarks extends data_source_bookmarks_base { get public_search_mode() { var query_args = this.url.searchParams; var rest = query_args.get("rest") || "show"; return rest == "show"? "public":"private"; } async continue_loading_page_internal(page) { var data = this.get_bookmark_query_params(page); var url = "/ajax/user/" + this.viewing_user_id + "/illusts/bookmarks"; var result = await helpers.get_request(url, data); result.body.works = data_source_bookmarks_base.filter_deleted_images(result.body.works); var illust_ids = []; for(var illust_data of result.body.works) illust_ids.push(illust_data.id); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } }; // Merged bookmark querying. This makes queries for both public and private bookmarks, // and merges them together. class data_source_bookmarks_merged extends data_source_bookmarks_base { get viewing_all_bookmarks() { return true; } get public_search_mode() { return "both"; } constructor(url) { super(url); this.max_page_per_type = [-1, -1]; // public, private this.bookmark_illust_ids = [[], []]; // 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. var request1 = this.request_bookmarks(page, "show"); var request2 = this.request_bookmarks(page, "hide"); // Wait for both requests to finish. await Promise.all([request1, request2]); // Both requests finished. Combine the two lists of illust IDs into a single page // and register it. var illust_ids = []; for(var i = 0; i < 2; ++i) if(this.bookmark_illust_ids[i] != null && this.bookmark_illust_ids[i][page] != null) illust_ids = illust_ids.concat(this.bookmark_illust_ids[i][page]); this.add_page(page, illust_ids); } async request_bookmarks(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; } var data = this.get_bookmark_query_params(page, rest); var url = "/ajax/user/" + this.viewing_user_id + "/illusts/bookmarks"; var result = await helpers.get_request(url, data); result.body.works = data_source_bookmarks_base.filter_deleted_images(result.body.works); // Put higher (newer) bookmarks first. result.body.works.sort(function(lhs, rhs) { return parseInt(rhs.bookmarkData.id) - parseInt(lhs.bookmarkData.id); }); var illust_ids = []; for(var illust_data of result.body.works) illust_ids.push(illust_data.id); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.works, "normal"); // If there are no results, remember that this is the last page, so we don't // make more requests for this type. if(illust_ids.length == 0) { if(this.max_page_per_type[is_private] == -1) this.max_page_per_type[is_private] = page; else this.max_page_per_type[is_private] = Math.min(page, this.max_page_per_type[is_private]); // console.log("max page for", is_private? "private":"public", this.max_page_per_type[is_private]); } // Store the IDs. We don't register them here. this.bookmark_illust_ids[is_private][page] = illust_ids; } } // new_illust.php class data_source_new_illust 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) { var query_args = this.url.searchParams; var hash_args = helpers.get_hash_args(this.url); // new_illust.php or new_illust_r18.php: let r18 = document.location.pathname == "/new_illust_r18.php"; var type = query_args.get("type") || "illust"; // Everything Pixiv does has always been based on page numbers, but this one uses starting IDs. // That's a better way (avoids duplicates when moving forward in the list), but it's inconsistent // with everything else. We usually load from page 1 upwards. If we're loading the next page and // we have a previous last_id, assume it starts at that ID. // // This makes some assumptions about how we're called: that we won't be called for the same page // multiple times and we're always loaded in ascending order. In practice this is almost always // true. If Pixiv starts using this method for more important pages it might be worth checking // this more carefully. if(this.last_id == null) { this.last_id = 0; this.last_id_page = 1; } if(this.last_id_page != page) { console.error("Pages weren't loaded in order"); return; } console.log("Assuming page", page, "starts at", this.last_id); var url = "/ajax/illust/new"; var result = await helpers.get_request(url, { limit: 20, type: type, r18: r18, lastId: this.last_id, }); var illust_ids = []; for(var illust_data of result.body.illusts) illust_ids.push(illust_data.id); if(illust_ids.length > 0) { this.last_id = illust_ids[illust_ids.length-1]; this.last_id_page++; } // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(result.body.illusts, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } refresh_thumbnail_ui(container) { this.set_item(container, "new-illust-type-illust", {type: null}); this.set_item(container, "new-illust-type-manga", {type: "manga"}); // These links are different from anything else on the site: they switch between // two top-level pages, even though they're just flags and everything else is the // same. We don't actually need to do this since we're just making API calls, but // we try to keep the base URLs compatible, so we go to the equivalent page on Pixiv // if we're turned off. var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']"); var r18_link = container.querySelector("[data-type='new-illust-ages-r18']"); var url = new URL(document.location); url.pathname = "/new_illust.php"; all_ages_link.href = url; var url = new URL(document.location); url.pathname = "/new_illust_r18.php"; r18_link.href = url; var url = new URL(document.location); 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 class data_source_bookmarks_new_illust extends data_source_from_page { get name() { return "bookmarks_new_illust"; } constructor(url, doc) { super(url, doc); this.bookmark_tags = []; } // Parse the loaded document and return the illust_ids. parse_document(doc) { this.bookmark_tags = []; for(var element of doc.querySelectorAll(".menu-items a[href*='bookmark_new_illust.php?tag'] span.icon-text")) this.bookmark_tags.push(element.innerText); var element = doc.querySelector("#js-mount-point-latest-following"); var items = JSON.parse(element.dataset.items); // Populate thumbnail data with this data. thumbnail_data.singleton().loaded_thumbnail_info(items, "following"); var illust_ids = []; for(var illust of items) illust_ids.push(illust.illustId); return illust_ids; } get page_title() { return "Following"; } get_displaying_text() { return "Following"; }; refresh_thumbnail_ui(container) { // Refresh the bookmark tag list. var current_tag = new URL(document.location).searchParams.get("tag") || "All"; var tag_list = container.querySelector(".follow-new-post-tag-list"); helpers.remove_elements(tag_list); var add_tag_link = function(tag) { var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = tag; var url = new URL(document.location); if(tag != "All") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); a.href = url.toString(); if(tag == current_tag) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All"); for(var tag of this.bookmark_tags) add_tag_link(tag); var all_ages_link = container.querySelector("[data-type='bookmarks-new-illust-all']"); var r18_link = container.querySelector("[data-type='bookmarks-new-illust-ages-r18']"); var url = new URL(document.location); url.pathname = "/bookmark_new_illust.php"; all_ages_link.href = url; var url = new URL(document.location); url.pathname = "/bookmark_new_illust_r18.php"; r18_link.href = url; var url = new URL(document.location); var currently_all_ages = url.pathname == "/bookmark_new_illust.php"; helpers.set_class(all_ages_link, "selected", currently_all_ages); helpers.set_class(r18_link, "selected", !currently_all_ages); } }; // /tags // // The new tag search UI is a bewildering mess: // // - Searching for a tag goes to "/tags/TAG/artworks". This searches all posts with the // tag. The API query is "/ajax/search/artworks/TAG". The "top" tab is highlighted, but // it's not actually on that tab and no tab button goes back here. "Illustrations, Manga, // Ugoira" in search options also goes here. // // - The "Illustrations" tab goes to "/tags/TAG/illustrations". The API is // "/ajax/search/illustrations/TAG?type=illust_and_ugoira". This is almost identical to // "artworks", but excludes posts marked as manga. "Illustrations, Ugoira" in search // options also goes here. // // - Clicking "manga" goes to "/tags/TAG/manga". The API is "/ajax/search/manga" and also // sets type=manga. This is "Manga" in the search options. This page is also useless. // // The "manga only" and "exclude manga" pages are useless, since Pixiv doesn't make any // useful distinction between "manga" and "illustrations with more than one page". We // only include them for completeness. // // - You can search for just animations, but there's no button for it in the UI. You // have to pick it from the dropdown in search options. This one is "illustrations?type=ugoira". // Why did they keep using type just for one search mode? Saying "type=manga" or any // other type fails, so it really is just used for this. // // - Clicking "Top" goes to "/tags/TAG" with no type. This is a completely different // page and API, "/ajax/search/top/TAG". It doesn't actually seem to be a rankings // page and just shows the same thing as the others with a different layout, so we // ignore this and treat it like "artworks". class data_source_search extends data_source { get name() { return "search"; } constructor(url, doc) { super(url, doc); this.cache_search_title = this.cache_search_title.bind(this); // Add the search tags to tag history. We only do this at the start when the // data source is created, not every time we navigate back to the search. let tag = this._search_tags; if(tag) helpers.add_recent_search_tag(tag); this.cache_search_title(); } get _search_tags() { return helpers._get_search_tags_from_url(this.url); } // Return the search type from the URL. This is one of "artworks", "illustrations" // or "novels" (not supported"). It can also be omitted, which is the "top" page, // but that gives the same results as "artworks" with a different page layout, so // we treat it as "artworks". get _search_type() { // ["", "tags", tag list, type] let url = helpers.get_url_without_language(this.url); let parts = url.pathname.split("/"); if(parts.length >= 4) return parts[3]; else return "artworks"; } startup() { super.startup(); // Refresh our title when translations are toggled. settings.register_change_callback("disable-translations", this.cache_search_title); } shutdown() { super.shutdown(); settings.unregister_change_callback("disable-translations", this.cache_search_title); } async cache_search_title() { this.title = "Search: "; let tags = this._search_tags; if(tags) { tags = await tag_translations.get().translate_tag_list(tags, "en"); var tag_list = document.createElement("span"); for(let tag of tags) { // Force "or" lowercase. if(tag.toLowerCase() == "or") tag = "or"; var span = document.createElement("span"); span.innerText = tag; span.classList.add("word"); if(tag == "or") span.classList.add("or"); else span.classList.add("tag"); tag_list.appendChild(span); } this.title += tags.join(" "); this.displaying_tags = tag_list; } // Update our page title. this.call_update_listeners(); } async load_page_internal(page) { var query_args = this.url.searchParams; let args = { p: page, }; // "artworks" and "illustrations" are different on the search page: "artworks" uses "/tag/TAG/artworks", // and "illustrations" is "/tag/TAG/illustrations?type=illust_and_ugoira". "artworks" doesn't include // posts flagged as manga, but it does include multi-page posts, which have no difference from manga. // There's no reason for the manga search to exist, but it's included for completeness. let search_type = this._search_type; let api_search_type = "artworks"; if(search_type == "artworks") { // "artworks" doesn't use the type field. api_search_type = "artworks"; } else if(search_type == "illustrations") { api_search_type = "illustrations"; args.type = "illust_and_ugoira"; } else if(search_type == "manga") { api_search_type = "manga"; args.type = "manga"; } query_args.forEach((value, key) => { args[key] = value; }); let tag = this._search_tags; // If we have no tags, we're probably on the "/tags" page, which is just a list of tags. Don't // run a search with no tags. if(!tag) { console.log("No search tags"); return; } var url = "/ajax/search/" + api_search_type + "/" + encodeURIComponent(tag); var result = await helpers.get_request(url, args); let body = result.body; // Store related tags. Only do this the first time and don't change it when we read // future pages, so the tags don't keep changing as you scroll around. if(this.related_tags == null) { this.related_tags = []; for(let tag of body.relatedTags) this.related_tags.push({tag: tag}); this.call_update_listeners(); } // Add translations. This is inconsistent with their other translation APIs, because Pixiv // never uses the same interface twice. Also, this has translations only for related tags // above, not for the tags used in the search, which sucks. let translations = []; for(let tag of Object.keys(body.tagTranslation)) { translations.push({ tag: tag, translation: body.tagTranslation[tag], }); } tag_translations.get().add_translations(translations); // /tag/TAG/illustrations returns results in body.illust. // /tag/TAG/artworks returns results in body.illustManga. // /tag/TAG/manga returns results in body.manga. let illusts = body.illust || body.illustManga || body.manga; illusts = illusts.data; // Populate thumbnail data with this data. thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal"); var illust_ids = []; for(let illust of illusts) illust_ids.push(illust.id); // Register the new page of data. this.add_page(page, illust_ids); } get page_title() { return this.title; } get_displaying_text() { return this.displaying_tags; }; initial_refresh_thumbnail_ui(container, view) { // Fill the search box with the current tag. var query_args = this.url.searchParams; let tag = this._search_tags; container.querySelector(".search-page-tag-entry .search-tags").value = tag; } // Return the search mode, which is selected by the "Type" search option. This generally // corresponds to the underlying page's search modes. get_url_search_mode() { // "/tags/tag/illustrations" has a "type" parameter with the search type. This is used for // "illust" (everything except animations) and "ugoira". let search_type = this._search_type; if(search_type == "illustrations") { let query_search_type = this.url.searchParams.get("type"); if(query_search_type == "ugoira") return "ugoira"; if(query_search_type == "illust") return "illust"; // If there's no parameter, show everything. return "all"; } if(search_type == "artworks") return "all"; if(search_type == "manga") return "manga"; // Use "all" for unrecognized types. return "all"; } // Return URL with the search mode set to mode. set_url_search_mode(url, mode) { url = new URL(url); url = helpers.get_url_without_language(url); // Only "ugoira" searches use type in the query. It causes an error in other modes, so remove it. if(mode == "illust") url.searchParams.set("type", "illust"); else if(mode == "ugoira") url.searchParams.set("type", "ugoira"); else url.searchParams.delete("type"); let search_type = "artworks"; if(mode == "manga") search_type = "manga"; else if(mode == "ugoira" || mode == "illust") search_type = "illustrations"; // Set the type in the URL. let parts = url.pathname.split("/"); parts[3] = search_type; url.pathname = parts.join("/"); return url; } refresh_thumbnail_ui(container, thumbnail_view) { if(this.related_tags) { thumbnail_view.tag_widget.set({ tags: this.related_tags }); } this.set_item(container, "ages-all", {mode: null}); this.set_item(container, "ages-safe", {mode: "safe"}); this.set_item(container, "ages-r18", {mode: "r18"}); this.set_item(container, "order-newest", {order: null}, {order: "date_d"}); this.set_item(container, "order-oldest", {order: "date"}); this.set_item(container, "order-all", {order: "popular_d"}); this.set_item(container, "order-male", {order: "popular_male_d"}); this.set_item(container, "order-female", {order: "popular_female_d"}); let set_search_mode = (container, type, mode) => { var link = container.querySelector("[data-type='" + type + "']"); if(link == null) { console.warn("Couldn't find button with selector", type); return; } let current_mode = this.get_url_search_mode(); let button_is_selected = current_mode == mode; helpers.set_class(link, "selected", button_is_selected); // Adjust the URL for this button. let url = this.set_url_search_mode(document.location, 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 = function(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 = function(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}); }.bind(this); var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); var last_week = new Date(); last_week.setDate(last_week.getDate() - 7); var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1); var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1); set_date_filter("time-week", last_week, tomorrow); set_date_filter("time-month", last_month, tomorrow); set_date_filter("time-year", last_year, tomorrow); for(var years_ago = 1; years_ago <= 7; ++years_ago) { var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1); var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago); set_date_filter("time-years-ago-" + years_ago, start_year, end_year); } this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]); // The "reset search" button removes everything in the query except search terms, and resets // the search type. var box = container.querySelector(".reset-search"); var url = new URL(document.location); 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; } }; class data_source_follows extends data_source { get name() { return "following"; } get search_mode() { return "users"; } constructor(url) { super(url); this.follow_tags = null; } get supports_start_page() { return true; } get viewing_user_id() { if(helpers.get_path_part(this.url, 0) == "users") { // New URLs (/users/13245/follows) return helpers.get_path_part(this.url, 1); } var query_args = this.url.searchParams; let user_id = query_args.get("id"); if(user_id == null) return window.global_data.user_id; return user_id; }; async load_page_internal(page) { // Make sure the user info is loaded. This should normally be preloaded by globalInitData // in main.js, and this won't make a request. this.user_info = await image_data.singleton().get_user_info_full(this.viewing_user_id); // Update to refresh our page title, which uses user_info. this.call_update_listeners(); var query_args = this.url.searchParams; var rest = query_args.get("rest") || "show"; var url = "/ajax/user/" + this.viewing_user_id + "/following"; let args = { offset: 48*(page-1), limit: 48, rest: rest, }; if(query_args.get("tag")) args.tag = query_args.get("tag"); let result = await helpers.get_request(url, args); // Store following tags. this.follow_tags = result.body.followUserTags; // Make a list of the first illustration for each user. var illusts = []; for(let followed_user of result.body.users) { if(followed_user == null) continue; // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data(followed_user, "following"); // XXX: user:user_id if(!followed_user.illusts.length) { console.log("Can't show followed user that has no posts:", followed_user.userId); continue; } let illust = followed_user.illusts[0]; illusts.push(illust); // We'll register this with thumbnail_data below. These results don't have profileImageUrl // and only put it in the enclosing user, so copy it over. illust.profileImageUrl = followed_user.profileImageUrl; } var illust_ids = []; for(let illust of illusts) illust_ids.push("user:" + illust.userId); // This request returns all of the thumbnail data we need. Forward it to // thumbnail_data so we don't need to look it up. thumbnail_data.singleton().loaded_thumbnail_info(illusts, "normal"); // Register the new page of data. this.add_page(page, illust_ids); } refresh_thumbnail_ui(container, thumbnail_view) { if(this.user_info) { thumbnail_view.avatar_widget.set_from_user_data(this.user_info); } // The public/private button only makes sense when viewing your own follows. var public_private_button_container = container.querySelector(".follows-public-private"); public_private_button_container.hidden = !this.viewing_self; this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"}); this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"}); var tag_list = container.querySelector(".follow-tag-list"); helpers.remove_elements(tag_list); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_url = new URL(document.location); current_url.searchParams.delete("p"); let current_query = current_url.searchParams.toString(); var add_tag_link = function(tag) { var a = document.createElement("a"); a.classList.add("box-link"); a.classList.add("following-tag"); a.innerText = tag; var url = new URL(document.location); url.searchParams.delete("p"); if(tag == "Uncategorized") url.searchParams.set("untagged", 1); else url.searchParams.delete("untagged", 1); if(tag != "All") url.searchParams.set("tag", tag); else url.searchParams.delete("tag"); a.href = url.toString(); if(url.searchParams.toString() == current_query) a.classList.add("selected"); tag_list.appendChild(a); }; add_tag_link("All"); for(var tag of this.follow_tags || []) add_tag_link(tag); if(this.user_info) thumbnail_view.avatar_widget.set_from_user_data(this.user_info); } 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. class data_source_related_favorites extends data_source_from_page { get name() { return "illust-bookmarks"; } get search_mode() { return "users"; } constructor(url) { super(url); this.illust_info = null; } async load_page_internal(page) { // Get info for the illustration we're displaying bookmarks for. var query_args = this.url.searchParams; var illust_id = query_args.get("illust_id"); this.illust_info = await image_data.singleton().get_image_info(illust_id); return super.load_page_internal(page); } // Parse the loaded document and return the illust_ids. parse_document(doc) { var ids = []; for(var element of doc.querySelectorAll("li.bookmark-item a[data-user_id]")) { // Register this as quick user data, for use in thumbnails. thumbnail_data.singleton().add_quick_user_data({ user_id: element.dataset.user_id, user_name: element.dataset.user_name, // This page gives links to very low-res avatars. Replace them with the high-res ones // that newer pages give. // // These links might be annoying animated GIFs, but we don't bother killing them here // like we do for the followed page since this isn't used very much. profile_img: element.dataset.profile_img.replace("_50", "_170"), }, "users_bookmarking_illust"); // The bookmarks: URL type will generate links to this user's bookmarks. ids.push("bookmarks:" + element.dataset.user_id); } return ids; } refresh_thumbnail_ui(container, thumbnail_view) { // Set the source image. var source_link = container.querySelector(".image-for-suggestions"); source_link.hidden = this.illust_info == null; if(this.illust_info) { source_link.href = "/artworks/" + this.illust_info.illustId + "#ppixiv"; var img = source_link.querySelector(".image-for-suggestions > img"); img.src = this.illust_info.urls.thumb; } } get page_title() { return "Similar Bookmarks"; }; get_displaying_text() { if(this.illust_info) return "Users who bookmarked " + this.illust_info.illustTitle; else return "Users who bookmarked image"; }; } class data_source_search_users extends data_source_from_page { get name() { return "search-users"; } get search_mode() { return "users"; } parse_document(doc) { var illust_ids = []; for(let item of doc.querySelectorAll(".user-recommendation-items .user-recommendation-item")) { let username = item.querySelector(".title").innerText; let user_id = item.querySelector(".follow").dataset.id; let profile_image = item.querySelector("._user-icon").dataset.src; thumbnail_data.singleton().add_quick_user_data({ user_id: user_id, user_name: username, profile_img: profile_image, }, "user_search"); illust_ids.push("user:" + user_id); } return illust_ids; } initial_refresh_thumbnail_ui(container, view) { let search = this.url.searchParams.get("nick"); container.querySelector(".search-users").value = search; } /* refresh_thumbnail_ui(container, thumbnail_view) { this.set_item(container, "public-follows", {rest: "show"}, {rest: "show"}); this.set_item(container, "private-follows", {rest: "hide"}, {rest: "show"}); var tag_list = container.querySelector(".follow-tag-list"); helpers.remove_elements(tag_list); // Refresh the bookmark tag list. Remove the page number from these buttons. let current_url = new URL(document.location); current_url.searchParams.delete("p"); let current_query = current_url.searchParams.toString(); } */ get page_title() { let search = this.url.searchParams.get("nick"); if(search) return "Search users: " + search; else return "Search users"; }; get_displaying_text() { return this.page_title; }; } // This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs. var 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; })(); // Hide the mouse cursor when it hasn't moved briefly, to get it out of the way. // This only hides the cursor over element. // // Chrome's cursor handling is buggy and doesn't update the cursor when it's not // moving, so this only works in Firefox. class hide_mouse_cursor_on_idle { constructor(element) { this.onmousemove = this.onmousemove.bind(this); this.onblur = this.onblur.bind(this); this.idle = this.idle.bind(this); this.hide_immediately = this.hide_immediately.bind(this); this.element = element; this.force_hidden_until = null; this.cursor_hidden = false; window.addEventListener("mousemove", this.onmousemove, true); window.addEventListener("blur", this.blur, true); window.addEventListener("hide-cursor-immediately", this.hide_immediately, true); window.addEventListener("enable-hiding-cursor", function() { this.enable = true; }.bind(this), true); window.addEventListener("disable-hiding-cursor", function() { this.enable = false; }.bind(this), true); settings.register_change_callback("no-hide-cursor", () => { this.refresh_hide_cursor(); }); this.enable = true; } // Temporarily disable hiding all mouse cursors. static enable_all() { window.dispatchEvent(new Event("enable-hiding-cursor")); } static disable_all() { window.dispatchEvent(new Event("disable-hiding-cursor")); } set enable(value) { if(this._enabled == value) return; this._enabled = value; if(this._enabled) this.reset_timer(); else { this.remove_timer(); this.show_cursor(); } } get enable() { return this._enabled; }; remove_timer() { if(!this.timer) return; clearTimeout(this.timer); this.timer = null; } // Hide the cursor now, and keep it hidden very briefly even if it moves. This is done // when releasing a zoom to prevent spuriously showing the mouse cursor. hide_immediately(e) { this.force_hidden_until = Date.now() + 150; this.idle(); } reset_timer() { this.show_cursor(); this.remove_timer(); this.timer = setTimeout(this.idle, 500); } idle() { this.remove_timer(); this.hide_cursor(); } onmousemove(e) { if(this.force_hidden_until && this.force_hidden_until > Date.now()) return; this.reset_timer(); } onblur(e) { this.remove_timer(); this.show_cursor(); } show_cursor(e) { this.cursor_hidden = false; this.refresh_hide_cursor(); } hide_cursor(e) { this.cursor_hidden = true; this.refresh_hide_cursor(); } refresh_hide_cursor() { // Setting style.cursor to none doesn't work in Chrome. Doing it with a style works // intermittently (seems to work better in fullscreen). Firefox doesn't have these // problems. // this.element.style.cursor = "none"; helpers.set_class(this.element, "hide-cursor", this.cursor_hidden && !settings.get("no-hide-cursor")); } } // 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. class image_data { constructor() { this.loaded_user_info = this.loaded_user_info.bind(this); this.illust_modified_callbacks = new callback_list(); this.user_modified_callbacks = new callback_list(); // Cached data: this.image_data = { }; this.user_data = { }; this.illust_loads = {}; this.user_info_loads = {}; }; // Return the singleton, creating it if needed. static singleton() { if(image_data._singleton == null) image_data._singleton = new image_data(); return image_data._singleton; }; // Call all illust_modified callbacks. call_user_modified_callbacks(user_id) { console.log("User modified:", user_id); this.user_modified_callbacks.call(user_id); } call_illust_modified_callbacks(illust_id) { this.illust_modified_callbacks.call(illust_id); } // Get image data. Call callback when it's available: // // callback(image_data, user_data); // // User data for the illustration will be fetched, and returned as image_data.userInfo. // Note that user data can change (eg. when following a user), and all images for the // same user will share the same userInfo object. // // If illust_id is a video, we'll also download the metadata before returning it, and store // it as image_data.ugoiraMetadata. get_image_info(illust_id) { if(illust_id == null) return null; // If we already have the image data, just return it. if(this.image_data[illust_id] != null && this.image_data[illust_id].userInfo) { return new Promise(resolve => { resolve(this.image_data[illust_id]); }); } // If there's already a load in progress, just return it. if(this.illust_loads[illust_id] != null) return this.illust_loads[illust_id]; var load_promise = this.load_image_info(illust_id); this._started_loading_image_info(illust_id, load_promise); return load_promise; } _started_loading_image_info(illust_id, load_promise) { this.illust_loads[illust_id] = load_promise; this.illust_loads[illust_id].then(() => { delete this.illust_loads[illust_id]; }); } // Like get_image_info, but return the result immediately. // // If the image info isn't loaded, don't start a request and just return null. get_image_info_sync(illust_id) { return this.image_data[illust_id]; } // Load illust_id and all data that it depends on. // // If we already have the image data (not necessarily the rest, like ugoira_metadata), // it can be supplied with illust_data. async load_image_info(illust_id, illust_data) { // 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.error("Fetching", illust_id); var user_info_promise = null; var manga_promise = null; var ugoira_promise = null; // Given a user ID and/or an illust_type (or null if either isn't known yet), start any // fetches we can. var start_loading = (user_id, illust_type, page_count) => { // If we know the user ID and haven't started loading user info yet, start it. if(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) manga_promise = helpers.get_request("/ajax/illust/" + illust_id + "/pages", {}); if(illust_type == 2 && ugoira_promise == null) ugoira_promise = helpers.get_request("/ajax/illust/" + illust_id + "/ugoira_meta"); }; // If we have thumbnail info, it tells us the user ID. This lets us start loading // user info without waiting for the illustration data to finish loading first. // Don't fetch thumbnail info if it's not already loaded. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info != null) start_loading(thumbnail_info.userId, thumbnail_info.illustType, thumbnail_info.pageCount); // If we don't have illust data, block while it loads. if(illust_data == null) { var illust_result_promise = helpers.get_request("/ajax/illust/" + illust_id, {}); var illust_result = await illust_result_promise; if(illust_result == null || illust_result.error) return; illust_data = illust_result.body; } tag_translations.get().add_translations(illust_data.tags.tags); // Now that we have illust data, load anything we weren't able to load before. start_loading(illust_data.userId, illust_data.illustType, illust_data.pageCount); // Store the results. illust_data.userInfo = await user_info_promise; // If we're loading image info, we're almost definitely going to load the avatar, so // start preloading it now. helpers.preload_images([illust_data.userInfo.imageBig]); if(manga_promise != null) { var manga_info = await manga_promise; illust_data.mangaPages = manga_info.body; } if(ugoira_promise != null) { var ugoira_result = await ugoira_promise; illust_data.ugoiraMetadata = ugoira_result.body; } // If this is a single-page image, create a dummy single-entry mangaPages array. This lets // us treat all images the same. if(illust_data.pageCount == 1) { illust_data.mangaPages = [{ width: illust_data.width, height: illust_data.height, // Rather than just referencing illust_Data.urls, copy just the image keys that // exist in the regular mangaPages list (no thumbnails). urls: { original: illust_data.urls.original, regular: illust_data.urls.regular, small: illust_data.urls.small, } }]; } // Store the image data. this.image_data[illust_id] = illust_data; return illust_data; } // 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); } // 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; // If we already have the user info for this illustration (and it's full data, if // requested), we're done. if(this.user_data[user_id] != null) { // user_info.partial is 1 if it's the full data (this is backwards). If we need // full data and we only have partial data, we still need to request data. if(!load_full_data || this.user_data[user_id].partial) { return new Promise(resolve => { resolve(this.user_data[user_id]); }); } } // If there's already a load in progress, just return it. if(this.user_info_loads[user_id] != null) return this.user_info_loads[user_id]; this.user_info_loads[user_id] = this.load_user_info(user_id); this.user_info_loads[user_id].then(() => { delete this.user_info_loads[user_id]; }); return this.user_info_loads[user_id]; }; async load_user_info(user_id) { // console.log("Fetch user", user_id); var result = await helpers.get_request("/ajax/user/" + user_id, {full:1}); 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', // 'premium', // 'sketchLiveId', // 'sketchLives', ]; let partial_keys = [ 'userId', 'isFollowed', 'name', 'imageBig', 'partial', ]; // partial is 0 if this is partial user data and 1 if it's full data (this is backwards). let expected_keys = user_data.partial? full_keys:partial_keys; var thumbnail_info_map = this.thumbnail_info_map_illust_list; var remapped_user_data = { }; for(let key of expected_keys) { if(!(key in user_data)) { console.warn("User info is missing key:", key); continue; } remapped_user_data[key] = user_data[key]; } return remapped_user_data; } loaded_user_info(user_result) { if(user_result.error) return; var user_data = user_result.body; user_data = this._check_user_data(user_data); var user_id = user_data.userId; // console.log("Got user", user_id); // Store the user data. if(this.user_data[user_id] == null) this.user_data[user_id] = user_data; else { // If we already have an object for this user, we're probably replacing partial user data // with full user data. Don't replace the user_data object itself, since widgets will have // a reference to the old one which will become stale. Just replace the data inside the // object. var old_user_data = this.user_data[user_id]; for(var key of Object.keys(old_user_data)) delete old_user_data[key]; for(var key of Object.keys(user_data)) old_user_data[key] = user_data[key]; } return user_data; } // Add image and user data to the cache that we received from other sources. Note that if // we have any fetches in the air already, we'll leave them running. add_illust_data(illust_data) { var load_promise = this.load_image_info(illust_data.illustId, illust_data); this._started_loading_image_info(illust_data.illustId, load_promise); } add_user_data(user_data) { this.loaded_user_info({ body: user_data, }); } // Load bookmark tags and comments. // // There's no visible API to do this, so we have to scrape the bookmark_add page. I wish // they'd just include this in bookmarkData. Since this takes an extra request, we should // only load this if the user is viewing/editing bookmark tags. get_bookmark_details(illust_info) { var illust_id = illust_info.illustId; if(this.bookmark_details[illust_id] == null) this.bookmark_details[illust_id] = this.load_bookmark_details(illust_info); return this.bookmark_details[illust_id]; } async load_bookmark_details(illust_info) { // Stop if this image isn't bookmarked. if(illust_info.bookmarkData == null) return; // Stop if this is already loaded. if(illust_info.bookmarkData.tags != null) return; var bookmark_page = await helpers.load_data_in_iframe("/bookmark_add.php?type=illust&illust_id=" + illust_info.illustId); // Stop if the image was unbookmarked while we were loading. if(illust_info.bookmarkData == null) return; var tags = bookmark_page.querySelector(".bookmark-detail-unit form input[name='tag']").value; var comment = bookmark_page.querySelector(".bookmark-detail-unit form input[name='comment']").value; tags = tags.split(" "); tags = tags.filter((value) => { return value != ""; }); illust_info.bookmarkData.tags = tags; illust_info.bookmarkData.comment = comment; } } // 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. class on_click_viewer { constructor() { this.onresize = this.onresize.bind(this); this.pointerdown = this.pointerdown.catch_bind(this); this.pointerup = this.pointerup.bind(this); this.pointermove = this.pointermove.bind(this); this.block_event = this.block_event.bind(this); this.window_blur = this.window_blur.bind(this); this._zoom_level = 0; // The caller can set this to a function to be called if the user clicks the image without // dragging. this.clicked_without_scrolling = null; this.original_width = 1; this.original_height = 1; this.zoom_pos = [0, 0]; settings.set_per_session("zoom-mode"); settings.set_per_session("zoom-level"); // Restore the most recent zoom mode. We assume that there's only one of these on screen. this.locked_zoom = settings.get("zoom-mode") == "locked"; this._zoom_level = settings.get("zoom-level", "cover"); } set_new_image(img, secondary_img, width, height) { if(this.img != null) { // Don't call this.disable, so we don't exit zoom. this._remove_events(); this.img.remove(); } this.img = img; this.secondary_img = secondary_img; this.original_width = width; this.original_height = height; if(this.img == null) return; this._add_events(); // If we've never set an image position, do it now. if(!this.set_initial_image_position) { // 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. This is based on how the image fits into the // browser window instead of the actual aspect ratio. // let aspect_ratio = this.original_width / this.original_height; // let portrait = aspect_ratio < 0.9; let screen_width = Math.max(this.container_width, 1); // might be 0 if we're hidden let screen_height = Math.max(this.container_height, 1); let portrait = (screen_width/this.original_width) > (screen_height/this.original_height); this.set_initial_image_position = true; this.set_image_position( [this.container_width * 0.5, this.container_height * 0.5], [this.width * 0.5, this.height * (portrait? 0:0.5)]); } this.reposition(); } block_event(e) { e.preventDefault(); } enable() { this._add_events(); } _add_events() { var target = this.img.parentNode; this.event_target = target; window.addEventListener("blur", this.window_blur); window.addEventListener("resize", this.onresize, true); target.addEventListener("pointerdown", this.pointerdown); target.addEventListener("pointerup", this.pointerup); target.addEventListener("pointercancel", this.pointerup); target.addEventListener("dragstart", this.block_event); target.addEventListener("selectstart", this.block_event); target.style.userSelect = "none"; target.style.MozUserSelect = "none"; } _remove_events() { if(this.event_target) { var target = this.event_target; this.event_target = null; target.removeEventListener("pointerdown", this.pointerdown); target.removeEventListener("pointerup", this.pointerup); target.removeEventListener("pointercancel", this.pointerup); target.removeEventListener("dragstart", this.block_event); target.removeEventListener("selectstart", this.block_event); target.style.userSelect = "none"; target.style.MozUserSelect = ""; } window.removeEventListener("blur", this.window_blur); window.removeEventListener("resize", this.onresize, true); } disable() { this.stop_dragging(); this._remove_events(); } onresize(e) { this.reposition(); } window_blur(e) { this.stop_dragging(); } // Enable or disable zoom lock. get locked_zoom() { return this._locked_zoom; } // Select between click-pan zooming and sticky, filled-screen zooming. set locked_zoom(enable) { this._locked_zoom = enable; settings.set("zoom-mode", enable? "locked":"normal"); this.reposition(); } // Relative zoom is applied on top of the main zoom. At 0, no adjustment is applied. // Positive values zoom in and negative values zoom out. get zoom_level() { return this._zoom_level; } set zoom_level(value) { this._zoom_level = value; settings.set("zoom-level", this._zoom_level); console.log("set", value); this.reposition(); } // Get the effective zoom level, translating "cover" and "fill" to actual values. get _zoom_level_value() { 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 zoom factor applied by relative zoom. get relative_zoom_factor() { return Math.pow(1.5, this._zoom_level_value); } // The zoom level for cover mode: get _zoom_level_cover() { let screen_width = this.container_width; let screen_height = this.container_height; let cover_zoom_ratio = Math.max(screen_width/this.width, screen_height/this.height); // Convert from a linear zoom ratio to the exponential zoom ratio. return Math.log2(cover_zoom_ratio) / Math.log2(1.5); } // The zoom level for "actual" mode: get _zoom_level_actual() { let actual_zoom_ratio = 1 / this._image_to_screen_ratio; // Convert from a linear zoom ratio to the exponential zoom ratio. return Math.log2(actual_zoom_ratio) / Math.log2(1.5); } // Zoom in or out. If zoom_in is true, zoom in by one level, otherwise zoom out by one level. change_zoom(zoom_out) { // zoom_level can be a number. At 0 (default), we zoom to fit the image in the screen. // Higher numbers zoom in, lower numbers zoom out. Zoom levels are logarithmic. // // zoom_level can be "cover", which zooms to fill the screen completely, so we only zoom on // one axis. // // zoom_level can also be "actual", which zooms the image to its natural size. // // These zoom levels have a natural ordering, which we use for incremental zooming. Figure // out the zoom levels that correspond to "cover" and "actual". This changes depending on the // image and screen size. let cover_zoom_level = this._zoom_level_cover; let actual_zoom_level = this._zoom_level_actual; // Increase or decrease relative_zoom_level by snapping to the next or previous increment. // We're usually on a multiple of increment, moving from eg. 0.5 to 0.75, but if we're on // a non-increment value from a special zoom level, this puts us back on the zoom increment. let old_level = this._zoom_level_value; 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 active zoom ratio. get _effective_zoom_level() { if(!this.zoom_active) return 1; return this.relative_zoom_factor; } // Given a screen position, return the normalized position relative to the image. // (0,0) is the top-left of the image and (1,1) is the bottom-right. get_image_position(screen_pos) { // zoom_pos shifts the image around in screen space. var zoom_center = [0,0]; if(this.zoom_active) { zoom_center[0] -= this.zoom_pos[0]; zoom_center[1] -= this.zoom_pos[1]; } zoom_center[0] += screen_pos[0]; zoom_center[1] += screen_pos[1]; // Offset by the base screen position we're in when not zoomed (centered). let screen_width = this.container_width; let screen_height = this.container_height; zoom_center[0] -= (screen_width - this.width) / 2; zoom_center[1] -= (screen_height - this.height) / 2; // Scale from the current zoom level to the effective size. var zoom_level = this._effective_zoom_level; zoom_center[0] /= zoom_level; zoom_center[1] /= zoom_level; return zoom_center; } // 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) { if(!this.zoom_active) return; // This just does the inverse of get_image_position. zoom_center = [zoom_center[0], zoom_center[1]]; var zoom_level = this._effective_zoom_level; zoom_center[0] *= zoom_level; zoom_center[1] *= zoom_level; // make this relative to zoom_pos, since that's what we need to set it back to below let screen_width = this.container_width; let screen_height = this.container_height; zoom_center[0] += (screen_width - this.width) / 2; zoom_center[1] += (screen_height - this.height) / 2; zoom_center[0] -= screen_pos[0]; zoom_center[1] -= screen_pos[1]; this.zoom_pos = [-zoom_center[0], -zoom_center[1]]; this.reposition(); } pointerdown(e) { if(e.button != 0) return; // We only want clicks on the image, or on the container backing the image, not other // elements inside the container. if(e.target != this.img && e.target != this.img.parentNode) return; this.event_target.style.cursor = "none"; // Don't show the UI if the mouse hovers over it while dragging. document.body.classList.add("hide-ui"); if(!this._locked_zoom) var zoom_center_percent = this.get_image_position([e.pageX, e.pageY]); this._mouse_pressed = true; this.dragged_while_zoomed = false; this.captured_pointer_id = e.pointerId; this.img.setPointerCapture(this.captured_pointer_id); // If this is a click-zoom, align the zoom to the point on the image that // was clicked. if(!this._locked_zoom) this.set_image_position([e.pageX, e.pageY], zoom_center_percent); this.reposition(); // Only listen to pointermove while we're dragging. this.event_target.addEventListener("pointermove", this.pointermove); } pointerup(e) { if(this.captured_pointer_id == null || e.pointerId != this.captured_pointer_id) return; if(!this._mouse_pressed) return; // Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the // cursor may have just been moved. This prevents the cursor from appearing briefly and // disappearing every time a zoom is released. window.dispatchEvent(new Event("hide-cursor-immediately")); this.stop_dragging(); } stop_dragging() { if(this.event_target != null) { this.event_target.removeEventListener("pointermove", this.pointermove); this.event_target.style.cursor = ""; } if(this.captured_pointer_id != null) { // Firefox has broken pointer capture, and will throw an exception when we call releasePointerCapture // on a valid captured pointer ID. There doesn't seem to be much we can do about this, so just swallow // the exception. try { this.img.releasePointerCapture(this.captured_pointer_id); } catch(e) { console.error("releasePointerCapture", e); } this.captured_pointer_id = null; } document.body.classList.remove("hide-ui"); this._mouse_pressed = false; this.reposition(); if(!this.dragged_while_zoomed && this.clicked_without_scrolling) this.clicked_without_scrolling(); } pointermove(e) { if(!this._mouse_pressed) return; // If button 1 isn't pressed, treat this as a pointerup. (The pointer events API // is really poorly designed in its handling of multiple button presses.) if((e.buttons & 1) == 0) { this.pointerup(e); return; } this.dragged_while_zoomed = true; // Apply mouse dragging. var x_offset = e.movementX; var y_offset = e.movementY; if(settings.get("invert-scrolling")) { x_offset *= -1; y_offset *= -1; } // Scale movement by the zoom level. var zoom_level = this._effective_zoom_level; this.zoom_pos[0] += x_offset * -1 * zoom_level; this.zoom_pos[1] += y_offset * -1 * zoom_level; this.reposition(); } // Return true if zooming is active. get zoom_active() { return this._mouse_pressed || this._locked_zoom; } get _image_to_screen_ratio() { let screen_width = this.container_width; let screen_height = this.container_height; // In case we're hidden and have no width, make sure we don't return an invalid value. if(screen_width == 0 || screen_height == 0) return 1; return Math.min(screen_width/this.original_width, screen_height/this.original_height); } // Return the width and height of the image when at 1x zoom. get width() { return this.original_width * this._image_to_screen_ratio; } get height() { return this.original_height * this._image_to_screen_ratio; } // The dimensions of the image viewport. This can be 0 if the view is hidden. get container_width() { return this.img.parentNode.offsetWidth; } get container_height() { return this.img.parentNode.offsetHeight; } reposition() { if(this.img == null) return; // Stop if we're being called after being disabled. if(this.img.parentNode == null) return; 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; // Normally (when unzoomed), the image is centered. var left = (screen_width - width) / 2; var top = (screen_height - height) / 2; if(this.zoom_active) { // Shift by the zoom position. left += this.zoom_pos[0]; top += this.zoom_pos[1]; // Apply the zoom. var zoom_level = this._effective_zoom_level; height *= zoom_level; width *= zoom_level; if(!settings.get("pan-past-edge")) { // 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 we're narrower than the screen, lock to center. let orig_top = top; if(screen_height < height) top = helpers.clamp(top, -(height - screen_height), 0); // clamp to the top and bottom else top = -(height - screen_height) / 2; // center vertically let orig_left = left; if(screen_width < width) left = helpers.clamp(left, -(width - screen_width), 0); // clamp to the left and right else left = -(width - screen_width) / 2; // center horizontally // Apply any clamping we did to the position to zoom_pos too, so if you move the // mouse far beyond the edge, you don't have to move it all the way back before we // start panning again. this.zoom_pos[0] += left - orig_left; this.zoom_pos[1] += top - orig_top; } } left = Math.round(left); top = Math.round(top); width = Math.round(width); height = Math.round(height); this.img.style.width = width + "px"; this.img.style.height = height + "px"; this.img.style.position = "absolute"; // We can either use CSS positioning or transforms. Transforms used to be a lot // faster, but today it doesn't matter. However, with CSS positioning we run into // weird Firefox compositing bugs that cause the image to disappear after zooming // and opening the context menu. That's hard to pin down, but since it doesn't happen // with translate, let's just use that. // this.img.style.left = left + "px"; // this.img.style.top = top + "px"; this.img.style.transform = "translate(" + left + "px, " + top + "px)"; this.img.style.right = "auto"; this.img.style.bottom = "auto"; // If we have a secondary (preview) image, put it in the same place as the main image. if(this.secondary_img) { this.secondary_img.style.width = width + "px"; this.secondary_img.style.height = height + "px"; this.secondary_img.style.position = "absolute"; this.secondary_img.style.left = left + "px"; this.secondary_img.style.top = top + "px"; this.secondary_img.style.right = "auto"; this.secondary_img.style.bottom = "auto"; } } } var 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); } }; } // This isn't really a polyfill, but we treat it like one for convenience. // // When functions called from event handlers throw exceptions, GreaseMonkey usually forgets // to log them to the console, probably sending them to some inconvenient browser-level log // instead. Work around some of this. func.catch_bind is like func.bind, but also wraps // the function in an exception handler to log errors correctly. The exception will still // be raised. // // This is only needed in Firefox, and we just point it at bind() otherwise. if(navigator.userAgent.indexOf("Firefox") == -1) { Function.prototype.catch_bind = Function.prototype.bind; } else { Function.prototype.catch_bind = function() { var func = this; var self = arguments[0]; var bound_args = Array.prototype.slice.call(arguments, 1); var wrapped_func = function() { try { var called_args = Array.prototype.slice.call(arguments, 0); var args = bound_args.concat(called_args); return func.apply(self, args); } catch(e) { console.error(e); throw e; } }; return wrapped_func; }; } 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; }; } } // A simple progress bar. // // Call bar.controller() to create a controller to update the progress bar. class progress_bar { 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. class progress_bar_controller { 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; } }; class seek_bar { constructor(container) { this.mousedown = this.mousedown.bind(this); this.mouseup = this.mouseup.bind(this); this.mousemove = this.mousemove.bind(this); this.mouseover = this.mouseover.bind(this); this.mouseout = this.mouseout.bind(this); this.container = container; this.bar = this.container.appendChild(helpers.create_node('\
\
\
\
\
\ ')); this.bar.addEventListener("mousedown", this.mousedown); this.bar.addEventListener("mouseover", this.mouseover); this.bar.addEventListener("mouseout", this.mouseout); this.current_time = 0; this.duration = 1; this.refresh_visibility(); this.refresh(); this.set_callback(null); }; mousedown(e) { // Never start dragging while we have no callback. This generally shouldn't happen // since we should be hidden. if(this.callback == null) return; if(this.dragging) return; console.log("down"); this.dragging = true; helpers.set_class(this.bar, "dragging", this.dragging); this.refresh_visibility(); // Only listen to mousemove while we're dragging. Put this on window, so we get drags outside // the window. window.addEventListener("mousemove", this.mousemove); window.addEventListener("mouseup", this.mouseup); this.set_drag_pos(e); } mouseover() { this.hovering = true; this.refresh_visibility(); } mouseout() { this.hovering = false; this.refresh_visibility(); } refresh_visibility() { // Show the seek bar if the mouse is over it, or if we're actively dragging. // Only show if we're active. var visible = this.callback != null && (this.hovering || this.dragging); helpers.set_class(this.bar, "visible", visible); } stop_dragging() { if(!this.dragging) return; this.dragging = false; helpers.set_class(this.bar, "dragging", this.dragging); this.refresh_visibility(); window.removeEventListener("mousemove", this.mousemove); window.removeEventListener("mouseup", this.mouseup); if(this.callback) this.callback(false, null); } mouseup(e) { this.stop_dragging(); } mousemove(e) { this.set_drag_pos(e); } // The user clicked or dragged. Pause and seek to the clicked position. set_drag_pos(e) { // Get the mouse position relative to the seek bar. var bounds = this.bar.getBoundingClientRect(); var pos = (e.clientX - bounds.left) / bounds.width; pos = Math.max(0, Math.min(1, pos)); var time = pos * this.duration; // Tell the user to seek. this.callback(true, time); } // Set the callback. callback(pause, time) will be called when the user interacts // with the seek bar. The first argument is true if the video should pause (because // the user is dragging the seek bar), and time is the desired playback time. If callback // is null, remove the callback. set_callback(callback) { this.bar.hidden = callback == null; if(this.callback == callback) return; // Stop dragging on any previous caller before we replace the callback. if(this.callback != null) this.stop_dragging(); this.callback = callback; this.refresh_visibility(); }; set_duration(seconds) { this.duration = seconds; this.refresh(); }; set_current_time(seconds) { this.current_time = seconds; this.refresh(); }; refresh() { var position = this.duration > 0.0001? (this.current_time / this.duration):0; this.bar.querySelector(".seek-fill").style.width = (position * 100) + "%"; }; } // 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*/ 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 } */ // 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. var ugoira_downloader_mjpeg = function(illust_data, progress) { this.illust_data = illust_data; this.progress = progress; // We don't need image data, but we make a dummy canvas to make ZipImagePlayer happy. var canvas = document.createElement("canvas"); // Create a ZipImagePlayer. This will download the ZIP, and handle parsing the file. this.player = new ZipImagePlayer({ "metadata": illust_data.ugoiraMetadata, "source": illust_data.ugoiraMetadata.originalSrc, "mime_type": illust_data.ugoiraMetadata.mime_type, "canvas": canvas, "progress": this.zip_finished_loading.bind(this), }); } ugoira_downloader_mjpeg.prototype.zip_finished_loading = function(progress) { if(this.progress) { try { this.progress.set(progress); } catch(e) { console.error(e); } } // We just want to know when the ZIP has been completely downloaded, which is indicated when progress // finishes. if(progress != null) return; // 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.player.getFrameData(0)], {type: this.player.op.metadata.mime_type || "image/png"}); var first_frame_url = URL.createObjectURL(blob); img.src = first_frame_url; img.onload = (e) => { URL.revokeObjectURL(first_frame_url); this.continue_saving(img.naturalWidth, img.naturalHeight) }; } ugoira_downloader_mjpeg.prototype.continue_saving = function(width, height) { 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.player.getFrameData(frame); encoder.add(frame_data, this.player.getFrameNoDuration(frame)); }; // 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.player.getFrameData(frame_count-1); encoder.add(frame_data, 0); // Build the file. var mkv = encoder.build(); var filename = this.illust_data.userInfo.name + " - " + this.illust_data.illustId + " - " + this.illust_data.illustTitle + ".mkv"; helpers.save_blob(mkv, filename); } catch(e) { console.error(e); }; }; // This is the base class for viewer classes, which are used to view a particular // type of content in the main display. class viewer { constructor(container, illust_data) { this.illust_data = illust_data; } // Remove any event listeners, nodes, etc. and shut down so a different viewer can // be used. shutdown() { } set page(page) { } get page() { return 0; } // 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; } } // This is the viewer for static images. We take an illust_data and show // either a single image or navigate between an image sequence. class viewer_images extends viewer { constructor(container, illust_data, options) { super(container, illust_data); this.container = container; this.options = options || {}; this.manga_page_bar = options.manga_page_bar; this.onkeydown = this.onkeydown.bind(this); this.blank_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; this.index = options.manga_page || 0; // Create a click and drag viewer for the image. this.on_click_viewer = new on_click_viewer(); main_context_menu.get.on_click_viewer = this.on_click_viewer; // Make a list of image URLs we're viewing. this.images = []; // If there are multiple pages, get image info from mangaPages. Otherwise, use // the main image. for(var page of illust_data.mangaPages) { this.images.push({ url: page.urls.original, preview_url: page.urls.small, width: page.width, height: page.height, }); } this.refresh(); } get current_image_type() { var url; if(this.illust_data.illustType != 2 && this.illust_data.pageCount == 1) url = this.illust_data.urls.original; else url = this.img.src; return helpers.get_extension(url).toUpperCase(); } shutdown() { if(this.on_click_viewer) { this.on_click_viewer.disable(); this.on_click_viewer = null; } if(this.img.parentNode) this.img.remove(); if(this.preview_img) this.preview_img.remove(); main_context_menu.get.on_click_viewer = null; } get page() { return this.index; } set page(page) { this.index = page; this.refresh(); } refresh() { var current_image = this.images[this.index]; if(current_image == null) { console.error("Invalid page", this.index, "in images", this.images); return; } if(this.on_click_viewer && this.img && this.img.src == current_image.url) return; // Create the new image and pass it to the viewer. this._create_image(current_image.url, current_image.preview_url, current_image.width, current_image.height); // Decode the next and previous image. This reduces flicker when changing pages // since the image will already be decoded. if(this.index > 0) helpers.decode_image(this.images[this.index - 1].url); if(this.index + 1 < this.images.length) helpers.decode_image(this.images[this.index + 1].url); // If we have a manga_page_bar, update to show the current page. if(this.manga_page_bar) { if(this.images.length == 1) this.manga_page_bar.set(null); else this.manga_page_bar.set((this.index+1) / this.images.length); } } _create_image(url, preview_url, width, height) { if(this.img) { this.img.remove(); this.img = null; } if(this.preview_img) { this.preview_img.remove(); this.preview_img = null; } // 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. this.preview_img = document.createElement("img"); this.preview_img.src = preview_url; this.preview_img.classList.add("low-res-preview"); // The secondary image holds the low-res preview image that's shown underneath the loading image. // It just follows the main image around and shouldn't receive input events. this.preview_img.style.pointerEvents = "none"; this.container.appendChild(this.preview_img); this.img = document.createElement("img"); this.img.src = url; this.img.className = "filtering"; this.container.appendChild(this.img); // When the image finishes loading, remove the preview image, to prevent artifacts with // transparent images. Keep a reference to preview_img, so we don't need to worry about // it changing. on_click_viewer will still have a reference to it, but it won't do anything. var preview_image = this.preview_img; this.img.addEventListener("load", (e) => { preview_image.remove(); }); this.on_click_viewer.set_new_image(this.img, this.preview_img, width, height); } onkeydown(e) { if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.keyCode) { case 36: // home e.stopPropagation(); e.preventDefault(); main_controller.singleton.show_illust(this.illust_data.id, { manga_page: 0, }); return; case 35: // end e.stopPropagation(); e.preventDefault(); main_controller.singleton.show_illust(this.illust_data.id, { manga_page: this.illust_data.pageCount - 1, }); return; } } } // This is used to display a muted image. class viewer_muted extends viewer { constructor(container, illust_data) { super(container, illust_data); this.container = container; // Create the display. this.root = helpers.create_from_template(".template-muted"); container.appendChild(this.root); // Show the user's avatar instead of the muted image. var img = this.root.querySelector(".muted-image"); img.src = illust_data.userInfo.imageBig; var muted_tag = muting.singleton.any_tag_muted(illust_data.tags.tags); var muted_user = muting.singleton.is_muted_user_id(illust_data.userId); var muted_label = this.root.querySelector(".muted-label"); if(muted_tag) muted_label.innerText = muted_tag; else muted_label.innerText = illust_data.userInfo.name; } shutdown() { this.root.parentNode.removeChild(this.root); } } class viewer_ugoira extends viewer { constructor(container, illust_data, seek_bar, options) { super(container, illust_data); console.log("create player:", illust_data.illustId); this.refresh_focus = this.refresh_focus.bind(this); this.clicked_canvas = this.clicked_canvas.bind(this); this.onkeydown = this.onkeydown.bind(this); this.drew_frame = this.drew_frame.bind(this); this.progress = this.progress.bind(this); this.seek_callback = this.seek_callback.bind(this); this.container = container; this.options = options; this.seek_bar = seek_bar; // Create an image to display the static image while we load. // // Like static image viewing, load the thumbnail, then the main image on top, since // the thumbnail will often be visible immediately. this.preview_img1 = document.createElement("img"); this.preview_img1.classList.add("low-res-preview"); this.preview_img1.style.position = "absolute"; this.preview_img1.style.width = "100%"; this.preview_img1.style.height = "100%"; this.preview_img1.style.objectFit = "contain"; this.preview_img1.src = illust_data.urls.small; this.container.appendChild(this.preview_img1); this.preview_img2 = document.createElement("img"); this.preview_img2.style.position = "absolute"; this.preview_img2.className = "filtering"; this.preview_img2.style.width = "100%"; this.preview_img2.style.height = "100%"; this.preview_img2.style.objectFit = "contain"; this.preview_img2.src = illust_data.urls.original; this.container.appendChild(this.preview_img2); // Remove the low-res preview image when the high-res one finishes loading. this.preview_img2.addEventListener("load", (e) => { this.preview_img1.remove(); }); // Create a canvas to render into. this.canvas = document.createElement("canvas"); this.canvas.hidden = true; this.canvas.className = "filtering"; this.canvas.style.width = "100%"; this.canvas.style.height = "100%"; this.canvas.style.objectFit = "contain"; this.container.appendChild(this.canvas); this.canvas.addEventListener("click", this.clicked_canvas, false); // True if we want to play if the window has focus. We always pause when backgrounded. this.want_playing = true; // 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); // Create the player. this.player = new ZipImagePlayer({ "metadata": illust_data.ugoiraMetadata, "autoStart": false, "source": illust_data.ugoiraMetadata.originalSrc, "mime_type": illust_data.ugoiraMetadata.mime_type, "autosize": true, "canvas": this.canvas, "loop": true, "debug": false, "progress": this.progress, drew_frame: this.drew_frame, }); this.refresh_focus(); } progress(value) { if(this.options.progress_bar) this.options.progress_bar.set(value); if(value == null) { // Once we send "finished", don't make any more progress calls. this.options.progress_bar = null; // Enable the seek bar once loading finishes. if(this.seek_bar) this.seek_bar.set_callback(this.seek_callback); } } // Once we draw a frame, hide the preview and show the canvas. This avoids // flicker when the first frame is drawn. drew_frame() { this.preview_img1.hidden = true; this.preview_img2.hidden = true; this.canvas.hidden = false; if(this.seek_bar) { // Update the seek bar. var frame_time = this.player.getCurrentFrameTime(); this.seek_bar.set_current_time(this.player.getCurrentFrameTime()); this.seek_bar.set_duration(this.player.getTotalDuration()); } } // 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.setSpeed(speed); return; } switch(e.keyCode) { case 32: // space e.stopPropagation(); e.preventDefault(); if(this.player) this.player.togglePause(); 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.setCurrentFrame(this.player.getFrameCount() - 1); return; case 81: // q case 87: // w e.stopPropagation(); e.preventDefault(); if(!this.player) return; this.pause(); var total_frames = this.player.getFrameCount(); var current_frame = this.player.getCurrentFrame(); var next = e.keyCode == 87; var new_frame = current_frame + (next?+1:-1); this.player.setCurrentFrame(new_frame); return; } } play() { this.want_playing = true; this.refresh_focus(); } pause() { this.want_playing = false; this.refresh_focus(); } shutdown() { console.log("shutdown player:", this.illust_data.illustId); this.finished = true; if(this.seek_bar) { this.seek_bar.set_callback(null); this.seek_bar = null; } window.removeEventListener("visibilitychange", this.refresh_focus); // Send a finished progress callback if we were still loading. We won't // send any progress calls after this (though the ZipImagePlayer will finish // downloading the file anyway). this.progress(null); if(this.player) this.player.pause(); this.preview_img1.remove(); this.preview_img2.remove(); this.canvas.remove(); } refresh_focus() { if(this.player == null) return; var active = this.want_playing && !this.seeking && !window.document.hidden && !this._hidden; if(active) this.player.play(); else this.player.pause(); }; clicked_canvas(e) { this.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.setCurrentFrameTime(seconds); }; } /* * 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. */ function ZipImagePlayer(options) { this.op = options; if (!Blob) { this._error("No Blob support"); } if (!Uint8Array) { this._error("No Uint8Array support"); } if (!DataView) { this._error("No DataView support"); } if (!ArrayBuffer) { this._error("No ArrayBuffer support"); } this._loadingState = 0; this._dead = false; this._context = options.canvas.getContext("2d"); this._files = {}; this._frameCount = this.op.metadata.frames.length; this._debugLog("Frame count: " + this._frameCount); this._frame = 0; this._loadFrame = 0; // Make a list of timestamps for each frame. this._frameTimestamps = []; var milliseconds = 0; for(var frame of this.op.metadata.frames) { this._frameTimestamps.push(milliseconds); milliseconds += frame.delay; } this._frameImages = []; this._paused = false; this._startLoad(); this.speed = 1; if (this.op.autoStart) { this.play(); } else { this._paused = true; } } // Removed partial loading. It doesn't cache in Firefox, and it's unnecessary with the very // tiny files Pixiv supports. ZipImagePlayer.prototype = { _failed: false, _mkerr: function(msg) { var _this = this; return function() { _this._error(msg); } }, _error: function(msg) { this._failed = true; throw Error("ZipImagePlayer error: " + msg); }, _debugLog: function(msg) { if (this.op.debug) { console.log(msg); } }, async _load() { var _this = this; // Use helpers.fetch_resource, so we share fetches with preloading. var response = helpers.fetch_resource(this.op.source, { onprogress: function(e) { if(!this.op.progress) return; try { this.op.progress(e.loaded / e.total); } catch(e) { console.error(e); } }.bind(this), }); var response = await response; if (_this._dead) { return; } _this._buf = response; var length = _this._buf.byteLength; _this._len = length; _this._pHead = length; _this._bytes = new Uint8Array(_this._buf); this._findCentralDirectory(); if(this.op.progress) { try { setTimeout(function() { this.op.progress(null); }.bind(this), 0); } catch(e) { console.error(e); } } }, _startLoad: function() { var _this = this; if (!this.op.source) { // Unpacked mode (individiual frame URLs) - just load the frames. this._loadNextFrame(); return; } _this._load(); }, _findCentralDirectory: function() { // No support for ZIP file comment var dv = new DataView(this._buf, this._len - 22, 22); if (dv.getUint32(0, true) != 0x06054b50) { this._error("End of Central Directory signature not found"); } var count = dv.getUint16(10, true); var size = dv.getUint32(12, true); var offset = dv.getUint32(16, true); if (offset < this._pTail) { this._error("End central directory past end of file"); return; } // Parse the central directory. var dv = new DataView(this._buf, offset, size); var p = 0; for (var i = 0; i < count; i++ ) { if (dv.getUint32(p, true) != 0x02014b50) { this._error("Invalid Central Directory signature"); } var compMethod = dv.getUint16(p + 10, true); var uncompSize = dv.getUint32(p + 24, true); var nameLen = dv.getUint16(p + 28, true); var extraLen = dv.getUint16(p + 30, true); var cmtLen = dv.getUint16(p + 32, true); var off = dv.getUint32(p + 42, true); if (compMethod != 0) { this._error("Unsupported compression method"); } p += 46; var nameView = new Uint8Array(this._buf, offset + p, nameLen); var name = ""; for (var j = 0; j < nameLen; j++) { name += String.fromCharCode(nameView[j]); } p += nameLen + extraLen + cmtLen; /*this._debugLog("File: " + name + " (" + uncompSize + " bytes @ " + off + ")");*/ this._files[name] = {off: off, len: uncompSize}; } // Two outstanding fetches at any given time. // Note: the implementation does not support more than two. if (this._pHead < this._pTail) { this._error("Chunk past end of file"); return; } this._pHead = this._len; this._loadNextFrame(); }, _fileDataStart: function(offset) { var dv = new DataView(this._buf, offset, 30); var nameLen = dv.getUint16(26, true); var extraLen = dv.getUint16(28, true); return offset + 30 + nameLen + extraLen; }, _isFileAvailable: function(name) { var info = this._files[name]; if (!info) { this._error("File " + name + " not found in ZIP"); } if (this._pHead < (info.off + 30)) { return false; } return this._pHead >= (this._fileDataStart(info.off) + info.len); }, getFrameData: function(frame) { if (this._dead) { return; } if (frame >= this._frameCount) { return null; } var meta = this.op.metadata.frames[frame]; if (!this._isFileAvailable(meta.file)) { return null; } var off = this._fileDataStart(this._files[meta.file].off); var end = off + this._files[meta.file].len; var mime_type = this.op.metadata.mime_type || "image/png"; var slice; if (!this._buf.slice) { slice = new ArrayBuffer(this._files[meta.file].len); var view = new Uint8Array(slice); view.set(this._bytes.subarray(off, end)); } else { slice = this._buf.slice(off, end); } return slice; }, _loadNextFrame: function() { if (this._dead) { return; } var frame = this._loadFrame; if (frame >= this._frameCount) { return; } var meta = this.op.metadata.frames[frame]; if (!this.op.source) { // Unpacked mode (individiual frame URLs) this._loadFrame += 1; this._loadImage(frame, meta.file, false); return; } if (!this._isFileAvailable(meta.file)) { return; } this._loadFrame += 1; var off = this._fileDataStart(this._files[meta.file].off); var end = off + this._files[meta.file].len; var mime_type = this.op.metadata.mime_type || "image/png"; var slice = this._buf.slice(off, end); var blob = new Blob([slice], {type: mime_type}); /*_this._debugLog("Loading " + meta.file + " to frame " + frame);*/ var url = URL.createObjectURL(blob); this._loadImage(frame, url, true); }, _loadImage: function(frame, url, isBlob) { var _this = this; var image = document.createElement("img"); // "can't access dead object" var meta = this.op.metadata.frames[frame]; image.addEventListener('load', function() { _this._debugLog("Loaded " + meta.file + " to frame " + frame); if (isBlob) { URL.revokeObjectURL(url); } if (_this._dead) { return; } _this._frameImages[frame] = image; if (_this._loadingState == 0) { _this._displayFrame.apply(_this); } if (frame >= (_this._frameCount - 1)) { _this._setLoadingState(2); _this._buf = null; _this._bytes = null; } else { _this._loadNextFrame(); } }); image.src = url; }, _setLoadingState: function(state) { if (this._loadingState != state) { this._loadingState = state; } }, _displayFrame: function() { if (this._dead) { return; } var _this = this; var meta = this.op.metadata.frames[this._frame]; // this._debugLog("Displaying frame: " + this._frame + " " + meta.file); var image = this._frameImages[this._frame]; if (!image) { this._debugLog("Image not available!"); this._setLoadingState(0); return; } if (this._loadingState != 2) { this._setLoadingState(1); } if (this.op.autosize) { if (this._context.canvas.width != image.width || this._context.canvas.height != image.height) { // make the canvas autosize itself according to the images drawn on it // should set it once, since we don't have variable sized frames this._context.canvas.width = image.width; this._context.canvas.height = image.height; } }; this.drawn_frame = this._frame; this._context.clearRect(0, 0, this.op.canvas.width, this.op.canvas.height); this._context.drawImage(image, 0, 0); // If the user wants to know when the frame is ready, call it. if(this.op.drew_frame) { try { setTimeout(function() { this.op.drew_frame(null); }.bind(this), 0); } catch(e) { console.error(e); } } if (this._paused) return; this._pending_frame_metadata = meta; this._refreshTimer(); }, _unsetTimer: function() { if(!this._timer) return; clearTimeout(this._timer); this._timer = null; }, _refreshTimer: function() { if(this._paused) return; this._unsetTimer(); this._timer = setTimeout(this._nextFrame.bind(this), this._pending_frame_metadata.delay / this.speed); }, getFrameDuration: function() { var meta = this.op.metadata.frames[this._frame]; return meta.delay; }, getFrameNoDuration: function(frame) { var meta = this.op.metadata.frames[frame]; return meta.delay; }, _nextFrame: function(frame) { this._timer = null; if (this._frame >= (this._frameCount - 1)) { if (this.op.loop) { this._frame = 0; } else { this.pause(); return; } } else { this._frame += 1; } this._displayFrame(); }, play: function() { if (this._dead) { return; } if (this._paused) { this._paused = false; this._displayFrame(); } }, pause: function() { if (this._dead) { return; } if (!this._paused) { this._unsetTimer(); this._paused = true; } }, togglePause: function() { if(this._paused) this.play(); else this.pause(); }, rewind: function() { if (this._dead) { return; } this._frame = 0; this._unsetTimer(); this._displayFrame(); }, setSpeed: function(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._refreshTimer(); }, stop: function() { this._debugLog("Stopped"); this._dead = true; this._unsetTimer(); this._frameImages = null; this._buf = null; this._bytes = null; }, getCurrentFrame: function() { return this._frame; }, setCurrentFrame: function(frame) { frame %= this._frameCount; if(frame < 0) frame += this._frameCount; this._frame = frame; this._displayFrame(); }, getTotalDuration: function() { var last_frame = this.op.metadata.frames.length - 1; return this._frameTimestamps[last_frame] / 1000; }, getCurrentFrameTime: function() { return this._frameTimestamps[this._frame] / 1000; }, // Set the video to the closest frame to the given time. setCurrentFrameTime: function(seconds) { // We don't actually need to check all frames, but there's no need to optimize this. var closest_frame = null; var closest_error = null; for(var frame = 0; frame < this.op.metadata.frames.length; ++frame) { var 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._displayFrame(); }, getLoadedFrames: function() { return this._frameImages.length; }, getFrameCount: function() { return this._frameCount; }, hasError: function() { return this._failed; } } // The base class for our main views. class view { constructor(container) { this.container = container; // Make our container focusable, so we can give it keyboard focus when we // become active. this.container.tabIndex = -1; } // Handle a key input. This is only called while the view is active. handle_onkeydown(e) { } // If this view is displaying an image, return its ID. // If this view is displaying a user's posts, return "user:ID". // Otherwise, return null. get displayed_illust_id() { return null; } // If this view is displaying a manga page, return its ID. Otherwise, return null. // If this is non-null, displayed_illust_id will always also be non-null. get displayed_illust_page() { return null; } // These are called to restore the scroll position on navigation. scroll_to_top() { } restore_scroll_position() { } scroll_to_illust_id(illust_id, manga_page) { } set active(active) { // Show or hide the view container. this.container.hidden = !active; if(active) { // Focus the container, so it receives keyboard events, eg. home/end. this.container.focus(); } else { // When the view isn't active, send viewhidden to close all popup menus inside it. view_hidden_listener.send_viewhidden(this.container); } } } // The main UI. This handles creating the viewers and the global UI. class view_illust extends view { constructor(container) { super(container); if(debug_show_ui) document.body.classList.add("force-ui"); this.onwheel = this.onwheel.bind(this); this.refresh_ui = this.refresh_ui.bind(this); this.data_source_updated = this.data_source_updated.bind(this); this.current_illust_id = -1; this.latest_navigation_direction_down = true; this.container = container; this.progress_bar = main_controller.singleton.progress_bar; // Create a UI box and put it in its container. var ui_container = this.container.querySelector(".ui"); this.ui = new image_ui(ui_container, this.progress_bar); var ui_box = this.container.querySelector(".ui-box"); var ui_visibility_changed = () => { // Hide the dropdown tag widget when the hover UI is hidden. if(!ui_box.classList.contains("hovering-over-box") && !ui_box.classList.contains("hovering-over-sphere")) { this.ui.bookmark_tag_widget.visible = false; // XXX remove view_hidden_listener.send_viewhidden(ui_box); } }; ui_box.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-box", true); ui_visibility_changed(); }); ui_box.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-box", false); ui_visibility_changed(); }); var hover_circle = this.container.querySelector(".ui .hover-circle"); hover_circle.addEventListener("mouseenter", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", true); ui_visibility_changed(); }); hover_circle.addEventListener("mouseleave", (e) => { helpers.set_class(ui_box, "hovering-over-sphere", false); ui_visibility_changed(); }); image_data.singleton().user_modified_callbacks.register(this.refresh_ui); image_data.singleton().illust_modified_callbacks.register(this.refresh_ui); settings.register_change_callback("recent-bookmark-tags", this.refresh_ui); new hide_mouse_cursor_on_idle(this.container.querySelector(".image-container")); // this.manga_thumbnails = new manga_thumbnail_widget(this.container.querySelector(".manga-thumbnail-container")); this.container.addEventListener("wheel", this.onwheel, { passive: false }); // A bar showing how far along in an image sequence we are: this.manga_page_bar = new progress_bar(this.container.querySelector(".ui-box")).controller(); this.seek_bar = new seek_bar(this.container.querySelector(".ugoira-seek-bar")); this.active = false; } set_data_source(data_source) { if(data_source == this.data_source) return; if(this.data_source != null) { this.data_source.remove_update_listener(this.data_source_updated); this.data_source = null; } this.data_source = data_source; this.ui.data_source = data_source; if(this.data_source != null) { this.data_source.add_update_listener(this.data_source_updated); this.refresh_ui(); } } get _hide_image() { return this.container.querySelector(".image-container").hidden; } set _hide_image(value) { this.container.querySelector(".image-container").hidden = value; } // Show an image. // // If manga_page isn't null, it's the page to display. // If manga_page is -1, show the last page. async show_image(illust_id, manga_page) { // If we previously set a pending navigation, this navigation overrides it. this.cancel_async_navigation(); // Remember that this is the image we want to be displaying. this.wanted_illust_id = illust_id; this.wanted_illust_page = manga_page; // If this image is already loaded, just make sure it's not hidden. if(illust_id == this.current_illust_id && this.viewer != null && this.wanted_illust_page == this.viewer.page && !this._hide_image) { console.log("illust_id", illust_id, "page", this.wanted_illust_page, "already displayed"); return; } // If we're not active, stop. We'll show this image if we become loaded later. if(!this.active) { // console.log("not active, set wanted id to", this.wanted_illust_id); return; } // Tell the preloader about the current image. image_preloader.singleton.set_current_image(illust_id); var image_container = this.container.querySelector(".image-container"); // If possible, show the quick preview. this.show_preview(illust_id); // Load info for this image if needed. var illust_data = await image_data.singleton().get_image_info(illust_id); // If we were deactivated while waiting for image info or the image we want to show has changed, stop. if(!this.active || this.wanted_illust_id != illust_id) { console.log("show_image: illust ID changed while async, stopping"); return; } // Remove the preview image, if any, since we're starting up the real viewer. Note // that viewer_illust will create an identical-looking preview once it starts. this.hide_preview(); // If manga_page is -1, we didn't know the page count when we did the navigation // and we want the last page. Otherwise, just make sure the page is in range. if(manga_page == -1) manga_page = illust_data.pageCount - 1; else manga_page = helpers.clamp(manga_page, 0, illust_data.pageCount-1); console.log("Showing image", illust_id, "page", manga_page); // If we adjusted the page, update the URL. For single-page posts, there should be // no page field. var args = helpers.get_args(document.location); var wanted_page_arg = illust_data.pageCount > 1? (manga_page + 1).toString():null; if(args.hash.get("page") != 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_args(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. var first_image_displayed = this.current_illust_id == -1 || this._hide_image; // If the illust ID isn't changing, just update the viewed page. if(illust_id == this.current_illust_id && this.viewer != null) { console.log("Image ID not changed, setting page", this.wanted_illust_page); this._hide_image = false; this.viewer.page = this.wanted_illust_page; if(this.manga_thumbnails) this.manga_thumbnails.current_page_changed(manga_page); this.refresh_ui(); return; } // Speculatively load the next image, which is what we'll show if you press page down, so // advancing through images is smoother. // // We don't do this when showing the first image, since the most common case is simply // viewing a single image and not navigating to any others, so this avoids making // speculative loads every time you load a single illustration. if(!first_image_displayed) { // Let image_preloader handle speculative loading. If preload_illust_id is null, // we're telling it that we don't need to load anything. var preload_illust_id = this.data_source.id_list.get_neighboring_illust_id(illust_id, this.latest_navigation_direction_down); image_preloader.singleton.set_speculative_image(preload_illust_id); } this.current_illust_id = illust_id; this.current_illust_data = illust_data; this.ui.illust_id = illust_id; this.refresh_ui(); var illust_data = this.current_illust_data; // If the image has the ドット絵 tag, enable nearest neighbor filtering. helpers.set_class(document.body, "dot", helpers.tags_contain_dot(illust_data)); // Dismiss any message when changing images. message_widget.singleton.hide(); // If we're showing something else, remove it. if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } // The viewer is gone, so we can unhide the image container without flashing the // previous image. this._hide_image = false; // Check if this image is muted. var muted_tag = muting.singleton.any_tag_muted(illust_data.tags.tags); var muted_user = muting.singleton.is_muted_user_id(illust_data.userId); if(muted_tag || muted_user) { // Tell the thumbnail view about the image. If the image is muted, disable thumbs. if(this.manga_thumbnails) this.manga_thumbnails.set_illust_info(null); // If the image is muted, load a dummy viewer. this.viewer = new viewer_muted(image_container, illust_data); return; } var manga_page = this.wanted_illust_page; if(manga_page == -1) manga_page = illust_data.pageCount - 1; // Tell the thumbnail view about the image. if(this.manga_thumbnails) { this.manga_thumbnails.set_illust_info(illust_data); this.manga_thumbnails.snap_transition(); // Let the manga thumbnail display know about the selected page. this.manga_thumbnails.current_page_changed(manga_page); } // Create the image viewer. var progress_bar = this.progress_bar.controller(); if(illust_data.illustType == 2) this.viewer = new viewer_ugoira(image_container, illust_data, this.seek_bar, { progress_bar: progress_bar, }); else { this.viewer = new viewer_images(image_container, illust_data, { progress_bar: progress_bar, manga_page_bar: this.manga_page_bar, manga_page: manga_page, }); } // Refresh the UI now that we have a new viewer. this.refresh_ui(); } // 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; } // When loading an image, illust_viewer shows the search thumbnail while loading the main // image. However, we can only start illust_viewer once we have image info, which causes // UI delays, even though we often already have enough info to show the preview image // immediately. // // If we have thumbnail data for illust_id and it's a single image (we don't do this for // manga), create a dummy image viewer to show it until we start the main viewer. The // image is already cached if we're coming from a search result, so this is often shown // immediately. // // If this shows a preview image, the viewer will be removed. // // - this isn't generally needed for manga (if we're coming from the manga viewer then image // info is already loaded and this is never visible) // - if we have a way to go directly to the first page of a manga post from search, we could // do this only if it's the first page (other pages won't match the thumb) // - if we do that, make sure we don't if the viewer is already pointing at that image show_preview(illust_id) { this.hide_preview(); // See if we already have thumbnail data loaded. var illust_thumbnail_data = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(illust_thumbnail_data == null) return; // We only do this for single images and animations right now. if(illust_thumbnail_data.pageCount != 1) return; // Don't show the preview if this image is muted. var muted_tag = muting.singleton.any_tag_muted(illust_thumbnail_data.tags); var muted_user = muting.singleton.is_muted_user_id(illust_thumbnail_data.userId); if(muted_tag || muted_user) return; console.log("Show placeholder for:", illust_thumbnail_data); this.preview_img = document.createElement("img"); this.preview_img.src = illust_thumbnail_data.url; this.preview_img.style.pointerEvents = "none"; this.preview_img.classList.add("filtering"); this.preview_img.classList.add("low-res-preview"); var preview_container = this.container.querySelector(".preview-container"); preview_container.appendChild(this.preview_img); this.preview_on_click_viewer = new on_click_viewer(); this.preview_on_click_viewer.set_new_image(this.preview_img, null, illust_thumbnail_data.width, illust_thumbnail_data.height); // Don't actually allow zooming the preview, since it'll reset once it's replaced with the real // viewer. We just create the on_click_viewer to match the zoom with what the real image will // have. this.preview_on_click_viewer.disable(); // The preview is taking the place of the viewer until we create it, so remove any existing // viewer. if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } } // Remove our preview image. hide_preview() { if(this.preview_on_click_viewer != null) { this.preview_on_click_viewer.disable(); this.preview_on_click_viewer = null; } if(this.preview_img != null) { this.preview_img.remove(); this.preview_img = null; } } // Stop displaying any image (and cancel any wanted navigation), putting us back // to where we were before displaying any images. // // This will also prevent the next image displayed from triggering speculative // loading, which we don't want to do when clicking an image in the thumbnail // view. stop_displaying_image() { if(this.viewer != null) { this.viewer.shutdown(); this.viewer = null; } if(this.manga_thumbnails) this.manga_thumbnails.set_illust_info(null); this.hide_preview(); this.wanted_illust_id = null; // The manga page to show, or the last page if -1. this.wanted_illust_page = 0; this.current_illust_id = -1; this.refresh_ui(); } data_source_updated() { this.refresh_ui(); } get active() { return this._active; } set active(active) { if(this._active == active) return; this._active = active; super.active = active; if(!active) { console.log("Hide illust,", this.viewer != null); this.cancel_async_navigation(); // Remove any image we're displaying, so if we show another image later, we // won't show the previous image while the new one's data loads. if(this.viewer != null) this._hide_image = true; // Stop showing the user in the context menu, and stop showing the current page. main_context_menu.get.user_info = null; main_context_menu.get.page = -1; return; } // If show_image was called while we were inactive, load it now. if(this.wanted_illust_id != this.current_illust_id || this.wanted_illust_page != this.viewer.page || this._hide_image) { // Show the image. console.log("Showing illust_id", this.wanted_illust_id, "that was set while hidden"); this.show_image(this.wanted_illust_id, this.wanted_illust_page); } // If we're becoming active, refresh the UI, since we don't do that while we're inactive. this.refresh_ui(); } // Refresh the UI for the current image. refresh_ui() { // Don't refresh if the thumbnail view is active. We're not visible, and we'll just // step over its page title, etc. if(!this._active) return; // Tell the UI which page is being viewed. var page = this.viewer != null? this.viewer.page:0; this.ui.set_displayed_page_info(page); // Tell the context menu which user is being viewed. main_context_menu.get.user_info = this.current_illust_data? this.current_illust_data.userInfo:null; main_context_menu.get.page = page; // Pull out info about the user and illustration. var illust_id = this.current_illust_id; // Update the disable UI button to point at the current image's illustration page. var disable_button = this.container.querySelector(".disable-ui-button"); disable_button.href = "/artworks/" + illust_id + "#no-ppixiv"; // If we're not showing an image yet, hide the UI and don't try to update it. helpers.set_class(this.container.querySelector(".ui"), "disabled", illust_id == -1); helpers.set_title_and_icon(this.current_illust_data); if(illust_id == -1) return; this.ui.refresh(); } onwheel(e) { if(!this._active) return; // Don't intercept wheel scrolling over the description box. if(e.target.closest(".description") != null) return; var down = e.deltaY > 0; this.move(down, e.shiftKey /* skip_manga_pages */); } get displayed_illust_id() { return this.wanted_illust_id; } get displayed_illust_page() { return this.wanted_illust_page; } handle_onkeydown(e) { // Let the viewer handle the input first. if(this.viewer && this.viewer.onkeydown) { this.viewer.onkeydown(e); if(e.defaultPrevented) return; } this.ui.handle_onkeydown(e); if(e.defaultPrevented) return; if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.keyCode) { case 37: // left case 38: // up case 33: // pgup e.preventDefault(); e.stopPropagation(); this.move(false, e.shiftKey /* skip_manga_pages */); break; case 39: // right case 40: // down case 34: // pgdn e.preventDefault(); e.stopPropagation(); this.move(true, e.shiftKey /* skip_manga_pages */); break; } } // Navigate to the next or previous image. // // If skip_manga_pages is true, jump past any manga pages in the current illustration. If // this is true and we're navigating backwards, we'll also jump to the first manga page // instead of the last. async move(down, skip_manga_pages) { // Remember whether we're navigating forwards or backwards, for preloading. this.latest_navigation_direction_down = down; this.cancel_async_navigation(); // See if we should change the manga page. if(!skip_manga_pages && this.current_illust_data != null && this.current_illust_data.pageCount > 1) { var old_page = this.wanted_illust_page; var new_page = old_page + (down? +1:-1); new_page = Math.max(0, Math.min(this.current_illust_data.pageCount - 1, new_page)); if(new_page != old_page) { main_controller.singleton.show_illust(this.current_illust_id, { manga_page: new_page, }); return; } } // If we have a target illust_id, move relative to it. Otherwise, move relative to the // displayed image. This way, if we navigate repeatedly before a previous navigation // finishes, we'll keep moving rather than waiting for each navigation to complete. var navigate_from_illust_id = this.wanted_illust_id; if(navigate_from_illust_id == null) navigate_from_illust_id = this.current_illust_id; // Get the next (or previous) illustration after the current one. var new_illust_id = this.data_source.id_list.get_neighboring_illust_id(navigate_from_illust_id, down); if(new_illust_id != null) { // Show the new image. main_controller.singleton.show_illust(new_illust_id, { manga_page: down || skip_manga_pages? 0:-1, }); return true; } // That page isn't loaded. Try to load it. var next_page = this.data_source.id_list.get_page_for_neighboring_illust(navigate_from_illust_id, down); // If we can't find the next page, then the current image isn't actually loaded in // the current search results. This can happen if the page is reloaded: we'll show // the previous image, but we won't have the results loaded (and the results may have // changed). Just jump to the first image in the results so we get back to a place // we can navigate from. // // Note that we use id_list.get_first_id rather than get_current_illust_id, which is // just the image we're already on. if(next_page == null) { // We should normally know which page the illustration we're currently viewing is on. console.warn("Don't know the next page for illust", navigate_from_illust_id); new_illust_id = this.data_source.id_list.get_first_id(); main_controller.singleton.show_illust(new_illust_id); return true; } 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.data_source.id_list.is_page_loaded(next_page)) { console.error("Page", next_page, "is already loaded"); return; } // Ask the data source to load it. var pending_navigation = this.pending_navigation = new Object(); if(!await this.data_source.load_page(next_page)) { console.log("Reached the end of the list"); return false; } // If this.pending_navigation is no longer set to this function, 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; } this.pending_navigation = null; // If we do have an image displayed, navigate up or down based on our most recent navigation // direction. This simply retries the navigation now that we have data. console.log("Retrying navigation after data load"); await this.move(down); return true; } } // The search UI. class view_search extends view { constructor(container) { super(container); this.thumbs_loaded = this.thumbs_loaded.bind(this); this.data_source_updated = this.data_source_updated.bind(this); this.onwheel = this.onwheel.bind(this); this.onscroll = this.onscroll.bind(this); // this.onmousemove = this.onmousemove.bind(this); this.refresh_thumbnail = this.refresh_thumbnail.bind(this); this.refresh_images = this.refresh_images.bind(this); this.window_onresize = this.window_onresize.bind(this); this.update_from_settings = this.update_from_settings.bind(this); this.thumbnail_onclick = this.thumbnail_onclick.bind(this); this.submit_user_search = this.submit_user_search.bind(this); this.active = false; this.thumbnail_templates = {}; window.addEventListener("thumbnailsLoaded", this.thumbs_loaded); window.addEventListener("resize", this.window_onresize); this.container.addEventListener("wheel", this.onwheel, { passive: false }); // this.container.addEventListener("mousemove", this.onmousemove); this.container.addEventListener("scroll", this.onscroll); window.addEventListener("resize", this.onscroll); image_data.singleton().user_modified_callbacks.register(this.refresh_ui.bind(this)); // When a bookmark is modified, refresh the heart icon. image_data.singleton().illust_modified_callbacks.register(this.refresh_thumbnail); this.thumbnail_dimensions_style = helpers.create_style(""); document.body.appendChild(this.thumbnail_dimensions_style); // Create the avatar widget shown on the artist data source. this.avatar_widget = new avatar_widget({ parent: this.container.querySelector(".avatar-container"), changed_callback: this.data_source_updated, big: true, mode: "dropdown", }); // Create the tag widget used by the search data source. this.tag_widget = new tag_widget({ parent: this.container.querySelector(".related-tag-list"), format_link: function(tag) { // The recommended tag links are already on the search page, and retain other // search settings. let url = page_manager.singleton().get_url_for_tag_search(tag); url.searchParams.delete("p"); return url.toString(); }.bind(this), }); // Don't scroll thumbnails when scrolling tag dropdowns. // FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though // they seem like the same thing. this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true); this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true); // Set up hover popups. dropdown_menu_opener.create_handlers(this.container, [".navigation-menu-box", ".thumbnail-settings-menu-box", ".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box", ".search-tags-box"]); // As an optimization, start loading image info on mousedown. We don't navigate until click, // but this lets us start loading image info a bit earlier. this.container.querySelector(".thumbnails").addEventListener("mousedown", (e) => { if(e.button != 0) return; // Don't do this when viewing followed users, since we'll be loading the user rather than the post. if(this.data_source && this.data_source.search_mode == "users") return; var a = e.target.closest("a.thumbnail-link"); if(a == null) return; if(a.dataset.illustId != null) image_data.singleton().get_image_info(a.dataset.illustId); }, true); this.container.querySelector(".refresh-search-button").addEventListener("click", this.refresh_search.bind(this)); this.container.querySelector(".whats-new-button").addEventListener("click", this.whats_new.bind(this)); this.container.querySelector(".thumbnails").addEventListener("click", this.thumbnail_onclick); var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box"); menu_option.add_settings(settings_menu); settings.register_change_callback("thumbnail-size", () => { // refresh_images first to update thumbnail_dimensions_style, then call onscroll // to fill in images. this.refresh_images(); this.onscroll(); }); 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); // Create the tag dropdown for the search page input. new tag_search_box_widget(this.container.querySelector(".tag-search-box")); // Create the tag dropdown for the search input in the menu dropdown. new tag_search_box_widget(this.container.querySelector(".navigation-search-box")); // Handle submitting searches on the user search page. this.container.querySelector(".user-search-box .search-submit-button").addEventListener("click", this.submit_user_search); helpers.input_handler(this.container.querySelector(".user-search-box input.search-users"), this.submit_user_search); // This IntersectionObserver is used to tell which illustrations are fully visible on screen, // so we can decide which page to put in the URL for data sources that use supports_start_page. this.visible_illusts = []; this.topmost_illust_observer = helpers.intersection_observer((entries) => { for(let entry of entries) { let thumb = entry.target; if(thumb.dataset.illust_id == null) continue; if(entry.isIntersecting) this.visible_illusts.push(thumb); else { let idx = this.visible_illusts.indexOf(thumb); if(idx != -1) this.visible_illusts.splice(idx, 1); } } this.visible_thumbs_changed(); }, { root: this.container, // We only want to include fully visible thumbnails. Note that for helpers.intersection_observer // we need to just use "threshold" and not "thresholds". threshold: 1, }); /* * Add a slight delay before hiding the UI. This allows opening the UI by swiping past the top * of the window, without it disappearing as soon as the mouse leaves the window. This doesn't * affect opening the UI. * * We're actually handling the manga UI's top-ui-box here too. */ for(let box of document.querySelectorAll(".top-ui-box")) new hover_with_delay(box, 0, 0.25); this.update_from_settings(); this.refresh_images(); this.load_needed_thumb_data(); this.refresh_whats_new_button(); } // The thumbnails visible on screen have changed. visible_thumbs_changed() { // visible_illusts isn't in any particular order, but should always be contiguous. // Start at the first thumb in the list, and walk backwards through thumbs until // we reach one that isn't in the list. The thumbnail display can get very long, // but visible_illusts is only the ones on screen. // Find the earliest thumb in the list. if(this.visible_illusts.length == 0) return; let first_thumb = this.visible_illusts[0]; while(first_thumb != null) { let prev_thumb = first_thumb.previousElementSibling; if(prev_thumb == null) break; if(this.visible_illusts.indexOf(prev_thumb) == -1) break; first_thumb = prev_thumb; } // If the data source supports a start page, update the page number in the URL to reflect // the first visible thumb. if(this.data_source == null || !this.data_source.supports_start_page || first_thumb.dataset.page == null) return; main_controller.singleton.temporarily_ignore_onpopstate = true; try { let args = helpers.get_args(document.location); this.data_source.set_start_page(args, first_thumb.dataset.page); helpers.set_args(args, false, "viewing-page"); } finally { main_controller.singleton.temporarily_ignore_onpopstate = false; } } window_onresize(e) { if(!this.active) return; this.refresh_images(); } 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_history_revision(); helpers.set_class(this.container.querySelector(".whats-new-button"), "updates", new_updates); } whats_new() { settings.set("whats-new-last-viewed-version", whats_new.latest_history_revision()); this.refresh_whats_new_button(); new whats_new(document.body.querySelector(".whats-new-box")); } /* This scrolls the thumbnail when you hover over it. It's sort of neat, but it's pretty * choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail, * causing it to pop to a new location. onmousemove(e) { var thumb = e.target.closest(".thumbnail-box a"); if(thumb == null) return; var bounds = thumb.getBoundingClientRect(); var x = e.clientX - bounds.left; var y = e.clientY - bounds.top; x = 100 * x / thumb.offsetWidth; y = 100 * y / thumb.offsetHeight; var img = thumb.querySelector("img.thumb"); img.style.objectPosition = x + "% " + y + "%"; } */ onwheel(e) { // Stop event propagation so we don't change images on any viewer underneath the thumbs. e.stopPropagation(); }; onscroll(e) { this.load_needed_thumb_data(); }; initial_refresh_ui() { if(this.data_source != null) { var ui_box = this.container.querySelector(".thumbnail-ui-box"); this.data_source.initial_refresh_thumbnail_ui(ui_box, this); } } set_data_source(data_source) { if(this.data_source == data_source) return; if(this.data_source != null) { this.data_source.remove_update_listener(this.data_source_updated); // Store our scroll position on the data source, so we can restore it if it's // reactivated. There's only one instance of thumbnail_view, so this is safe. this.data_source.thumbnail_view_scroll_pos = this.container.scrollTop; } // If the search mode is changing (eg. we're going from a list of illustrations to a list // of users), remove thumbs so we recreate them. Otherwise, refresh_images will reuse them // and they can be left on the wrong display type. var old_search_mode = this.data_source? this.data_source.search_mode:""; var new_search_mode = data_source? data_source.search_mode:""; if(old_search_mode != new_search_mode) { var ul = this.container.querySelector("ul.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. this.topmost_illust_observer.unobserve(node); helpers.remove_array_element(this.visible_illusts, node); } } this.data_source = data_source; if(this.data_source == null) return; // If we disabled loading more pages earlier, reenable it. this.disable_loading_more_pages = false; // Listen to the data source loading new pages, so we can refresh the list. this.data_source.add_update_listener(this.data_source_updated); this.refresh_images(); this.load_needed_thumb_data(); this.initial_refresh_ui(); this.refresh_ui(); }; restore_scroll_position() { // If we saved a scroll position when navigating away from a data source earlier, // restore it now. Only do this once. if(this.data_source.thumbnail_view_scroll_pos != null) { this.container.scrollTop = this.data_source.thumbnail_view_scroll_pos; delete this.data_source.thumbnail_view_scroll_pos; } else this.scroll_to_top(); } scroll_to_top() { this.container.scrollTop = 0; } refresh_ui() { if(!this.active) return; var element_displaying = this.container.querySelector(".displaying"); element_displaying.hidden = this.data_source.get_displaying_text == null; if(this.data_source.get_displaying_text != null) { // get_displaying_text can either be a string or an element. let text = this.data_source.get_displaying_text(); helpers.remove_elements(element_displaying); if(typeof text == "string") element_displaying.innerText = text; else if(text instanceof HTMLElement) { helpers.remove_elements(element_displaying); element_displaying.appendChild(text); } } helpers.set_page_title(this.data_source.page_title || "Loading..."); var ui_box = this.container.querySelector(".thumbnail-ui-box"); this.data_source.refresh_thumbnail_ui(ui_box, this); this.refresh_ui_for_user_id(); }; // Return the user ID we're viewing, or null if we're not viewing anything specific to a user. get viewing_user_id() { if(this.data_source == null) return null; return this.data_source.viewing_user_id; } // If the data source has an associated artist, return the "user:ID" for the user, so // when we navigate back to an earlier search, pulse_thumbnail will know which user to // flash. get displayed_illust_id() { if(this.data_source == null) return super.displayed_illust_id; let user_id = this.data_source.viewing_user_id; if(user_id != null) return "user:" + user_id; return super.displayed_illust_id; } // Call refresh_ui_for_user_info with the user_info for the user we're viewing, // if the user ID has changed. async refresh_ui_for_user_id() { // If we're viewing ourself (our own bookmarks page), hide the user-related UI. var initial_user_id = this.viewing_user_id; var user_id = initial_user_id == window.global_data.user_id? null:initial_user_id; var user_info = await image_data.singleton().get_user_info_full(user_id); // Stop if the user ID changed since we started this request, or if we're no longer active. if(this.viewing_user_id != initial_user_id || !this.active) return; helpers.set_icon(null, user_info); // Set the bookmarks link. var bookmarks_link = this.container.querySelector(".bookmarks-link"); bookmarks_link.hidden = user_info == null; if(user_info != null) { var bookmarks_url = "/users/" + user_info.userId + "/bookmarks/artworks#ppixiv"; bookmarks_link.href = bookmarks_url; bookmarks_link.dataset.popup = user_info? ("View " + user_info.name + "'s bookmarks"):"View bookmarks"; } // Set the similar artists link. var similar_artists_link = this.container.querySelector(".similar-artists-link"); similar_artists_link.hidden = user_info == null; if(user_info) similar_artists_link.href = "/discovery/users#ppixiv?user_id=" + user_info.userId; // Set the following link. var following_link = this.container.querySelector(".following-link"); following_link.hidden = user_info == null; if(user_info != null) { let following_url = "/users/" + user_info.userId + "/following#ppixiv"; following_link.href = following_url; following_link.dataset.popup = user_info? ("View " + user_info.name + "'s followed users"):"View following"; } let extra_links = []; // Set the webpage link. // // If the webpage link is on a known site, disable the webpage link and add this to the // generic links list, so it'll use the specialized icon. var webpage_url = user_info && user_info.webpage; if(webpage_url != null && this.find_link_image_type(webpage_url)) { extra_links.push(webpage_url); webpage_url = null; } var webpage_link = this.container.querySelector(".webpage-link"); webpage_link.hidden = webpage_url == null; if(webpage_url != null) { webpage_link.href = webpage_url; webpage_link.dataset.popup = webpage_url; } // Set the twitter link. var twitter_url = user_info && user_info.social && user_info.social.twitter && user_info.social.twitter.url; var twitter_link = this.container.querySelector(".twitter-icon"); twitter_link.hidden = twitter_url == null; if(twitter_url != null) { twitter_link.href = twitter_url; var path = new URL(twitter_url).pathname; var parts = path.split("/"); twitter_link.dataset.popup = parts.length > 1? ("@" + parts[1]):"Twitter"; } // Set the pawoo link. var pawoo_url = user_info && user_info.social && user_info.social.pawoo && user_info.social.pawoo.url; var pawoo_link = this.container.querySelector(".pawoo-icon"); pawoo_link.hidden = pawoo_url == null; if(pawoo_url != null) pawoo_link.href = pawoo_url; // Set the "send a message" link. var contact_link = this.container.querySelector(".contact-link"); contact_link.hidden = user_info == null; if(user_info != null) contact_link.href = "/messages.php?receiver_id=" + user_info.userId; // Remove any extra buttons that we added earlier. let row = this.container.querySelector(".button-row"); for(let div of row.querySelectorAll(".extra-profile-link-button")) div.remove(); // Find any other links in the user's profile text. if(user_info != null) { let div = document.createElement("div"); div.innerHTML = user_info.commentHtml; for(let link of div.querySelectorAll("a")) extra_links.push(helpers.fix_pixiv_link(link.href)); } let count = 0; for(let url of extra_links) { url = new URL(url); let entry = helpers.create_from_template(".template-extra-profile-link-button"); let a = entry.querySelector(".extra-link"); a.href = url; a.dataset.popup = a.href; let link_type = this.find_link_image_type(url); if(link_type != null) { entry.querySelector(".default-icon").hidden = true; entry.querySelector(link_type).hidden = false; } // Put these at the beginning, so they don't change the positioning of the other // icons. row.insertBefore(entry, row.querySelector(".first-icon")); count++; // Limit this in case people are putting a million links in their profiles. if(count == 4) break; } // Tell the context menu which user is being viewed (if we're viewing a user-specific // search). main_context_menu.get.user_info = user_info; } // Use different icons for sites where you can give the artist money. This helps make // the string of icons more meaningful (some artists have a lot of them). find_link_image_type(url) { url = new URL(url); let alt_icons = { ".shopping-cart": [ "dlsite.com", "fanbox.cc", "fantia.jp", "skeb.jp", "ko-fi.com", "dmm.co.jp", ] }; // Special case for old Fanbox URLs that were under the Pixiv domain. if((url.hostname == "pixiv.net" || url.hostname == "www.pixiv.net") && url.pathname.startsWith("/fanbox/")) return ".shopping-cart"; for(let alt in alt_icons) { // "domain.com" matches domain.com and *.domain.com. for(let domain of alt_icons[alt]) { if(url.hostname == domain) return alt; if(url.hostname.endsWith("." + domain)) return alt; } } return null; }; set active(active) { if(this._active == active) return; this._active = active; super.active = active; if(active) { this.initial_refresh_ui(); this.refresh_ui(); // Refresh the images now, so it's possible to scroll to entries, but wait to start // loading data to give the caller a chance to call scroll_to_illust_id(), which needs // to happen after refresh_images but before load_needed_thumb_data. This way, if // we're showing a page far from the top, we won't load the first page that we're about // to scroll away from. this.refresh_images(); setTimeout(function() { this.load_needed_thumb_data(); }.bind(this), 0); } else { this.stop_pulsing_thumbnail(); main_context_menu.get.user_info = null; } } get active() { return this._active; } data_source_updated() { this.refresh_images(); this.load_needed_thumb_data(); this.refresh_ui(); } // Recreate thumbnail images (the actual elements). // // This is done when new pages are loaded, to create the correct number of images. // We don't need to do this when scrolling around or when new thumbnail data is available. refresh_images() { // Make a list of [illust_id, page] thumbs to add. var images_to_add = []; if(this.data_source != null) { var id_list = this.data_source.id_list; var min_page = id_list.get_lowest_loaded_page(); var max_page = id_list.get_highest_loaded_page(); var items_per_page = this.data_source.estimated_items_per_page; for(var page = min_page; page <= max_page; ++page) { var illust_ids = id_list.illust_ids_by_page[page]; if(illust_ids == null) { // This page isn't loaded. Fill the gap with items_per_page blank entries. for(var idx = 0; idx < items_per_page; ++idx) images_to_add.push([null, page]); continue; } // Create an image for each ID. for(var illust_id of illust_ids) images_to_add.push({id: illust_id, page: page}); } // If this data source supports a start page and we started after page 1, add the "load more" // button at the beginning. // // The page number for this button is the same as the thumbs that follow it, not the // page it'll load if clicked, so scrolling to it doesn't make us think we're scrolled // to that page. if(this.data_source.initial_page > 1) images_to_add.splice(0, 0, { id: "special:previous-page", page: this.data_source.initial_page }); } // Add thumbs. // // Most of the time we're just adding thumbs to the list. Avoid removing or recreating // thumbs that aren't actually changing, which reduces flicker. // // Do this by looking for a range of thumbnails that matches a range in images_to_add. // If we're going to display [0,1,2,3,4,5,6,7,8,9], and the current thumbs are [4,5,6], // then 4,5,6 matches and can be reused. We'll add [0,1,2,3] to the beginning and [7,8,9] // to the end. // // Most of the time we're just appending. The main time that we add to the beginning is // the "load previous results" button. var ul = this.container.querySelector("ul.thumbnails"); var next_node = ul.firstElementChild; // Make a dictionary of all illust IDs and pages, so we can look them up quickly. let images_to_add_index = {}; for(let i = 0; i < images_to_add.length; ++i) { let entry = images_to_add[i]; let illust_id = entry.id; let page = entry.page; let index = illust_id + "/" + page; images_to_add_index[index] = i; } let get_node_idx = function(node) { if(node == null) return null; let illust_id = node.dataset.illust_id; let page = node.dataset.page; let index = illust_id + "/" + page; return images_to_add_index[index]; } // Find the first match (4 in the above example). let first_matching_node = next_node; while(first_matching_node && get_node_idx(first_matching_node) == null) first_matching_node = first_matching_node.nextElementSibling; // If we have a first_matching_node, walk forward to find the last matching node (6 in // the above example). let last_matching_node = first_matching_node; if(last_matching_node != null) { // Make sure the range is contiguous. first_matching_node and all nodes through last_matching_node // should match a range exactly. If there are any missing entries, stop. let next_expected_idx = get_node_idx(last_matching_node) + 1; while(last_matching_node && get_node_idx(last_matching_node.nextElementSibling) == next_expected_idx) { last_matching_node = last_matching_node.nextElementSibling; next_expected_idx++; } } // If we have a matching range, save the scroll position relative to it, so if we add // new elements at the top, we stay scrolled where we are. Otherwise, just restore the // current scroll position. let save_scroll = new SaveScrollPosition(this.container); if(first_matching_node) save_scroll.save_relative_to(first_matching_node); // If we have a range, delete all items outside of it. Otherwise, just delete everything. while(first_matching_node && first_matching_node.previousElementSibling) first_matching_node.previousElementSibling.remove(); while(last_matching_node && last_matching_node.nextElementSibling) last_matching_node.nextElementSibling.remove(); if(!first_matching_node && !last_matching_node) helpers.remove_elements(ul); // If we have a matching range, add any new elements before it. if(first_matching_node) { let first_idx = get_node_idx(first_matching_node); for(let idx = first_idx - 1; idx >= 0; --idx) { let entry = images_to_add[idx]; var illust_id = entry.id; var page = entry.page; var node = this.create_thumb(illust_id, page); first_matching_node.insertAdjacentElement("beforebegin", node); first_matching_node = node; } } // Add any new elements after the range. If we don't have a range, just add everything. let last_idx = -1; if(last_matching_node) last_idx = get_node_idx(last_matching_node); for(let idx = last_idx + 1; idx < images_to_add.length; ++idx) { let entry = images_to_add[idx]; var illust_id = entry.id; var page = entry.page; var node = this.create_thumb(illust_id, page); ul.appendChild(node); } if(this.container.offsetWidth == 0) return; let thumbnail_size = settings.get("thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".view-search-container", { wide: true, size: thumbnail_size, max_columns: 5, // Set a minimum padding to make sure there's room for the popup text to fit between images. min_padding: 15, }); // Restore the value of scrollTop from before we updated. For some reason, Firefox // modifies scrollTop after we add a bunch of items, which causes us to scroll to // the wrong position, even though scrollRestoration is disabled. save_scroll.restore(); } // Start loading data pages that we need to display visible thumbs, and start // loading thumbnail data for nearby thumbs. async load_needed_thumb_data() { // elements is a list of elements that are onscreen (or close to being onscreen). // We want thumbnails loaded for these, even if we need to load more thumbnail data. // // nearby_elements is a list of elements that are a bit further out. If we load // thumbnail data for elements, we'll load these instead. That way, if we scroll // up a bit and two more thumbs become visible, we'll load a bigger chunk. // That way, we make fewer batch requests instead of requesting two or three // thumbs at a time. // Make a list of pages that we need loaded, and illustrations that we want to have // set. var wanted_illust_ids = []; var need_thumbnail_data = false; var elements = this.get_visible_thumbnails(false); for(var element of elements) { if(element.dataset.illust_id != null) { // If this is an illustration, add it to wanted_illust_ids so we load its thumbnail // info. Don't do this if it's a user. if(helpers.id_type(element.dataset.illust_id) == "illust") wanted_illust_ids.push(element.dataset.illust_id); } } // We load pages when the last thumbs on the previous page are loaded, but the first // time through there's no previous page to reach the end of. Always make sure the // first page is loaded (usually page 1). let load_page = null; let first_page = this.data_source? this.data_source.initial_page:1; if(this.data_source && !this.data_source.is_page_loaded_or_loading(first_page)) load_page = first_page; // If the last thumb in the list is being loaded, we need the next page to continue. // Note that since get_visible_thumbnails returns thumbs before they actually scroll // into view, this will happen before the last thumb is actually visible to the user. var ul = this.container.querySelector("ul.thumbnails"); if(load_page == null && elements.length > 0 && elements[elements.length-1] == ul.lastElementChild) { let last_element = elements[elements.length-1]; load_page = parseInt(last_element.dataset.page)+1; } // Hide "no results" if it's shown while we load data. this.container.querySelector(".no-results").hidden = true; if(load_page != null) { console.log("Load page", load_page, "for thumbnails"); this.container.querySelector(".no-results").hidden = true; var result = await this.data_source.load_page(load_page); // If this page didn't load, it probably means we've reached the end, so stop trying // to load more pages. if(!result) this.disable_loading_more_pages = true; } // If we have no IDs and nothing is loading, the data source is empty (no results). if(this.data_source && this.data_source.id_list.get_first_id() == null && !this.data_source.any_page_loading) { console.log("Showing no results"); this.container.querySelector(".no-results").hidden = false; } if(!thumbnail_data.singleton().are_all_ids_loaded_or_loading(wanted_illust_ids)) { // At least one visible thumbnail needs to be loaded, so load more data at the same // time. var nearby_elements = this.get_visible_thumbnails(true); var nearby_illust_ids = []; for(var element of nearby_elements) { if(element.dataset.illust_id == null) continue; nearby_illust_ids.push(element.dataset.illust_id); } // console.log("Wanted:", wanted_illust_ids.join(", ")); // console.log("Nearby:", nearby_illust_ids.join(", ")); // Load the thumbnail data if needed. thumbnail_data.singleton().get_thumbnail_info(nearby_illust_ids); } this.set_visible_thumbs(); } // Handle clicks on the "load previous results" button. // // If we let the regular click handling in main_controller.set_current_data_source do this, // it'll notice that the requested page isn't loaded and create a new data source. We know // we can view the previous page, so special case this so we don't lose the pages that are // already loaded. // // This can also trigger for the "return to start" button if we happen to be on page 2. async thumbnail_onclick(e) { // This only matters if the data source supports start pages. if(!this.data_source.supports_start_page) return; let a = e.target.closest("A"); if(a == null) return; // See if this link is for this data source, one page before the current start page. let args = helpers.get_args(document.location); let page = this.data_source.get_start_page(args); this.data_source.set_start_page(args, page-1); let previous_page_url = helpers.get_url_from_args(args).toString(); let clicked_url = new URL(e.target.href, document.location).toString(); // console.log("Previous page:", previous_page_url); // console.log("Clicked:", clicked_url); if(clicked_url.toString() != previous_page_url.toString()) return; e.preventDefault(); e.stopImmediatePropagation(); console.log("Loading previous page:", page-1); await this.data_source.load_page(page-1); } update_from_settings() { var thumbnail_mode = settings.get("thumbnail-size"); this.set_visible_thumbs(); this.refresh_images(); helpers.set_class(document.body, "light", settings.get("theme") == "light"); helpers.set_class(document.body, "disable-thumbnail-panning", settings.get("disable_thumbnail_panning")); helpers.set_class(document.body, "disable-thumbnail-zooming", settings.get("disable_thumbnail_zooming")); helpers.set_class(document.body, "ui-on-hover", settings.get("ui-on-hover")); // Flush the top UI transition, so it doesn't animate weirdly when toggling ui-on-hover. for(let box of document.querySelectorAll(".top-ui-box")) { box.classList.add("disable-transition"); box.offsetHeight; box.classList.remove("disable-transition"); } } // Set the URL for all loaded thumbnails that are onscreen. // // This won't trigger loading any data (other than the thumbnails themselves). set_visible_thumbs() { // Make a list of IDs that we're assigning. var elements = this.get_visible_thumbnails(); var illust_ids = []; for(var element of elements) { if(element.dataset.illust_id == null) continue; illust_ids.push(element.dataset.illust_id); } for(var element of elements) { var illust_id = element.dataset.illust_id; if(illust_id == null) continue; var search_mode = this.data_source.search_mode; let thumb_type = helpers.id_type(illust_id); let thumb_id = helpers.actual_id(illust_id); let thumb_data = {}; // For illustrations, get thumbnail info. If we don't have it yet, skip the image (leave it pending) // and we'll come back once we have it. if(thumb_type == "illust") { // Get thumbnail info. var info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(info == null) continue; } // Leave it alone if it's already been loaded. if(!("pending" in element.dataset)) continue; // Why is this not working in FF? It works in the console, but not here. Sandboxing // issue? // delete element.dataset.pending; element.removeAttribute("data-pending"); if(thumb_type == "user" || thumb_type == "bookmarks") { // This is a user thumbnail rather than an illustration thumbnail. It just shows a small subset // of info. let user_id = helpers.actual_id(illust_id); var link = element.querySelector("a.thumbnail-link"); if(thumb_type == "user") link.href = "/users/" + user_id + "#ppixiv"; else link.href = "/users/" + user_id + "/bookmarks/artworks#ppixiv"; link.dataset.userId = user_id; let quick_user_data = thumbnail_data.singleton().get_quick_user_data(user_id); if(quick_user_data == null) { // We should always have this data for users if the data source asked us to display this user. throw "Missing quick user data for user ID " + user_id; } var thumb = element.querySelector(".thumb"); thumb.src = quick_user_data.profileImageUrl; var label = element.querySelector(".thumbnail-label"); label.hidden = false; label.querySelector(".label").innerText = quick_user_data.userName; // Point the "similar illustrations" thumbnail button to similar users for this result, so you can // chain from one set of suggested users to another. element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + user_id; continue; } if(illust_id == "special:previous-page") { // 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 args = helpers.get_args(document.location); let page = this.data_source.get_start_page(args); this.data_source.set_start_page(args, page-1); element.querySelector("a.load-previous-page-link").href = helpers.get_url_from_args(args); this.data_source.set_start_page(args, 1); element.querySelector("a.load-first-page-link").href = helpers.get_url_from_args(args); continue; } if(thumb_type != "illust") throw "Unexpected thumb type: " + thumb_type; // Set this thumb. var url = info.url; var thumb = element.querySelector(".thumb"); // Check if this illustration is muted (blocked). var muted_tag = muting.singleton.any_tag_muted(info.tags); var muted_user = muting.singleton.is_muted_user_id(info.userId); if(muted_tag || muted_user) { element.classList.add("muted"); // The image will be obscured, but we still shouldn't load the image the user blocked (which // is something Pixiv does wrong). Load the user profile image instead. thumb.src = info.profileImageUrl; element.querySelector(".muted-label").textContent = muted_tag? muted_tag:info.userName; // We can use this if we want a "show anyway' UI. thumb.dataset.mutedUrl = url; } else { thumb.src = url; // The search page thumbs are always square (aspect ratio 1). helpers.set_thumbnail_panning_direction(element, info.width, info.height, 1); } // Set the link. Setting dataset.illustId will allow this to be handled with in-page // navigation, and the href will allow middle click, etc. to work normally. // // If we're on the followed users page, set these to the artist page instead. var link = element.querySelector("a.thumbnail-link"); if(search_mode == "users") { link.href = "/users/" + info.userId + "#ppixiv"; } else { link.href = "/artworks/" + illust_id + "#ppixiv"; } link.dataset.illustId = illust_id; link.dataset.userId = info.userId; // Don't show this UI when we're in the followed users view. if(search_mode == "illusts") { if(info.illustType == 2) element.querySelector(".ugoira-icon").hidden = false; if(info.pageCount > 1) { var pageCountBox = element.querySelector(".page-count-box"); pageCountBox.hidden = false; pageCountBox.href = link.href + "?view=manga"; element.querySelector(".page-count-box .page-count").textContent = info.pageCount; } } helpers.set_class(element, "dot", helpers.tags_contain_dot(info)); // On most pages, the suggestions button in thumbnails shows similar illustrations. On following, // show similar artists instead. if(search_mode == "users") element.querySelector("A.similar-illusts-button").href = "/discovery/users#ppixiv?user_id=" + info.userId; else element.querySelector("A.similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv?recommendations=1"; this.refresh_bookmark_icon(element); // Set the label. This is only actually shown in following views. var label = element.querySelector(".thumbnail-label"); if(search_mode == "users") { label.hidden = false; label.querySelector(".label").innerText = info.userName; } else { label.hidden = true; } } } // Refresh the thumbnail for illust_id. // // This is used to refresh the bookmark icon when changing a bookmark. refresh_thumbnail(illust_id) { var ul = this.container.querySelector("ul.thumbnails"); var thumbnail_element = ul.querySelector("[data-illust_id=\"" + illust_id + "\"]"); if(thumbnail_element == null) return; this.refresh_bookmark_icon(thumbnail_element); } // Set the bookmarked heart for thumbnail_element. This can change if the user bookmarks // or un-bookmarks an image. refresh_bookmark_icon(thumbnail_element) { if(this.data_source && this.data_source.search_mode == "users") return; var illust_id = thumbnail_element.dataset.illust_id; if(illust_id == null) return; // Get thumbnail info. var thumbnail_info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id); if(thumbnail_info == null) return; var show_bookmark_heart = thumbnail_info.bookmarkData != null; if(this.data_source != null && !this.data_source.show_bookmark_icons) show_bookmark_heart = false; thumbnail_element.querySelector(".heart.public").hidden = !show_bookmark_heart || thumbnail_info.bookmarkData.private; thumbnail_element.querySelector(".heart.private").hidden = !show_bookmark_heart || !thumbnail_info.bookmarkData.private; } // Return a list of thumbnails that are either visible, or close to being visible // (so we load thumbs before they actually come on screen). // // If extra is true, return more offscreen thumbnails. get_visible_thumbnails(extra) { // 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 []; // We'll load thumbnails when they're within this number of pixels from being onscreen. var threshold = 450; var ul = this.container.querySelector("ul.thumbnails"); var elements = []; var bounds_top = this.container.scrollTop - threshold; var bounds_bottom = this.container.scrollTop + this.container.offsetHeight + threshold; for(var element = ul.firstElementChild; element != null; element = element.nextElementSibling) { if(element.offsetTop + element.offsetHeight < bounds_top) continue; if(element.offsetTop > bounds_bottom) continue; elements.push(element); } if(extra) { // Expand the list outwards to include more thumbs. var expand_by = 20; var expand_upwards = true; while(expand_by > 0) { if(!elements[0].previousElementSibling && !elements[elements.length-1].nextElementSibling) { // Stop if there's nothing above or below our results to add. break; } if(!expand_upwards && elements[0].previousElementSibling) { elements.unshift(elements[0].previousElementSibling); expand_by--; } else if(expand_upwards && elements[elements.length-1].nextElementSibling) { elements.push(elements[elements.length-1].nextElementSibling); expand_by--; } expand_upwards = !expand_upwards; } } return elements; } // Create a thumb placeholder. This doesn't load the image yet. // // illust_id is the illustration this will be if it's displayed, or null if this // is a placeholder for pages we haven't loaded. page is the page this illustration // is on (whether it's a placeholder or not). create_thumb(illust_id, page) { let template_type = ".template-thumbnail"; if(illust_id == "special:previous-page") template_type = ".template-load-previous-results"; // Cache a reference to the thumbnail template. We can do this a lot, and this // query takes a lot of page setup time if we run it for each thumb. if(this.thumbnail_templates[template_type] == null) this.thumbnail_templates[template_type] = document.body.querySelector(template_type); let entry = helpers.create_from_template(this.thumbnail_templates[template_type]); // Mark that this thumb hasn't been filled in yet. entry.dataset.pending = true; if(illust_id != null) entry.dataset.illust_id = illust_id; entry.dataset.page = page; this.topmost_illust_observer.observe(entry); return entry; } // This is called when thumbnail_data has loaded more thumbnail info. thumbs_loaded(e) { this.set_visible_thumbs(); } // Scroll to illust_id if it's available. This is called when we display the thumbnail view // after coming from an illustration. scroll_to_illust_id(illust_id) { var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']"); if(thumb == null) return; // If the item isn't visible, center it. var scroll_pos = this.container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight) this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2; }; pulse_thumbnail(illust_id) { var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']"); if(thumb == null) return; this.stop_pulsing_thumbnail(); this.flashing_image = thumb; thumb.classList.add("flash"); }; // Work around a bug in CSS animations: even if animation-iteration-count is 1, // the animation will play again if the element is hidden and displayed again, which // causes previously-flashed thumbnails to flash every time we exit and reenter // thumbnails. stop_pulsing_thumbnail() { if(this.flashing_image == null) return; this.flashing_image.classList.remove("flash"); this.flashing_image = null; }; // Handle submitting searches on the user search page. submit_user_search(e) { let search = this.container.querySelector(".user-search-box input.search-users").value; let url = new URL("/search_user.php#ppixiv", document.location); url.searchParams.append("nick", search); url.searchParams.append("s_mode", "s_usr"); helpers.set_page_url(url, true); } }; // A full page viewer for manga thumbnails. // // This is similar to the main search view. It doesn't share code, since it // works differently enough that it would complicate things too much. class view_manga extends view { constructor(container) { super(container); this.refresh_ui = this.refresh_ui.bind(this); this.window_onresize = this.window_onresize.bind(this); this.refresh_images = this.refresh_images.bind(this); window.addEventListener("resize", this.window_onresize); this.progress_bar = main_controller.singleton.progress_bar; this.ui = new image_ui(this.container.querySelector(".ui-container"), this.progress_bar); this.scroll_positions_by_illust_id = {}; image_data.singleton().user_modified_callbacks.register(this.refresh_ui); image_data.singleton().illust_modified_callbacks.register(this.refresh_ui); settings.register_change_callback("manga-thumbnail-size", this.refresh_images); // Create a style for our thumbnail style. this.thumbnail_dimensions_style = helpers.create_style(""); document.body.appendChild(this.thumbnail_dimensions_style); this.active = false; } window_onresize(e) { if(!this.active) return; console.log("resize"); this.refresh_images(); } set active(active) { if(this.active == active) return; this._active = active; if(!active) { // Save the old scroll position. if(this.illust_id != null) { console.log("save scroll position for", this.illust_id, this.container.scrollTop); this.scroll_positions_by_illust_id[this.illust_id] = this.container.scrollTop; } // Hide the dropdown tag widget. this.ui.bookmark_tag_widget.visible = false; // Stop showing the user in the context menu. main_context_menu.get.user_info = null; } super.active = active; if(active) this.load_illust_id(); } get active() { return this._active; } get shown_illust_id() { return this.illust_id; } set shown_illust_id(illust_id) { if(this.illust_id == illust_id) return; // The load itself is async and might not happen immediately if we don't have page info yet. // Clear any previous image list so it doesn't flash on screen while we load the new info. let ul = this.container.querySelector("ul.thumbnails"); helpers.remove_elements(ul); this.illust_id = illust_id; this.illust_info = null; this.ui.illust_id = illust_id; // Refresh even if illust_id is null, so we quickly clear the view. this.refresh_ui(); if(this.illust_id == null) return; if(!this.active) return; this.load_illust_id(); } async load_illust_id() { if(this.illust_id == null) return; console.log("Loading manga view for:", this.illust_id); // Load image info. var illust_info = await image_data.singleton().get_image_info(this.illust_id); if(illust_info.id != this.illust_id) return; this.illust_info = illust_info; this.refresh_ui(); } get displayed_illust_id() { return this.illust_id; } refresh_ui() { if(!this._active) return; helpers.set_title_and_icon(this.illust_info); // Tell the context menu which user is being viewed. main_context_menu.get.user_info = this.illust_info.userInfo; this.refresh_images(); } refresh_images() { var original_scroll_top = this.container.scrollTop; // Remove all existing entries and collect them. var ul = this.container.querySelector("ul.thumbnails"); helpers.remove_elements(ul); if(this.illust_info == null) return; // Get the aspect ratio to crop images to. var ratio = this.get_display_aspect_ratio(this.illust_info.mangaPages); // // console.log("size", size); let thumbnail_size = settings.get("manga-thumbnail-size", 4); thumbnail_size = thumbnail_size_slider_widget.thumbnail_size_for_value(thumbnail_size); this.thumbnail_dimensions_style.textContent = helpers.make_thumbnail_sizing_style(ul, ".view-manga-container", { wide: true, size: thumbnail_size, ratio: ratio, // We preload this page anyway since it doesn't cause a lot of API calls, so we // can allow a high column count and just let the size take over. max_columns: 15, }); for(var page = 0; page < this.illust_info.mangaPages.length; ++page) { var manga_page = this.illust_info.mangaPages[page]; var entry = this.create_thumb(page, manga_page); var link = entry.querySelector(".thumbnail-link"); helpers.set_thumbnail_panning_direction(entry, manga_page.width, manga_page.height, ratio); ul.appendChild(entry); } // Restore the value of scrollTop from before we updated. For some reason, Firefox // modifies scrollTop after we add a bunch of items, which causes us to scroll to // the wrong position, even though scrollRestoration is disabled. this.container.scrollTop = original_scroll_top; } // Given a list of manga infos, return the aspect ratio we'll crop them to. get_display_aspect_ratio(manga_info) { // A lot of manga posts use the same resolution for all images, or just have // one or two exceptions for things like title pages. If most images have // about the same aspect ratio, use it. var total = 0; for(var manga_page of manga_info) total += manga_page.width / manga_page.height; var average_aspect_ratio = total / manga_info.length; var illusts_far_from_average = 0; for(var manga_page of manga_info) { var ratio = manga_page.width / manga_page.height; if(Math.abs(average_aspect_ratio - ratio) > 0.1) illusts_far_from_average++; } // If we didn't find a common aspect ratio, just use square thumbs. if(illusts_far_from_average > 3) return 1; else return average_aspect_ratio; } get_display_resolution(width, height) { var fit_width = 300; var fit_height = 300; var ratio = width / fit_width; if(ratio > 1) { height /= ratio; width /= ratio; } var ratio = height / fit_height; if(ratio > 1) { height /= ratio; width /= ratio; } return [width, height]; } create_thumb(page_idx, manga_page) { if(this.thumbnail_template == null) this.thumbnail_template = document.body.querySelector(".template-manga-view-thumbnail"); var element = helpers.create_from_template(this.thumbnail_template); // These URLs should be the 540x540_70 master version, which is a non-squared high-res // thumbnail. These tend to be around 30-40k, so loading a full manga set of them is // quick. // // XXX: switch this to 540x540_10_webp in Chrome, around 5k? var thumb = element.querySelector(".thumb"); var url = manga_page.urls.small; // url = url.replace("/540x540_70/", "/540x540_10_webp/"); thumb.src = url; var size = this.get_display_resolution(manga_page.width, manga_page.height); thumb.width = size[0]; thumb.height = size[1]; var link = element.querySelector("a.thumbnail-link"); link.href = "/artworks/" + this.illust_id + "#ppixiv?page=" + (page_idx+1); link.dataset.illustId = this.illust_id; link.dataset.pageIdx = page_idx; element.dataset.pageIdx = page_idx; return element; } scroll_to_top() { // Read offsetHeight to force layout to happen. If we don't do this, setting scrollTop // sometimes has no effect in Firefox. this.container.offsetHeight; this.container.scrollTop = 0; console.log("scroll to top", this.container.scrollTop, this.container.hidden, this.container.offsetHeight); } restore_scroll_position() { // If we saved a scroll position when navigating away from a data source earlier, // restore it now. Only do this once. var scroll_pos = this.scroll_positions_by_illust_id[this.illust_id]; if(scroll_pos != null) { console.log("scroll pos:", scroll_pos); this.container.scrollTop = scroll_pos; delete this.scroll_positions_by_illust_id[this.illust_id]; } else this.scroll_to_top(); } scroll_to_illust_id(illust_id, manga_page) { if(manga_page == null) return; var thumb = this.container.querySelector('[data-page-idx="' + manga_page + '"]'); if(thumb == null) return; console.log("Scrolling to", thumb); // If the item isn't visible, center it. var scroll_pos = this.container.scrollTop; if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight) this.container.scrollTop = thumb.offsetTop + thumb.offsetHeight/2 - this.container.offsetHeight/2; } handle_onkeydown(e) { this.ui.handle_onkeydown(e); } } // This handles the overlay UI on the illustration page. class image_ui { constructor(container, progress_bar) { this.image_data_loaded = this.image_data_loaded.bind(this); this.clicked_download = this.clicked_download.bind(this); this.refresh = this.refresh.bind(this); this.container = container; this.progress_bar = progress_bar; this.ui = helpers.create_from_template(".template-image-ui"); this.container.appendChild(this.ui); this.avatar_widget = new avatar_widget({ parent: this.container.querySelector(".avatar-popup"), mode: "dropdown", }); this.tag_widget = new tag_widget({ parent: this.container.querySelector(".tag-list"), }); // Set up hover popups. dropdown_menu_opener.create_handlers(this.container, [".image-settings-menu-box"]); image_data.singleton().illust_modified_callbacks.register(this.refresh); this.bookmark_tag_widget = new bookmark_tag_list_widget(this.container.querySelector(".popup-bookmark-tag-dropdown-container")); this.toggle_tag_widget = new toggle_bookmark_tag_list_widget(this.container.querySelector(".button-bookmark-tags"), this.bookmark_tag_widget); this.like_button = new like_button_widget(this.container.querySelector(".button-like")); // The bookmark buttons, and clicks in the tag dropdown: this.bookmark_buttons = []; for(var a of this.container.querySelectorAll(".button-bookmark")) this.bookmark_buttons.push(new bookmark_button_widget(a, a.classList.contains("private"), this.bookmark_tag_widget)); this.container.querySelector(".download-button").addEventListener("click", this.clicked_download); this.container.querySelector(".navigate-out-button").addEventListener("click", function(e) { main_controller.singleton.navigate_out(); }.bind(this)); var settings_menu = this.container.querySelector(".settings-menu-box > .popup-menu-box"); menu_option.add_settings(settings_menu); } set data_source(data_source) { if(this._data_source == data_source) return; this._data_source = data_source; this.refresh(); } shutdown() { image_data.singleton().illust_modified_callbacks.unregister(this.refresh); this.avatar_widget.shutdown(); } get illust_id() { return this._illust_id; } set illust_id(illust_id) { if(this._illust_id == illust_id) return; this._illust_id = illust_id; this.illust_data = null; image_data.singleton().get_image_info(illust_id).then((illust_info) => { this.image_data_loaded(illust_info); }).catch((e) => { console.error(e); }); this.like_button.illust_id = illust_id; this.bookmark_tag_widget.illust_id = illust_id; this.toggle_tag_widget.illust_id = illust_id; for(var button of this.bookmark_buttons) button.illust_id = illust_id; } handle_onkeydown(e) { this.avatar_widget.handle_onkeydown(e); if(e.defaultPrevented) return; if(e.keyCode == 66) // b { // b to bookmark publically, B to bookmark privately, ^B to remove a bookmark. // // Use a separate hotkey to remove bookmarks, rather than toggling like the bookmark // button does, so you don't have to check whether an image is bookmarked. You can // just press B to bookmark without worrying about accidentally removing a bookmark // instead. e.stopPropagation(); e.preventDefault(); var illust_data = this.illust_data; if(illust_data == null) return; if(e.altKey) { actions.add_new_tag(this._illust_id); return; } if(e.ctrlKey) { // Remove the bookmark. if(illust_data.bookmarkData == null) { message_widget.singleton.show("Image isn't bookmarked"); return; } actions.bookmark_remove(illust_data); return; } if(illust_data.bookmarkData) { message_widget.singleton.show("Already bookmarked (^B to remove bookmark)"); return; } actions.bookmark_add(illust_data, { private: e.shiftKey }); return; } if(e.ctrlKey || e.altKey || e.metaKey) return; switch(e.keyCode) { case 86: // v e.stopPropagation(); e.preventDefault(); actions.like_image(this.illust_data); return; } } image_data_loaded(illust_data) { if(illust_data.illustId != this._illust_id) return; this.illust_data = illust_data; this.refresh(); } refresh() { if(this.illust_data == null) return; var illust_data = this.illust_data; var illust_id = illust_data.illustId; var user_data = illust_data.userInfo; // 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_data.userId; 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_from_user_data(user_data); this.tag_widget.set(illust_data.tags); var element_title = this.container.querySelector(".title"); element_title.textContent = illust_data.illustTitle; element_title.href = "/artworks/" + illust_id + "#ppixiv"; var element_author = this.container.querySelector(".author"); element_author.textContent = user_data.name; element_author.href = "/users/" + user_data.userId + "#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_data.userId; this.container.querySelector(".similar-bookmarks-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv"; // Fill in the post info text. this.set_post_info(this.container.querySelector(".post-info")); // The comment (description) can contain HTML. var element_comment = this.container.querySelector(".description"); element_comment.hidden = illust_data.illustComment == ""; element_comment.innerHTML = illust_data.illustComment; helpers.fix_pixiv_links(element_comment); helpers.make_pixiv_links_internal(element_comment); // Set the download button popup text. if(this.illust_data != null) { var download_type = actions.get_download_type_for_image(this.illust_data); var download_button = this.container.querySelector(".download-button"); download_button.hidden = download_type == null; if(download_type != null) download_button.dataset.popup = "Download " + download_type; } // Set the popup for the thumbnails button. var navigate_out_label = main_controller.singleton.navigate_out_label; var title = navigate_out_label != null? ("Return to " + navigate_out_label):""; this.container.querySelector(".navigate-out-button").dataset.popup = title; } set_post_info(post_info_container) { var illust_data = this.illust_data; var set_info = (query, text) => { var node = post_info_container.querySelector(query); node.innerText = text; node.hidden = text == ""; }; var seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000; set_info(".post-age", helpers.age_to_string(seconds_old) + " ago"); post_info_container.querySelector(".post-age").dataset.popup = helpers.date_to_string(illust_data.createDate); var info = ""; // Add the resolution and file type if available. if(this.displayed_page != null && this.illust_data != null) { var page_info = this.illust_data.mangaPages[this.displayed_page]; page_info.width; info += page_info.width + "x" + page_info.height; } var ext = this.viewer? this.viewer.current_image_type:null; if(ext != null) info += " " + ext; set_info(".image-info", info); var duration = ""; if(illust_data.illustType == 2) { var seconds = 0; for(var frame of illust_data.ugoiraMetadata.frames) seconds += frame.delay / 1000; var duration = seconds.toFixed(duration >= 10? 0:1); duration += seconds == 1? " second":" seconds"; } set_info(".ugoira-duration", duration); set_info(".ugoira-frames", illust_data.illustType == 2? (illust_data.ugoiraMetadata.frames.length + " frames"):""); // Add the page count for manga. var page_text = ""; if(illust_data.pageCount > 1 && this.displayed_page != null) page_text = "Page " + (this.displayed_page+1) + "/" + illust_data.pageCount; set_info(".page-count", page_text); } // Set the resolution to display in image info. If both are null, no resolution // is displayed. set_displayed_page_info(page) { this.displayed_page = page; this.refresh(); } clicked_download(e) { if(this.illust_data == null) return; var clicked_button = e.target.closest(".download-button"); if(clicked_button == null) return; e.preventDefault(); e.stopPropagation(); actions.download_illust(this.illust_data, this.progress_bar.controller()); } } // Handle showing the search history and tag edit dropdowns. class tag_search_box_widget { constructor(container) { this.input_onfocus = this.input_onfocus.bind(this); this.input_onblur = this.input_onblur.bind(this); this.container_onmouseenter = this.container_onmouseenter.bind(this); this.container_onmouseleave = this.container_onmouseleave.bind(this); this.submit_search = this.submit_search.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); this.dropdown_widget = new tag_search_dropdown_widget(container); this.edit_widget = new tag_search_edit_widget(container); this.container.addEventListener("mouseenter", this.container_onmouseenter); this.container.addEventListener("mouseleave", this.container_onmouseleave); this.input_element.addEventListener("focus", this.input_onfocus); this.input_element.addEventListener("blur", this.input_onblur); let edit_button = this.container.querySelector(".edit-search-button"); if(edit_button) { edit_button.addEventListener("click", (e) => { // Toggle the edit widget, hiding the search history dropdown if it's shown. if(this.dropdown_widget.shown) this.hide(); if(this.edit_widget.shown) this.hide(); else this.show_edit(); }); } // Search submission: helpers.input_handler(this.input_element, this.submit_search); this.container.querySelector(".search-submit-button").addEventListener("click", this.submit_search); // Hide the dropdowns on navigation. new view_hidden_listener(this.input_element, (e) => { this.hide(); }); } async show_history() { // Don't show history if search editing is already open. if(this.edit_widget.shown) return; this.dropdown_widget.show(); } show_edit() { // Don't show search editing if history is already open. if(this.dropdown_widget.shown) return; this.edit_widget.show(); // Disable adding searches to search history while the edit dropdown is open. Otherwise, // every time a tag is toggled, that combination of tags is added to search history by // data_source_search, which makes a mess. helpers.disable_adding_search_tags(true); } hide() { helpers.disable_adding_search_tags(false); this.dropdown_widget.hide(); this.edit_widget.hide(); } container_onmouseenter(e) { this.mouse_over_parent = true; } container_onmouseleave(e) { this.mouse_over_parent = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } // Show the dropdown when the input is focused. Hide it when the input is both // unfocused and this.container isn't being hovered. This way, the input focus // can leave the input box to manipulate the dropdown without it being hidden, // but we don't rely on hovering to keep the dropdown open. input_onfocus(e) { this.input_focused = true; if(!this.dropdown_widget.shown && !this.edit_widget.shown) this.show_history(); } input_onblur(e) { this.input_focused = false; if(this.dropdown_widget.shown && !this.input_focused && !this.mouse_over_parent) this.hide(); } submit_search(e) { // This can be sent to either the search page search box or the one in the // navigation dropdown. Figure out which one we're on. var search_box = e.target.closest(".search-box"); var tags = this.input_element.value.trim(); if(tags.length == 0) return; // Add this tag to the recent search list. helpers.add_recent_search_tag(tags); // If we're submitting by pressing enter on an input element, unfocus it and // close any widgets inside it (tag dropdowns). if(e.target instanceof HTMLInputElement) { e.target.blur(); view_hidden_listener.send_viewhidden(e.target); } // Run the search. helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(tags), true); } } class tag_search_dropdown_widget { constructor(container) { this.dropdown_onclick = this.dropdown_onclick.bind(this); this.input_onkeydown = this.input_onkeydown.bind(this); this.input_oninput = this.input_oninput.bind(this); this.populate_dropdown = this.populate_dropdown.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); this.input_element.addEventListener("keydown", this.input_onkeydown); this.input_element.addEventListener("input", this.input_oninput); // Refresh the dropdown when the tag search history changes. window.addEventListener("recent-tag-searches-changed", this.populate_dropdown); // Add the dropdown widget. this.tag_dropdown = helpers.create_from_template(".template-tag-dropdown"); this.tag_dropdown.addEventListener("click", this.dropdown_onclick); this.container.appendChild(this.tag_dropdown); this.current_autocomplete_results = []; // input-dropdown is resizable. Save the size when the user drags it. this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown"); let observer = new MutationObserver((mutations) => { // resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads // as 0 here. settings.set("tag-dropdown-width", this.input_dropdown.style.width); }); observer.observe(this.input_dropdown, { attributes: true }); // Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly. this.input_dropdown.style.width = settings.get("tag-dropdown-width", "400px"); this.shown = false; this.tag_dropdown.hidden = true; // Sometimes the popup closes when searches are clicked and sometimes they're not. Make sure // we always close on navigation. this.tag_dropdown.addEventListener("click", (e) => { if(e.defaultPrevented) return; let a = e.target.closest("A"); if(a == null) return; this.input_element.blur(); this.hide(); }); } dropdown_onclick(e) { var remove_entry = e.target.closest(".remove-history-entry"); if(remove_entry != null) { // Clicked X to remove a tag from history. e.stopPropagation(); e.preventDefault(); var tag = e.target.closest(".entry").dataset.tag; helpers.remove_recent_search_tag(tag); return; } // Close the dropdown if the user clicks a tag (but not when clicking // remove-history-entry). if(e.target.closest(".tag")) this.hide(); } input_onkeydown(e) { // Only handle inputs when we're open. if(this.tag_dropdown.hidden) return; switch(e.keyCode) { case 38: // up arrow case 40: // down arrow e.preventDefault(); e.stopImmediatePropagation(); this.move(e.keyCode == 40); break; } } input_oninput(e) { if(this.tag_dropdown.hidden) return; // Clear the selection on input. this.set_selection(null); // Update autocomplete when the text changes. this.run_autocomplete(); } async show() { if(this.shown) return; this.shown = true; // Fill in the dropdown before displaying it. If hide() is called before this // finishes this will return false, so stop. if(!await this.populate_dropdown()) return; this.tag_dropdown.hidden = false; } hide() { if(!this.shown) return; this.shown = false; // If populate_dropdown is still running, cancel it. this.cancel_populate_dropdown(); this.tag_dropdown.hidden = true; // Make sure the input isn't focused. this.input_element.blur(); } async run_autocomplete() { // If true, this is a value change caused by keyboard navigation. Don't run autocomplete, // since we don't want to change the dropdown due to navigating in it. if(this.navigating) return; var tags = this.input_element.value.trim(); // Stop if we're already up to date. if(this.most_recent_search == tags) return; if(this.autocomplete_request != null) { // If an autocomplete request is already running, let it finish before we // start another. This matches the behavior of Pixiv's input forms. console.log("Delaying search for", tags); return; } if(tags == "") { // Don't send requests with an empty string. Just finish the search synchronously, // so we clear the autocomplete immediately. if(this.abort_autocomplete != null) this.abort_autocomplete.abort(); this.autocomplete_request_finished("", { candidates: [] }); return; } // Run the search. try { this.abort_autocomplete = new AbortController(); var result = await helpers.rpc_get_request("/rpc/cps.php", { keyword: tags, }, { signal: this.abort_autocomplete.signal, }); this.autocomplete_request_finished(tags, result); } catch(e) { console.info("Tag autocomplete aborted:", e); } finally { this.abort_autocomplete = null; } } // A tag autocomplete request finished. autocomplete_request_finished(tags, result) { this.most_recent_search = tags; this.abort_autocomplete = null; // Store the new results. this.current_autocomplete_results = result.candidates || []; // Refresh the dropdown with the new results. this.populate_dropdown(); // If the input element's value has changed since we started this search, we // stalled any other autocompletion. Start it now. if(tags != this.input_element.value) { console.log("Run delayed autocomplete"); this.run_autocomplete(); } } // tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations. create_entry(tag_search, translated_tags) { var entry = helpers.create_from_template(".template-tag-dropdown-entry"); entry.dataset.tag = tag_search; let translated_tag = translated_tags[tag_search]; if(translated_tag) entry.dataset.translated_tag = translated_tag; let tag_container = entry.querySelector(".search"); for(let tag of helpers.split_search_tags(tag_search)) { if(tag == "") continue; // Force "or" lowercase. if(tag.toLowerCase() == "or") tag = "or"; let span = document.createElement("span"); span.dataset.tag = tag; span.classList.add("word"); if(tag == "or") span.classList.add("or"); else span.classList.add("tag"); // Split off - prefixes to look up the translation, then add it back. let prefix_and_tag = helpers.split_tag_prefixes(tag); let translated_tag = translated_tags[prefix_and_tag[1]]; if(translated_tag) translated_tag = prefix_and_tag[0] + translated_tag; span.innerText = translated_tag || tag; if(translated_tag) span.dataset.translated_tag = translated_tag; tag_container.appendChild(span); } var url = page_manager.singleton().get_url_for_tag_search(tag_search); entry.href = url; return entry; } set_selection(idx) { // Temporarily set this.navigating to true. This lets run_autocomplete know that // it shouldn't run an autocomplete request for this value change. this.navigating = true; try { // If there's an autocomplete request in the air, cancel it. if(this.abort_autocomplete != null) this.abort_autocomplete.abort(); // Clear any old selection. var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry"); if(this.selected_idx != null) all_entries[this.selected_idx].classList.remove("selected"); // Set the new selection. this.selected_idx = idx; if(this.selected_idx != null) { var new_entry = all_entries[this.selected_idx]; new_entry.classList.add("selected"); this.input_element.value = new_entry.dataset.tag; } } finally { this.navigating = false; } } // Select the next or previous entry in the dropdown. move(down) { var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry"); // Stop if there's nothing in the list. var total_entries = all_entries.length; if(total_entries == 0) return; var idx = this.selected_idx; if(idx == null) idx = down? 0:(total_entries-1); else idx += down? +1:-1; idx %= total_entries; this.set_selection(idx); } // Populate the tag dropdown. // // This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection. // Network APIs should be async, but local I/O should not be forced async.) If another // call to populate_dropdown() is made before this completes or cancel_populate_dropdown // cancels it, return false. If it completes, return true. async populate_dropdown() { // If another populate_dropdown is already running, cancel it and restart. this.cancel_populate_dropdown(); // Set populate_dropdown_abort to an AbortController for this call. let abort_controller = this.populate_dropdown_abort = new AbortController(); let abort_signal = abort_controller.signal; var tag_searches = settings.get("recent-tag-searches") || []; // Separate tags in each search, so we can look up translations. // var all_tags = {}; for(let tag_search of tag_searches) { for(let tag of helpers.split_search_tags(tag_search)) { tag = helpers.split_tag_prefixes(tag)[1]; all_tags[tag] = true; } } all_tags = Object.keys(all_tags); let translated_tags = await tag_translations.get().get_translations(all_tags, "en"); // Check if we were aborted while we were loading tags. if(abort_signal && abort_signal.aborted) { console.log("populate_dropdown_inner aborted"); return false; } var list = this.tag_dropdown.querySelector(".input-dropdown-list"); helpers.remove_elements(list); 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(); } } class tag_search_edit_widget { constructor(container) { this.dropdown_onclick = this.dropdown_onclick.bind(this); this.populate_dropdown = this.populate_dropdown.bind(this); this.container = container; this.input_element = this.container.querySelector(".search-tags"); // Refresh the dropdown when the tag search history changes. window.addEventListener("recent-tag-searches-changed", this.populate_dropdown); // Add the dropdown widget. this.tag_dropdown = helpers.create_from_template(".template-edit-search-dropdown"); this.tag_dropdown.addEventListener("click", this.dropdown_onclick); this.container.appendChild(this.tag_dropdown); // Refresh tags if the user edits the search directly. this.input_element.addEventListener("input", (e) => { this.refresh_highlighted_tags(); }); // input-dropdown is resizable. Save the size when the user drags it. this.input_dropdown = this.tag_dropdown.querySelector(".input-dropdown"); let observer = new MutationObserver((mutations) => { // resize sets the width. Use this instead of offsetWidth, since offsetWidth sometimes reads // as 0 here. settings.set("search-edit-dropdown-width", this.input_dropdown.style.width); }); observer.observe(this.input_dropdown, { attributes: true }); // Restore input-dropdown's width. Force a minimum width, in case this setting is saved incorrectly. this.input_dropdown.style.width = settings.get("search-edit-dropdown-width", "400px"); this.shown = false; this.tag_dropdown.hidden = true; } dropdown_onclick(e) { e.preventDefault(); e.stopImmediatePropagation(); // Clicking tags toggles the tag in the search box. let tag = e.target.closest(".tag"); if(tag == null) return; this.toggle_tag(tag.dataset.tag); // Control-clicking the tag probably caused its enclosing search link to be focused, which will // cause it to activate when enter is pressed. Switch focus to the input box, so pressing enter // will submit the search. this.input_element.focus(); } async show() { if(this.shown) return; this.shown = true; // Fill in the dropdown before displaying it. If hide() is called before this // finishes this will return false, so stop. if(!await this.populate_dropdown()) return; this.tag_dropdown.hidden = false; } hide() { if(!this.shown) return; this.shown = false; // If populate_dropdown is still running, cancel it. this.cancel_populate_dropdown(); this.tag_dropdown.hidden = true; // Make sure the input isn't focused. this.input_element.blur(); } // tag_search is a search, like "tag -tag2". translated_tags is a dictionary of known translations. create_entry(tag_search, translated_tags) { var entry = helpers.create_from_template(".template-edit-search-dropdown-entry"); entry.dataset.tag = tag_search; let translated_tag = translated_tags[tag_search]; if(translated_tag) entry.dataset.translated_tag = translated_tag; let tag_container = entry.querySelector(".search"); for(let tag of helpers.split_search_tags(tag_search)) { if(tag == "") continue; let span = document.createElement("span"); span.dataset.tag = tag; span.classList.add("word"); if(tag != "or") span.classList.add("tag"); // Split off - prefixes to look up the translation, then add it back. let prefix_and_tag = helpers.split_tag_prefixes(tag); let translated_tag = translated_tags[prefix_and_tag[1]]; if(translated_tag) translated_tag = prefix_and_tag[0] + translated_tag; span.innerText = translated_tag || tag; if(translated_tag) span.dataset.translated_tag = translated_tag; tag_container.appendChild(span); } var url = page_manager.singleton().get_url_for_tag_search(tag_search); entry.querySelector("A.search").href = url; return entry; } // Populate the tag dropdown. // // This is async, since IndexedDB is async. (It shouldn't be. It's an overcorrection. // Network APIs should be async, but local I/O should not be forced async.) If another // call to populate_dropdown() is made before this completes or cancel_populate_dropdown // cancels it, return false. If it completes, return true. async populate_dropdown() { // If another populate_dropdown is already running, cancel it and restart. this.cancel_populate_dropdown(); // Set populate_dropdown_abort to an AbortController for this call. let abort_controller = this.populate_dropdown_abort = new AbortController(); let abort_signal = abort_controller.signal; var tag_searches = settings.get("recent-tag-searches") || []; // Individually show all tags in search history. var all_tags = {}; for(let tag_search of tag_searches) { for(let tag of helpers.split_search_tags(tag_search)) { tag = helpers.split_tag_prefixes(tag)[1]; // Ignore "or". if(tag == "" || tag == "or") continue; all_tags[tag] = true; } } all_tags = Object.keys(all_tags); let translated_tags = await tag_translations.get().get_translations(all_tags, "en"); // Sort tags by their translation. all_tags.sort((lhs, rhs) => { if(translated_tags[lhs]) lhs = translated_tags[lhs]; if(translated_tags[rhs]) rhs = translated_tags[rhs]; return lhs.localeCompare(rhs); }); // Check if we were aborted while we were loading tags. if(abort_signal && abort_signal.aborted) { console.log("populate_dropdown_inner aborted"); return false; } var list = this.tag_dropdown.querySelector(".input-dropdown-list"); helpers.remove_elements(list); for(var tag of all_tags) { var entry = this.create_entry(tag, translated_tags); list.appendChild(entry); } this.refresh_highlighted_tags(); return true; } cancel_populate_dropdown() { if(this.populate_dropdown_abort == null) return; this.populate_dropdown_abort.abort(); } refresh_highlighted_tags() { let tags = helpers.split_search_tags(this.input_element.value); var list = this.tag_dropdown.querySelector(".input-dropdown-list"); for(let tag_entry of list.querySelectorAll("[data-tag]")) { let tag = tag_entry.dataset.tag; let tag_selected = tags.indexOf(tag) != -1; helpers.set_class(tag_entry, "highlight", tag_selected); } } // Add or remove tag from the tag search. This doesn't affect -tag searches. toggle_tag(tag) { console.log("Toggle tag:", tag); let tags = helpers.split_search_tags(this.input_element.value); let idx = tags.indexOf(tag); if(idx != -1) tags.splice(idx, 1); else tags.push(tag); this.input_element.value = tags.join(" "); this.refresh_highlighted_tags(); // Navigate to the edited search immediately. Don't add these to history, since it // spams navigation history. helpers.set_page_url(page_manager.singleton().get_url_for_tag_search(this.input_element.value), false); } } class tag_translations { // 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("pp_tag_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". data[tag.tag] = { tag: tag.tag, translation: translation, romaji: tag.romaji, }; } // Batch write: await this.db.multi_set(data); } async get_tag_info(tags) { // If the user has disabled translations, don't return any. if(settings.get("disable-translations")) return {}; let result = {}; let translations = await this.db.multi_get(tags); for(let i = 0; i < tags.length; ++i) { if(translations[i] == null) continue; result[tags[i]] = translations[i]; } return result; } async get_translations(tags, language) { let info = await this.get_tag_info(tags); let result = {}; for(let tag of tags) { if(info[tag] == null || info[tag].translation == null) continue; // Skip this tag if we don't have a translation for this language. let translation = info[tag].translation[language]; if(translation == null) continue; result[tag] = translation; } return result; } // Given a tag search, return a translated search. async translate_tag_list(tags, language) { // Pull out individual tags, removing -prefixes. let split_tags = helpers.split_search_tags(tags); let tag_list = []; for(let tag of split_tags) { let prefix_and_tag = helpers.split_tag_prefixes(tag); tag_list.push(prefix_and_tag[1]); } // Get translations. let translated_tags = await this.get_translations(tag_list, language); // Put the search back together. let result = []; for(let one_tag of split_tags) { let prefix_and_tag = helpers.split_tag_prefixes(one_tag); let prefix = prefix_and_tag[0]; let tag = prefix_and_tag[1]; if(translated_tags[tag]) tag = translated_tags[tag]; result.push(prefix + tag); } return result; } } // 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). class thumbnail_data { constructor() { this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this); // Cached data: this.thumbnail_data = { }; this.quick_user_data = { }; // IDs that we're currently requesting: this.loading_ids = {}; }; // Return the singleton, creating it if needed. static singleton() { if(thumbnail_data._singleton == null) thumbnail_data._singleton = new thumbnail_data(); return thumbnail_data._singleton; }; // Return true if all thumbs in illust_ids have been loaded, or are currently loading. // // We won't start fetching IDs that aren't loaded. are_all_ids_loaded_or_loading(illust_ids) { for(var illust_id of illust_ids) { if(this.thumbnail_data[illust_id] == null && !this.loading_ids[illust_id]) return false; } return true; } // Return thumbnail data for illud_id, or null if it's not loaded. // // The thumbnail data won't be loaded if it's not already available. Use get_thumbnail_info // to load thumbnail data in batches. get_one_thumbnail_info(illust_id) { return this.thumbnail_data[illust_id]; } // Return thumbnail data for illust_ids, and start loading any requested IDs that aren't // already loaded. get_thumbnail_info(illust_ids) { var result = {}; var needed_ids = []; for(var illust_id of illust_ids) { var data = this.thumbnail_data[illust_id]; if(data == null) { // If this is a user:user_id instead of an illust ID, make sure we don't request it // as an illust ID. if(illust_id.indexOf(":") != -1) continue; needed_ids.push(illust_id); continue; } result[illust_id] = data; } // Load any thumbnail data that we didn't have. if(needed_ids.length) this.load_thumbnail_info(needed_ids); return result; } // Load thumbnail info for the given list of IDs. async load_thumbnail_info(illust_ids) { // Make a list of IDs that we're not already loading. var ids_to_load = []; for(var id of illust_ids) if(this.loading_ids[id] == null) ids_to_load.push(id); if(ids_to_load.length == 0) return; for(var id of ids_to_load) this.loading_ids[id] = true; // There's also // // https://www.pixiv.net/ajax/user/user_id/profile/illusts?ids[]=1&ids[]=2&... // // which is used by newer pages. That's useful since it tells us whether each // image is bookmarked. However, it doesn't tell us the user's name or profile image // URL, and for some reason it's limited to a particular user. Hopefully they'll // have an updated generic illustration lookup call if they ever update the // regular search pages, and we can switch to it then. var result = await helpers.rpc_get_request("/rpc/illust_list.php", { illust_ids: ids_to_load.join(","), // Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150 // ones we'll get if we don't (though changing the URL is easy enough too). page: "discover", // We do our own muting, but for some reason this flag is needed to get bookmark info. exclude_muted_illusts: 1, }); this.loaded_thumbnail_info(result, "illust_list"); } // Get the mapping from /ajax/user/id/illusts/bookmarks to illust_list.php's keys. get thumbnail_info_map_illust_list() { if(this._thumbnail_info_map_illust_list != null) return this._thumbnail_info_map_illust_list; this._thumbnail_info_map_illust_list = [ ["illust_id", "id"], ["url", "url"], ["tags", "tags"], ["illust_user_id", "userId"], ["illust_width", "width"], ["illust_height", "height"], ["illust_type", "illustType"], ["illust_page_count", "pageCount"], ["illust_title", "title"], ["user_profile_img", "profileImageUrl"], ["user_name", "userName"], ]; return this._thumbnail_info_map_illust_list; }; // Get the mapping from search.php and bookmark_new_illust.php to illust_list.php's keys. get thumbnail_info_map_following() { if(this._thumbnail_info_map_following != null) return this._thumbnail_info_map_following; this._thumbnail_info_map_following = [ ["illustId", "id"], ["url", "url"], ["tags", "tags"], ["userId", "userId"], ["width", "width"], ["height", "height"], ["pageCount", "pageCount"], ["illustTitle", "title"], ["userName", "userName"], ["illustType", "illustType"], // ["user_profile_img", "profileImageUrl"], ]; return this._thumbnail_info_map_following; }; 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_page_count", "pageCount"], ["title", "title"], ["user_name", "userName"], ["illust_type", "illustType"], // ["profile_img", "profileImageUrl"], ]; return this._thumbnail_info_map_ranking; }; // Given a low-res thumbnail URL from thumbnail data, return a high-res thumbnail URL. get_high_res_thumbnail_url(url) { // 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, document.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"; return url.toString(); } // This is called when we have new thumbnail data available. thumb_result is // an array of thumbnail items. // // This can come from a bunch of different places, which all return the same data, but // each in a different way: // // name URL // normal /ajax/user/id/illusts/bookmarks // illust_list illust_list.php // following bookmark_new_illust.php // following search.php // rankings ranking.php // // We map each of these to "normal". // // These have the same data, but for some reason everything has different names. // Remap them to "normal", and check that all fields we expect exist, to make it // easier to notice if something is wrong. loaded_thumbnail_info(thumb_result, source) { if(thumb_result.error) return; var thumbnail_info_map = this.thumbnail_info_map_illust_list; var urls = []; for(var thumb_info of thumb_result) { // Ignore entries with "isAdContainer". These aren't search results at all and just contain // stuff we're not interested in. if(thumb_info.isAdContainer) continue; if(source == "normal") { // The data is already in the format we want. Just check that all keys we // expect exist, and remove any keys we don't know about so we don't use them // accidentally. var thumbnail_info_map = this.thumbnail_info_map_illust_list; var remapped_thumb_info = { }; for(var pair of thumbnail_info_map) { var 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 == "following" || source == "rankings") { // Get the mapping for this mode. var thumbnail_info_map = source == "illust_list"? this.thumbnail_info_map_illust_list: source == "following"? this.thumbnail_info_map_following: this.thumbnail_info_map_ranking; var remapped_thumb_info = { }; for(var pair of thumbnail_info_map) { var from_key = pair[0]; var to_key = pair[1]; if(!(from_key in thumb_info)) { console.warn("Thumbnail info is missing key:", from_key); continue; } var 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(source == "illust_list" || source == "rankings") { 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, }; } } else if(source == "following") { // Why are there fifteen API variants for everything? It's as if they // hire a contractor for every feature and nobody ever talks to each other, // so every feature has its own new API layout. if(!('isBookmarked' in thumb_info)) console.warn("Thumbnail info is missing key: isBookmarked"); if(thumb_info.isBookmarked) { remapped_thumb_info.bookmarkData = { private: thumb_info.isPrivateBookmark, }; } } // illustType can be a string in these instead of an int, so convert it. remapped_thumb_info.illustType = parseInt(remapped_thumb_info.illustType); // Some of these APIs don't provide the user's avatar URL. We only use it in a blurred- // out thumbnail for muted images, so just drop in the "no avatar" image. if(remapped_thumb_info.profileImageUrl == null) remapped_thumb_info.profileImageUrl = "https://s.pximg.net/common/images/no_profile_s.png"; } else throw "Unrecognized source: " + source; // Different APIs return different thumbnail URLs. remapped_thumb_info.url = this.get_high_res_thumbnail_url(remapped_thumb_info.url); // 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]); } thumb_info = remapped_thumb_info; // Store the data. this.add_thumbnail_info(thumb_info); var illust_id = thumb_info.id; delete this.loading_ids[illust_id]; // Don't preload muted images. if(!this.is_muted(thumb_info)) urls.push(thumb_info.url); } // Broadcast that we have new thumbnail data available. window.dispatchEvent(new Event("thumbnailsLoaded")); }; // Store thumbnail info. add_thumbnail_info(thumb_info) { var illust_id = thumb_info.id; this.thumbnail_data[illust_id] = thumb_info; } is_muted(thumb_info) { if(muting.singleton.is_muted_user_id(thumb_info.illust_user_id)) return true; if(muting.singleton.any_tag_muted(thumb_info.tags)) return true; return false; } // This is a simpler form of thumbnail data for user info. This is just the bare minimum // info we need to be able to show a user thumbnail on the search page. // // We can get this info from two places, the following page (data_source_follows) and the // user recommendations page (data_source_discovery_users). Of course, since Pixiv never // does anything the same way twice, they have different formats. // // The only info we need is: // userId // userName // profileImageUrl add_quick_user_data(user_data, source) { let data = null; if(source == "following") { data = { userId: user_data.userId, userName: user_data.userName, profileImageUrl: user_data.profileImageUrl, }; } else if(source == "recommendations" || source == "users_bookmarking_illust" || source == "user_search") { data = { userId: user_data.user_id, userName: user_data.user_name, profileImageUrl: user_data.profile_img, }; } else throw "Unknown source: " + source; this.quick_user_data[data.userId] = data; } get_quick_user_data(user_id) { return this.quick_user_data[user_id]; } } class scroll_handler { constructor(container) { this.container = container; } // Bring item into view. We'll also try to keep the next and previous items visible. scroll_into_view(item) { // Make sure item is a direct child of the container. if(item.parentNode != this.container) { console.error("Node", item, "isn't in scroller", this.container); return; } // Scroll so the items to the left and right of the current thumbnail are visible, // so you can tell whether there's another entry to scroll to. If we can't fit // them, center the selection. var scroller_left = this.container.getBoundingClientRect().left; var left = item.offsetLeft - scroller_left; if(item.previousElementSibling) left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left); var right = item.offsetLeft + item.offsetWidth - scroller_left; if(item.nextElementSibling) right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left); var new_left = this.container.scrollLeft; if(new_left > left) new_left = left; if(new_left + this.container.offsetWidth < right) new_left = right - this.container.offsetWidth; this.container.scrollLeft = new_left; // If we didn't fit the previous and next entries, there isn't enough space. This // might be a wide thumbnail or the window might be very narrow. Just center the // selection. Note that we need to compare against the value we assigned and not // read scrollLeft back, since the API is broken and reads back the smoothed value // rather than the target we set. if(new_left > left || new_left + this.container.offsetWidth < right) { this.center_item(item); } } // Scroll the given item to the center. center_item(item) { var scroller_left = this.container.getBoundingClientRect().left; var left = item.offsetLeft - scroller_left; left += item.offsetWidth/2; left -= this.container.offsetWidth / 2; this.container.scrollLeft = left; } /* Snap to the target position, cancelling any smooth scrolling. */ snap() { this.container.style.scrollBehavior = "auto"; if(this.container.firstElementChild) this.container.firstElementChild.getBoundingClientRect(); this.container.getBoundingClientRect(); this.container.style.scrollBehavior = ""; } }; class manga_thumbnail_widget { constructor(container) { this.onclick = this.onclick.bind(this); this.onmouseenter = this.onmouseenter.bind(this); this.onmouseleave = this.onmouseleave.bind(this); this.check_image_loads = this.check_image_loads.bind(this); this.window_onresize = this.window_onresize.bind(this); window.addEventListener("resize", this.window_onresize); this.container = container; this.container.addEventListener("click", this.onclick); this.container.addEventListener("mouseenter", this.onmouseenter); this.container.addEventListener("mouseleave", this.onmouseleave); this.cursor = document.createElement("div"); this.cursor.classList.add("thumb-list-cursor"); this.scroll_box = this.container.querySelector(".manga-thumbnails"); this.scroller = new scroll_handler(this.scroll_box); this.visible = false; this.set_illust_info(null); } // Both Firefox and Chrome have some nasty layout bugs when resizing the window, // causing the flexbox and the images inside it to be incorrect. Work around it // by forcing a refresh. window_onresize(e) { this.refresh(); } onmouseenter(e) { this.hovering = true; this.refresh_visible(); } onmouseleave(e) { this.stop_hovering(); } stop_hovering() { this.hovering = false; this.refresh_visible(); } refresh_visible() { this.visible = this.hovering; } get visible() { return this.container.classList.contains("visible"); } set visible(visible) { if(visible == this.visible) return; helpers.set_class(this.container, "visible", visible); if(!visible) this.stop_hovering(); } onclick(e) { var arrow = e.target.closest(".manga-thumbnail-arrow"); if(arrow != null) { e.preventDefault(); e.stopPropagation(); var left = arrow.dataset.direction == "left"; console.log("scroll", left); var new_page = this.current_page + (left? -1:+1); if(new_page < 0 || new_page >= this.entries.length) return; main_controller.singleton.show_illust(this.illust_info.illustId, { manga_page: new_page, }); /* var entry = this.entries[new_page]; if(entry == null) return; this.scroller.scroll_into_view(entry); */ return; } var thumb = e.target.closest(".manga-thumbnail-box"); if(thumb != null) { e.preventDefault(); e.stopPropagation(); var new_page = parseInt(thumb.dataset.page); main_controller.singleton.show_illust(this.illust_info.illustId, { manga_page: new_page, }); return; } } set_illust_info(illust_info) { if(illust_info == this.illust_info) return; // Only display if we have at least two pages. if(illust_info != null && illust_info.pageCount < 2) illust_info = null; // If we're not on a manga page, hide ourselves entirely, including the hover box. this.container.hidden = illust_info == null; this.illust_info = illust_info; if(illust_info == null) this.stop_hovering(); // Refresh the thumb images. this.refresh(); // Start or stop check_image_loads if needed. if(this.illust_info == null && this.check_image_loads_timer != null) { clearTimeout(this.check_image_loads_timer); this.check_image_loads_timer = null; } this.check_image_loads(); } snap_transition() { this.scroller.snap(); } // This is called when the manga page is changed externally. current_page_changed(page) { // Ignore page changes if we're not displaying anything. if(this.illust_info == null) return this.current_page = page; if(this.current_page == null) return; // Find the entry for the page. var entry = this.entries[this.current_page]; if(entry == null) { console.error("Scrolled to unknown page", this.current_page); return; } this.scroller.scroll_into_view(entry); if(this.selected_entry) helpers.set_class(this.selected_entry, "selected", false); this.selected_entry = entry; if(this.selected_entry) { helpers.set_class(this.selected_entry, "selected", true); this.update_cursor_position(); } } update_cursor_position() { // Wait for images to know their size before positioning the cursor. if(this.selected_entry == null || this.waiting_for_images || this.cursor.parentNode == null) return; // Position the cursor to the position of the selection. this.cursor.style.width = this.selected_entry.offsetWidth + "px"; var scroller_left = this.scroll_box.getBoundingClientRect().left; var base_left = this.cursor.parentNode.getBoundingClientRect().left; var position_left = this.selected_entry.getBoundingClientRect().left; var left = position_left - base_left; this.cursor.style.left = left + "px"; } // We can't update the UI properly until we know the size the thumbs will be, // and the site doesn't tell us the size of manga pages (only the first page). // Work around this by hiding until we have naturalWidth for all images, which // will allow layout to complete. There's no event for this for some reason, // so the only way to detect it is with a timer. // // This often isn't needed because of image preloading. check_image_loads() { if(this.illust_info == null) return; this.check_image_loads_timer = null; var all_images_loaded = true; for(var img of this.container.querySelectorAll("img.manga-thumb")) { if(img.naturalWidth == 0) all_images_loaded = false; } // If all images haven't loaded yet, check again. if(!all_images_loaded) { this.waiting_for_images = true; this.check_image_loads_timer = setTimeout(this.check_image_loads, 10); return; } this.waiting_for_images = false; // Now that we know image sizes and layout can update properly, we can update the cursor's position. this.update_cursor_position(); } refresh() { if(this.cursor.parentNode) this.cursor.parentNode.removeChild(this.cursor); var ul = this.container.querySelector(".manga-thumbnails"); helpers.remove_elements(ul); this.entries = []; if(this.illust_info == null) return; // Add left and right padding elements to center the list if needed. var left_padding = document.createElement("div"); left_padding.style.flex = "1"; ul.appendChild(left_padding); for(var page = 0; page < this.illust_info.pageCount; ++page) { var page_info = this.illust_info.mangaPages[page]; var url = page_info.urls.small; var img = document.createElement("img"); var entry = helpers.create_from_template(".template-manga-thumbnail"); entry.dataset.page = page; entry.querySelector("img.manga-thumb").src = url; ul.appendChild(entry); this.entries.push(entry); } var right_padding = document.createElement("div"); right_padding.style.flex = "1"; ul.appendChild(right_padding); // Place the cursor inside the first entry, so it follows it around as we scroll. this.entries[0].appendChild(this.cursor); this.update_cursor_position(); } }; // 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. class page_manager { constructor() { this.window_popstate = this.window_popstate.bind(this); window.addEventListener("popstate", this.window_popstate, true); this.data_sources_by_canonical_url = {}; this.active = this._active_internal(); }; // Return the singleton, creating it if needed. static singleton() { if(page_manager._singleton == null) page_manager._singleton = new page_manager(); return page_manager._singleton; }; // Return the data source for a URL, or null if the page isn't supported. get_data_source_for_url(url) { // url is usually document.location, which for some reason doesn't have .searchParams. var url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); let first_part = helpers.get_page_type_from_url(url); if(first_part == "artworks") return data_source_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_source_artist. let mode = helpers.get_path_part(url, 2); if(mode == "following") return data_source_follows; if(mode != "bookmarks") return data_source_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_source_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_source_bookmarks_merged. Otherwise, // use data_source_bookmarks. var hash_args = helpers.get_hash_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 && hash_args.get("show-all") != "0"; return both_public_and_private? data_source_bookmarks_merged:data_source_bookmarks; } else if(url.pathname == "/bookmark.php" && url.searchParams.get("type") == null) { // Note: This code is copied and pasted from the above. There's no point in combining this // code, it'll be removed soon once we're sure bookmark.php is rolled out to all users. // // 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_source_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_source_bookmarks_merged. Otherwise, // use data_source_bookmarks. var hash_args = helpers.get_hash_args(url); var query_args = url.searchParams; var user_id = query_args.get("id"); 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 && hash_args.get("show-all") != "0"; return both_public_and_private? data_source_bookmarks_merged:data_source_bookmarks; } else if(url.pathname == "/bookmark.php" && url.searchParams.get("type") == "user") return data_source_follows; else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php") return data_source_new_illust; else if(url.pathname == "/bookmark_new_illust.php" || url.pathname == "/bookmark_new_illust_r18.php") return data_source_bookmarks_new_illust; else if(first_part == "tags") return data_source_search; else if(url.pathname == "/discovery") return data_source_discovery; else if(url.pathname == "/discovery/users") return data_source_discovery_users; else if(url.pathname == "/bookmark_detail.php") { // If we've added "recommendations" to the hash info, this was a recommendations link. var hash_args = helpers.get_hash_args(url); if(hash_args.get("recommendations")) return data_source_related_illusts; else return data_source_related_favorites; } else if(url.pathname == "/ranking.php") return data_source_rankings; else if(url.pathname == "/search_user.php") return data_source_search_users; else return null; }; // Create the data source for a given URL. // // If we've already created a data source for this URL, the same one will be // returned. // // If force is true, we'll always create a new data source, replacing any // previously created one. async create_data_source_for_url(url, doc, force) { var data_source_class = this.get_data_source_for_url(url); if(data_source_class == null) { console.error("Unexpected path:", url.pathname); return; } // Canonicalize the URL to see if we already have a data source for this URL. var canonical_url = await data_source_class.get_canonical_url(url); // console.log("url", url.toString(), "becomes", canonical_url); if(!force && canonical_url in this.data_sources_by_canonical_url) { // console.log("Reusing data source for", url.toString()); return this.data_sources_by_canonical_url[canonical_url]; } // console.log("Creating new data source for", url.toString()); var source = new data_source_class(url.href, doc); this.data_sources_by_canonical_url[canonical_url] = source; return source; } // Return true if it's possible for us to be active on this page. available() { // We support the page if it has a data source. return this.get_data_source_for_url(document.location) != null; }; window_popstate(e) { var currently_active = this._active_internal(); if(this.active == currently_active) return; // Stop propagation, so other listeners don't see this. For example, this prevents // the thumbnail viewer from turning on or off as a result of us changing the hash // to "#no-ppixiv". e.stopImmediatePropagation(); if(this.active == currently_active) return; this.store_ppixiv_disabled(!currently_active); console.log("Active state changed"); // The URL has changed and caused us to want to activate or deactivate. Reload the // page. // // We'd prefer to reload with cache, like a regular navigation, but Firefox seems // to reload without cache no matter what we do, even though document.location.reload // is only supposed to bypass cache on reload(true). There doesn't seem to be any // reliable workaround. document.location.reload(); } store_ppixiv_disabled(disabled) { // Remember that we're enabled or disabled in this tab. if(disabled) window.sessionStorage.ppixiv_disabled = 1; else delete window.sessionStorage.ppixiv_disabled; } // Return true if we're active by default on the current page. active_by_default() { // If the disabled-by-default setting is enabled, disable by default until manually // turned on. if(settings.get("disabled-by-default")) return false; // If this is set, the user clicked the "return to Pixiv" button. Stay disabled // in this tab until we're reactivated. if(window.sessionStorage.ppixiv_disabled) return false; return this.available(); }; // 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(document.location.hash == "") return this.active_by_default(); // If we have a hash and it's not #ppixiv, then we're explicitly disabled. If we // # do have a #ppixiv hash, we're explicitly enabled. // // If we're explicitly enabled but aren't actually available, we're disabled. This // makes sure we don't break pages if we accidentally load them with a #ppixiv hash, // or if we remove support for a page that people have in their browser session. return helpers.parse_hash(document.location) != null && this.available(); }; // 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) { let url = new URL(document.location); 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", document.location); } return url; } } // Fix Pixiv's annoying link interstitials. // // External links on Pixiv go through a pointless extra page. This seems like // they're trying to mask the page the user is coming from, but that's what // rel=noreferrer is for. Search for these links and fix them. // // This also removes target=_blank, which is just obnoxious. If I want a new // tab I'll middle click. (function() { // Ignore iframes. if(window.top != window.self) return; var observer = new window.MutationObserver(function(mutations) { for(var mutation of mutations) { if(mutation.type != 'childList') return; for(var node of mutation.addedNodes) { if(node.querySelectorAll == null) continue; helpers.fix_pixiv_links(node); } } }); window.addEventListener("DOMContentLoaded", function() { helpers.fix_pixiv_links(document.body); observer.observe(window.document.body, { // We could listen to attribute changes so we'll fix links that have their // target changed after they're added to the page, but unless there are places // where that's needed, let's just listen to node additions so we don't trigger // too often. attributes: false, childList: true, subtree: true }); }, true); })(); // Handle preloading images. // // If we have a reasonably fast connection and the site is keeping up, we can just preload // blindly and let the browser figure out priorities. However, if we preload too aggressively // for the connection and loads start to back up, it can cause image loading to become delayed. // For example, if we preload 100 manga page images, and then back out of the page and want to // view something else, the browser won't load anything else until those images that we no // longer need finish loading. // // image_preloader is told the illust_id that we're currently showing, and the ID that we want // to speculatively load. We'll run loads in parallel, giving the current image's resources // priority and cancelling loads when they're no longer needed. // // This doesn't handle thumbnail preloading. Those are small and don't really need to be // cancelled, and since we don't fill the browser's load queue here, we shouldn't prevent // thumbnails from being able to load. // A base class for fetching a single resource: class _preloader { // Cancel the fetch. cancel() { if(this.abort_controller == null) return; this.abort_controller.abort(); this.abort_controller = null; } } // Load a single image with : class _img_preloader extends _preloader { constructor(url) { super(); this.url = url; } // Start the fetch. This should only be called once. async start() { this.abort_controller = new AbortController(); await helpers.decode_image(this.url, this.abort_controller.signal); } } // Load a resource with XHR. We rely on helpers.fetch_resource to make concurrent // loads with zip_image_player work cleanly. class _xhr_preloader extends _preloader { constructor(url) { super(); this.url = url; } async start() { this.abort_controller = new AbortController(); await helpers.fetch_resource(this.url, { signal: this.abort_controller.signal, }); } } // The image preloader singleton. class image_preloader { // Return the singleton, creating it if needed. static get singleton() { if(image_preloader._singleton == null) image_preloader._singleton = new image_preloader(); return image_preloader._singleton; }; constructor() { // The _preloader objects that we're currently running. this.preloads = []; // A queue of URLs that we've finished preloading recently. We use this to tell if // we don't need to run a preload. this.recently_preloaded_urls = []; } // Set the illust_id the user is currently viewing. If illust_id is null, the user isn't // viewing an image (eg. currently viewing thumbnails). async set_current_image(illust_id) { if(this.current_illust_id == illust_id) return; this.current_illust_id = illust_id; this.current_illust_info = null; if(this.current_illust_id == null) return; // Get the image data. This will often already be available. var illust_info = await image_data.singleton().get_image_info(this.current_illust_id); if(this.current_illust_id != illust_id || this.current_illust_info != null) return; // Store the illust_info for current_illust_id. this.current_illust_info = illust_info; // Preload thumbnails. this.preload_thumbs(illust_info); this.check_fetch_queue(); } // Set the illust_id we want to speculatively load, which is the next or previous image in // the current search. If illust_id is null, we don't want to speculatively load anything. async set_speculative_image(illust_id) { if(this.speculative_illust_id == illust_id) return; this.speculative_illust_id = illust_id; this.speculative_illust_info = null; if(this.speculative_illust_id == null) return; // Get the image data. This will often already be available. var illust_info = await image_data.singleton().get_image_info(this.speculative_illust_id); if(this.speculative_illust_id != illust_id || this.speculative_illust_info != null) return; // Store the illust_info for current_illust_id. this.speculative_illust_info = illust_info; // Preload thumbnails. this.preload_thumbs(illust_info); this.check_fetch_queue(); } // See if we need to start or stop preloads. We do this when we have new illustration info, // and when a fetch finishes. check_fetch_queue() { // console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null); // Make a list of fetches that we want to be running, in priority order. var wanted_preloads = []; if(this.current_illust_info != null) wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info)); if(this.speculative_illust_info != null) wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info)); // Remove all preloads from wanted_preloads that we've already finished recently. var filtered_preloads = []; for(var 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. var 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. var unwanted_preloads; var updated_preload_list = []; for(let preload of filtered_preloads) { // Start this preload. // console.log("Start preload:", preload.url); preload.start().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. 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. var 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(var 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(var 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) { // Don't precache muted images. if(muting.singleton.any_tag_muted(illust_data.tags.tags)) return []; if(muting.singleton.is_muted_user_id(illust_data.userId)) return []; // If this is a video, preload the ZIP. if(illust_data.illustType == 2) { var results = []; results.push(new _xhr_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; } // Otherwise, preload the images. Preload thumbs first, since they'll load // much faster. Only preload low-res images for image viewing if low res previews // are enabled. var results = []; for(var page of illust_data.mangaPages) results.push(new _img_preloader(page.urls.small)); // Only preload the first page, which is the main page of a regular illustration. // This also forces us to wait for the current image to load before preloading future // images, so we don't slow down loading the current image by preloading too early. if(illust_data.mangaPages.length >= 1) results.push(new _img_preloader(illust_data.mangaPages[0].urls.original)); return results; } preload_thumbs(illust_info) { // We're only interested in preloading thumbs for manga pages. if(illust_info.pageCount < 2) return; // Preload thumbs directly rather than queueing, since they load quickly and // this reduces flicker in the manga thumbnail bar. var thumbs = []; for(var page of illust_info.mangaPages) thumbs.push(page.urls.small); helpers.preload_images(thumbs); } }; // This should be inside whats_new, but Firefox is in the dark ages and doesn't support class fields. let _update_history = [ { 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 Alt-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." }, ]; class whats_new { // Return the newest revision that exists in history. This is always the first // history entry. static latest_history_revision() { return _update_history[0].version; } constructor(container) { this.container = container; this.refresh(); this.container.querySelector(".close-button").addEventListener("click", (e) => { this.hide(); }); // Close if the container is clicked, but not if something inside the container is clicked. this.container.addEventListener("click", (e) => { if(e.target != this.container) return; this.hide(); }); // Hide on any state change. window.addEventListener("popstate", (e) => { this.hide(); }); this.show(); } refresh() { let items_box = this.container.querySelector(".items"); // Not really needed, since our contents never change helpers.remove_elements(items_box); let item_template = document.body.querySelector(".template-version-history-item"); for(let update of _update_history) { let entry = helpers.create_from_template(item_template); entry.querySelector(".rev").innerText = "r" + update.version; entry.querySelector(".text").innerHTML = update.text; items_box.appendChild(entry); } } show() { this.container.hidden = false; } hide() { this.container.hidden = true; } }; var debug_show_ui = false; // This runs first and sets everything else up. class early_controller { constructor() { // Early initialization. This happens before anything on the page is loaded, since // this script runs at document-start. // // If this is an iframe, don't do anything. This may be a helper iframe loaded by // load_data_in_iframe, in which case the main page will do the work. if(window.top != window.self) return; // Don't activate for things like sketch.pixiv.net. if(document.location.hostname != "www.pixiv.net") return; console.log("ppixiv setup"); // catch_bind isn't available if we're not active, so we use bind here. this.dom_content_loaded = this.dom_content_loaded.bind(this); if(document.readyState == "loading") window.addEventListener("DOMContentLoaded", this.dom_content_loaded, true); else setTimeout(this.dom_content_loaded, 0); if(!page_manager.singleton().active) return; // Do early setup. This happens early in page loading, without waiting for DOMContentLoaded. // Unfortunately TamperMonkey doesn't correctly call us at the very start of the page in // Chrome, so this doesn't happen until some site scripts have had a chance to run. // Pixiv scripts run on DOMContentLoaded and load, whichever it sees first. Add capturing // listeners on both of these and block propagation, so those won't be run. This keeps most // of the site scripts from running underneath us. Make sure this is registered after our // own DOMContentLoaded listener above, or it'll block ours too. // // This doesn't always work in Chrome. TamperMonkey often runs user scripts very late, // even after DOMContentLoaded has already been sent, even in run-at: document-start. var stop_event = function(e) { e.preventDefault(); e.stopImmediatePropagation(); }; if(document.readyState == "loading") window.addEventListener("DOMContentLoaded", stop_event, true); window.addEventListener("load", stop_event, true); // 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(); // 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(var 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 prevent site scripts from running, since we don't need any of it. if(navigator.userAgent.indexOf("Firefox") != -1) helpers.block_all_scripts(); this.temporarily_hide_document(); } dom_content_loaded(e) { try { this.setup(); } catch(e) { // GM error logs don't make it to the console for some reason. console.log(e); } } 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(function(mutation_list) { if(document.documentElement == null) return; observer.disconnect(); document.documentElement.hidden = true; }); observer.observe(document, { attributes: false, childList: true, subtree: true }); }; // This is called on DOMContentLoaded (whether we're active or not). setup() { // If we're not active, stop without doing anything and leave the page alone. if(!page_manager.singleton().active) { // If we're disabled and can be enabled on this page, add our button. this.setup_disabled_ui(); if(page_manager.singleton().available()) { // 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); } return; } // Create the main controller. main_controller.create_singleton(); } // When we're disabled, but available on the current page, add the button to enable us. setup_disabled_ui() { // 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['disabled.html']); helpers.add_style('.ppixiv-disabled-ui > a { background-image: url("' + binary_data['activate-icon.png'] + '"); };'); // If we're on a page that we don't support, like the top page, rewrite the link to switch to // a page we do support. if(!page_manager.singleton().available()) disabled_ui.querySelector("a").href = "/ranking.php?mode=daily#ppixiv"; document.body.appendChild(disabled_ui); }; } // This handles high-level navigation and controlling the different views. class main_controller { // We explicitly create this singleton rather than doing it on the first call to // singleton(), so it's explicit when it's created. static create_singleton() { if(main_controller._singleton != null) throw "main_controller is already created"; new main_controller(); } static get singleton() { if(main_controller._singleton == null) throw "main_controller isn't created"; return main_controller._singleton; } constructor() { main_controller._singleton = this; this.onkeydown = this.onkeydown.catch_bind(this); this.redirect_event_to_view = this.redirect_event_to_view.catch_bind(this); this.window_onclick_capture = this.window_onclick_capture.catch_bind(this); this.window_onpopstate = this.window_onpopstate.catch_bind(this); // Create the page manager. page_manager.singleton(); this.setup(); }; async setup() { // This format is used on at least /new_illust.php. let global_data = document.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. var pixiv = helpers.get_pixiv_data(document); // Pixiv scripts that use meta-global-data remove the element from the page after // it's parsed for some reason. Since browsers are too broken to allow user scripts // to reliably run before site scripts, it's hard for us to guarantee that we can // get this data before it's removed. // // If we didn't get any init data, reload the page in an iframe and look for meta-global-data // again. This request doesn't allow scripts to run. At least in Chrome, this comes out of // cache, so it doesn't actually cause us to load the page twice. if(global_data == null && pixiv == null) { console.log("Reloading page to get init data"); let url = new URL(document.location); let result = await helpers.load_data_in_iframe(url.toString()); global_data = result.querySelector("#meta-global-data"); if(global_data != null) global_data = JSON.parse(global_data.getAttribute("content")); console.log("Finished loading init data"); } // 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 we don't have either of these (or we're logged out), stop and let the regular page display. // It may be a page we don't support. if(global_data == null && pixiv == null) { console.log("Couldn't find context data. Are we logged in?"); document.documentElement.hidden = false; return; } console.log("Starting"); // We know that we have enough info to continue, so we can do this now. // // Try to prevent the underlying page from making requests. It would be better to do this // earlier, in early_controller's constructor, but we don't know for sure whether we'll be // able to continue at that point, and we don't want to do this if we aren't. This used to // matter more, but since browsers are bad and don't reliably allow user scripts to run early // anymore, this wouldn't prevent all early network requests anyway. // // This needs to be done before calling anything else, or our internal network requests // won't work. helpers.block_network_requests(); // Also block creating script and style elements. helpers.block_elements(); if(global_data != null) { this.init_global_data(global_data.token, global_data.userData.id, global_data.userData.premium, global_data.mute, global_data.userData.adult); 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]); } } else { this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium, pixiv.user.mutes, pixiv.user.explicit); } window.addEventListener("click", this.window_onclick_capture); window.addEventListener("popstate", this.window_onpopstate); window.addEventListener("keyup", this.redirect_event_to_view, true); window.addEventListener("keydown", this.redirect_event_to_view, true); window.addEventListener("keypress", this.redirect_event_to_view, true); window.addEventListener("keydown", this.onkeydown); this.current_view_name = null; this.current_history_index = helpers.current_history_state_index(); // If the URL hash doesn't start with #ppixiv, the page was loaded with the base Pixiv // URL, and we're active by default. Add #ppixiv to the URL. If we don't do this, we'll // still work, but none of the URLs we create will have #ppixiv, so we won't handle navigation // directly and the page will reload on every click. Do this before we create any of our // UI, so our links inherit the hash. if(helpers.parse_hash(document.location) == null) { // Don't create a new history state. let newURL = new URL(document.location); newURL.hash = "#ppixiv"; history.replaceState(null, "", newURL.toString()); } // Don't restore the scroll position. // // If we browser back to a search page and we were scrolled ten pages down, scroll // restoration will try to scroll down to it incrementally, causing us to load all // data in the search from the top all the way down to where we were. This can cause // us to spam the server with dozens of requests. This happens on F5 refresh, which // isn't useful (if you're refreshing a search page, you want to see new results anyway), // and recommendations pages are different every time anyway. // // This won't affect browser back from an image to the enclosing search. history.scrollRestoration = "manual"; // Remove everything from the page and move it into a dummy document. var html = document.createElement("document"); helpers.move_children(document.head, html); helpers.move_children(document.body, html); // Copy the location to the document copy, so the data source can tell where // it came from. html.location = document.location; // Now that we've cleared the document, we can unhide it. document.documentElement.hidden = false; // Add binary resources as CSS styles. helpers.add_style('body .noise-background { background-image: url("' + binary_data['noise.png'] + '"); };'); helpers.add_style('body.light .noise-background { background-image: url("' + binary_data['noise-light.png'] + '"); };'); helpers.add_style('.ugoira-icon { background-image: url("' + binary_data['play-button.svg'] + '"); };'); helpers.add_style('.page-icon { background-image: url("' + binary_data['page-icon.png'] + '"); };'); helpers.add_style('.page-count-box:hover .page-icon { background-image: url("' + binary_data['page-icon-hover.png'] + '"); };'); // Add the main CSS style. helpers.add_style(resources['main.css']); // Create the page from our HTML resource. document.body.insertAdjacentHTML("beforeend", resources['main.html']); // Create the shared title and page icon. document.head.appendChild(document.createElement("title")); var document_icon = document.head.appendChild(document.createElement("link")); document_icon.setAttribute("rel", "icon"); helpers.add_clicks_to_search_history(document.body); this.container = document.body; // Create the popup menu handler. this.context_menu = new main_context_menu(document.body); // Create the main progress bar. this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar")); // Create the thumbnail view handler. this.thumbnail_view = new view_search(this.container.querySelector(".view-search-container")); // Create the manga page viewer. this.manga_view = new view_manga(this.container.querySelector(".view-manga-container")); // Create the main UI. this.ui = new view_illust(this.container.querySelector(".view-illust-container")); this.views = { search: this.thumbnail_view, illust: this.ui, manga: this.manga_view, }; // Create the data source for this page. this.set_current_data_source(html, "initialization"); }; window_onpopstate(e) { // A special case for the bookmarks data source. It changes its page in the URL to mark // how far the user has scrolled. We don't want this to trigger a data source change. if(this.temporarily_ignore_onpopstate) { console.log("Not navigating for internal page change"); return; } // Set the current data source and state. this.set_current_data_source(null, 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", document.location.toString()); await page_manager.singleton().create_data_source_for_url(document.location, null, true); await this.set_current_data_source(null, "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. // // If this is on startup, html is the HTML elements on the page to pass to the data source // to preload the first page. On navigation, html is null. If we navigate to a page that // can load the first page from the HTML page, we won't load the HTML and we'll just allow // the first page to load like any other page. async set_current_data_source(html, cause) { // Remember what we were displaying before we start changing things. var old_view = this.views[this.current_view_name]; var old_illust_id = old_view? old_view.displayed_illust_id:null; var old_illust_page = old_view? old_view.displayed_illust_page:null; // Get the current data source. If we've already created it, this will just return // the same object and not create a new one. var data_source = await page_manager.singleton().create_data_source_for_url(document.location, html); // If the data source supports_start_page, and a link was clicked on a page that isn't currently // loaded, create a new data source. If we're on page 5 of bookmarks and the user clicks a link // for page 1 (the main bookmarks navigation button) or page 10, the current data source can't // display that since we'd need to load every page in-between to keep pages contiguous, so we // just create a new data source. // // This doesn't work great for jumping to arbitrary pages (we don't handle scrolling to that page // very well), but it at least makes rewinding to the first page work. if(data_source == this.data_source && data_source.supports_start_page) { let args = helpers.get_args(document.location); let wanted_page = this.data_source.get_start_page(args); let lowest_page = data_source.id_list.get_lowest_loaded_page(); let highest_page = data_source.id_list.get_highest_loaded_page(); if(wanted_page < lowest_page || wanted_page > highest_page) { // This works the same as refresh_current_data_source above. console.log("Resetting data source to an unavailable page:", lowest_page, wanted_page, highest_page); data_source = await page_manager.singleton().create_data_source_for_url(document.location, null, true); } } // If the data source is changing, set it. if(this.data_source != data_source) { // Shut down the old data source. if(this.data_source != null) this.data_source.shutdown(); // 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.ui.set_data_source(data_source); this.thumbnail_view.set_data_source(data_source); this.context_menu.set_data_source(data_source); if(this.data_source != null) this.data_source.startup(); } if(data_source == null) return; // Figure out which view to display. var new_view_name; var args = helpers.get_args(document.location); if(!args.hash.has("view")) new_view_name = data_source.default_view; else new_view_name = args.hash.get("view"); var illust_id = data_source.get_current_illust_id(); var manga_page = args.hash.has("page")? parseInt(args.hash.get("page"))-1:0; // If we're on search, we don't care what image is current. Clear illust_id so we // tell context_menu that we're not viewing anything, so it disables bookmarking. if(new_view_name == "search") illust_id = null; console.log("Loading data source. View:", new_view_name, "Cause:", cause, "URL:", document.location.href); console.log(" Show image", illust_id, "page", manga_page); // Mark the current view. Other code can watch for this to tell which view is // active. document.body.dataset.currentView = new_view_name; // Set the image before activating the view. If we do this after activating it, // it'll start loading any previous image it was pointed at. Don't do this in // search mode, or we'll start loading the default image. if(new_view_name == "illust") this.ui.show_image(illust_id, manga_page); else if(new_view_name == "manga") this.manga_view.shown_illust_id = illust_id; var new_view = this.views[new_view_name]; // main_context_menu uses this to see which view is active. document.body.dataset.currentView = new_view_name; this.context_menu.illust_id = illust_id; // If we're changing between views, update the active view. var view_changing = new_view != old_view; if(view_changing) { this.current_view_name = new_view_name; // Make sure we deactivate the old view before activating the new one. if(old_view != null) old_view.active = false; if(new_view != null) new_view.active = true; // Dismiss any message when toggling between views. message_widget.singleton.hide(); } // If we're enabling the thumbnail, pulse the image that was just being viewed (or // loading to be viewed), to make it easier to find your place. if(new_view_name == "search" && old_illust_id != null) this.thumbnail_view.pulse_thumbnail(old_illust_id); // Are we navigating forwards or back? var new_history_index = helpers.current_history_state_index(); var navigating_forwards = cause == "history" && new_history_index > this.current_history_index; this.current_history_index = new_history_index; // Handle scrolling for the new state. // // We could do this better with history.state (storing each state's scroll position would // allow it to restore across browser sessions, and if the same data source is multiple // places in history). Unfortunately there's no way to update history.state without // calling history.replaceState, which is slow and causes jitter. history.state being // read-only is a design bug in the history API. if(cause == "navigation") { // If this is an initial navigation, eg. from a user clicking a link to a search, always // scroll to the top. If this data source exists previously in history, we don't want to // restore the scroll position from back then. console.log("Scroll to top for new search"); new_view.scroll_to_top(); } else if(navigating_forwards) { // On browser history forwards, try to restore the scroll position. console.log("Restore scroll position for forwards navigation"); new_view.restore_scroll_position(); } else if(view_changing && old_illust_id != null) { // If we're navigating backwards or toggling, and we're switching from the image UI to thumbnails, // try to scroll the thumbnail view to the image that was displayed. Otherwise, tell // the thumbnail view to restore any scroll position saved in the data source. console.log("Scroll to", old_illust_id, old_illust_page); new_view.scroll_to_illust_id(old_illust_id, old_illust_page); } else { new_view.restore_scroll_position(); } } show_data_source_specific_elements() { // Show UI elements with this data source in their data-datasource attribute. var data_source_name = this.data_source.name; for(var node of this.container.querySelectorAll(".data-source-specific[data-datasource]")) { var data_sources = node.dataset.datasource.split(" "); var show_element = data_sources.indexOf(data_source_name) != -1; node.hidden = !show_element; } } // Show an illustration by ID. // // This actually just sets the history URL. We'll do the rest of the work in popstate. show_illust(illust_id, options) { if(options == null) options = {}; var manga_page = options.manga_page != null? options.manga_page:null; var add_to_history = options.add_to_history || false; var view = options.view || "illust"; // Sanity check: if(illust_id == null) { console.error("Invalid illust_id", illust_id); return; } // Set the wanted illust_id in the URL, and disable the thumb view so we show // the image. Do this in a single URL update, so we don't add multiple history // entries. var args = helpers.get_args(document.location); this._set_active_view_in_url(args.hash, view); this.data_source.set_current_illust_id(illust_id, args); // Remove any leftover page from the current illust. We'll load the default. if(manga_page == null) args.hash.delete("page"); else args.hash.set("page", manga_page + 1); helpers.set_args(args, add_to_history, "navigation"); } // Return the displayed view instance. get displayed_view() { for(var view_name in this.views) { var view = this.views[view_name]; if(view.active) return view; } return null; } _set_active_view_in_url(hash_args, view) { hash_args.set("view", view); } set_displayed_view_by_name(view, add_to_history, cause) { // Update the URL to mark whether thumbs are displayed. var args = helpers.get_args(document.location); this._set_active_view_in_url(args.hash, view); helpers.set_args(args, add_to_history, cause); } // Navigate out. // // This navigates from the illust page to the manga page (for multi-page posts) or search, and // from the manga page to search. // // This is similar to browser back, but allows moving up to the search even for new tabs. It // would be better for this to integrate with browser history (just browser back if browser back // is where we're going), but for some reason you can't view history state entries even if they're // on the same page, so there's no way to tell where History.back() would take us. _get_navigate_out_target() { var new_page = null; var view = this.displayed_view; // This gets called by the popup menu when it's created before we have any view. if(view == null) return [null, null]; if(view == this.views.manga) { return ["search", "search"]; } else if(view == this.views.illust) { var page_count = view.current_illust_data != null? view.current_illust_data.pageCount:1; if(page_count > 1) return ["manga", "page list"]; else return ["search", "search"]; } else return [null, null]; } get navigate_out_label() { var target = this._get_navigate_out_target(); return target[1]; } navigate_out() { var target = this._get_navigate_out_target(); var new_page = target[0]; if(new_page != null) this.set_displayed_view_by_name(new_page, 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 views, // rather than loading a new page, we just set up a new data source, so we // don't have to do a full navigation. // // This only affects left clicks (middle clicks into a new tab still behave // normally). window_onclick_capture(e) { // Only intercept regular left clicks. if(e.button != 0 || e.metaKey || e.ctrlKey || e.altKey) return; if(!(e.target instanceof Element)) return; // Look up from the target for a link. var a = e.target.closest("A"); if(a == null) return; // If this isn't a #ppixiv URL, let it run normally. var url = new URL(a.href, document.href); var is_ppixiv_url = helpers.parse_hash(url) != null; if(!is_ppixiv_url) return; // Stop all handling for this link. e.preventDefault(); e.stopImmediatePropagation(); // Search links to images always go to /artworks/#, but if they're clicked in-page we // want to stay on the same search and just show the image, so handle them directly. var url = new unsafeWindow.URL(url); url = helpers.get_url_without_language(url); if(url.pathname.startsWith("/artworks/")) { let parts = url.pathname.split("/"); let illust_id = parts[2]; var args = helpers.get_args(a.href); var page = args.hash.has("page")? parseInt(args.hash.get("page"))-1: null; var view = args.hash.has("view")? args.hash.get("view"):"illust"; this.show_illust(illust_id, { view: view, manga_page: page, add_to_history: true }); return; } // Navigate to the URL in-page. helpers.set_page_url(url, true /* add to history */, "navigation"); } init_global_data(csrf_token, user_id, premium, mutes, content_mode) { var muted_tags = []; var muted_user_ids = []; for(var mute of mutes) { if(mute.type == 0) muted_tags.push(mute.value); else if(mute.type == 1) muted_user_ids.push(mute.value); } muting.singleton.set_muted_tags(muted_tags); muting.singleton.set_muted_user_ids(muted_user_ids); window.global_data = { // Store the token for XHR requests. csrf_token: csrf_token, user_id: user_id, include_r18: content_mode >= 1, include_r18g: content_mode >= 2, }; // 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", premium); // These are used to hide buttons that the user has disabled. helpers.set_class(document.body, "hide-r18", !window.global_data.include_r18); helpers.set_class(document.body, "hide-r18g", !window.global_data.include_r18g); }; // Redirect keyboard events that didn't go into the active view. redirect_event_to_view(e) { var view = this.displayed_view; if(view == 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 view, redirect // it to the view's container. var target = e.target; // If the event is going to an element inside the view already, just let it continue. if(helpers.is_above(view.container, e.target)) return; // Clone the event and redispatch it to the view's container. var e2 = new e.constructor(e.type, e); if(!view.container.dispatchEvent(e2)) { e.preventDefault(); e.stopImmediatePropagation(); return; } } onkeydown(e) { // Ignore keypresses if we haven't set up the view yet. var view = this.displayed_view; if(view == null) return; // If a popup is open, leave inputs alone and don't process hotkeys. if(document.body.dataset.popupOpen) return; if(e.keyCode == 27) // escape { e.preventDefault(); e.stopPropagation(); this.navigate_out(); return; } // Let the view handle the input. view.handle_onkeydown(e); } }; new early_controller(); })();